agent-protocols 0.4.0 → 0.5.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 (62) hide show
  1. package/README.md +6 -5
  2. package/dist/delegation.d.ts +111 -0
  3. package/dist/delegation.d.ts.map +1 -0
  4. package/dist/delegation.js +132 -0
  5. package/dist/delegation.js.map +1 -0
  6. package/dist/discourse.d.ts +76 -3
  7. package/dist/discourse.d.ts.map +1 -1
  8. package/dist/discourse.js +161 -3
  9. package/dist/discourse.js.map +1 -1
  10. package/dist/errors.d.ts +6 -2
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +7 -3
  13. package/dist/errors.js.map +1 -1
  14. package/dist/http-client.d.ts +24 -2
  15. package/dist/http-client.d.ts.map +1 -1
  16. package/dist/http-client.js +61 -4
  17. package/dist/http-client.js.map +1 -1
  18. package/dist/identity.d.ts +17 -0
  19. package/dist/identity.d.ts.map +1 -1
  20. package/dist/identity.js +40 -8
  21. package/dist/identity.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/local-connector/catalog.d.ts +55 -0
  27. package/dist/local-connector/catalog.d.ts.map +1 -0
  28. package/dist/local-connector/catalog.js +151 -0
  29. package/dist/local-connector/catalog.js.map +1 -0
  30. package/dist/local-connector/engine.d.ts +94 -0
  31. package/dist/local-connector/engine.d.ts.map +1 -0
  32. package/dist/local-connector/engine.js +1234 -0
  33. package/dist/local-connector/engine.js.map +1 -0
  34. package/dist/local-connector/inputs.d.ts +231 -0
  35. package/dist/local-connector/inputs.d.ts.map +1 -0
  36. package/dist/local-connector/inputs.js +4 -0
  37. package/dist/local-connector/inputs.js.map +1 -0
  38. package/dist/local-connector/internal.d.ts +6 -0
  39. package/dist/local-connector/internal.d.ts.map +1 -0
  40. package/dist/local-connector/internal.js +18 -0
  41. package/dist/local-connector/internal.js.map +1 -0
  42. package/dist/local-connector/projection.d.ts +16 -0
  43. package/dist/local-connector/projection.d.ts.map +1 -0
  44. package/dist/local-connector/projection.js +306 -0
  45. package/dist/local-connector/projection.js.map +1 -0
  46. package/dist/local-connector/state.d.ts +66 -0
  47. package/dist/local-connector/state.d.ts.map +1 -0
  48. package/dist/local-connector/state.js +18 -0
  49. package/dist/local-connector/state.js.map +1 -0
  50. package/dist/local-connector/views.d.ts +169 -0
  51. package/dist/local-connector/views.d.ts.map +1 -0
  52. package/dist/local-connector/views.js +98 -0
  53. package/dist/local-connector/views.js.map +1 -0
  54. package/dist/local-connector.d.ts +5 -230
  55. package/dist/local-connector.d.ts.map +1 -1
  56. package/dist/local-connector.js +26 -241
  57. package/dist/local-connector.js.map +1 -1
  58. package/dist/profile.d.ts +18 -1
  59. package/dist/profile.d.ts.map +1 -1
  60. package/dist/profile.js +16 -1
  61. package/dist/profile.js.map +1 -1
  62. package/package.json +7 -3
@@ -0,0 +1,1234 @@
1
+ // The stateful local connector engine. `LocalConnector` is transport-neutral:
2
+ // it signs on the active agent's behalf, calls an Agent Discourse host over
3
+ // HTTP, materializes local room state by projecting accepted records, derives
4
+ // an actionable inbox, and holds drafts on a room-head mismatch — all behind a
5
+ // single `callTool` dispatcher. The agent never sees the signing key or a
6
+ // reusable request JWT. This is one deep module; its methods are mutually
7
+ // recursive over `this`, so the class stays whole here while the data shapes
8
+ // and pure projection live in sibling submodules.
9
+ import { discourseEvent, eventType, roomCreateEvent, validateDiscourseEnvelope, validateRoomPath, verifyServerRecord, } from "../discourse.js";
10
+ import { ClientNonceManager, DEFAULT_REQUEST_JWT_TTL_SECS, createRequestBinding, createRequestJwtClaims, publicKeyBytes, serviceOrigin, unixTimeMillis, unixTimeSecs, withMentions, } from "../identity.js";
11
+ import { DiscourseClient, ProfileClient } from "../http-client.js";
12
+ import { profileUpdateEvent, } from "../profile.js";
13
+ import { TOOL_AGENT_STATUS_CLEAR, TOOL_AGENT_STATUS_GET, TOOL_AGENT_STATUS_LIST, TOOL_AGENT_STATUS_SET, TOOL_DRAFTS_LIST, TOOL_DRAFT_COMMIT, TOOL_DRAFT_DROP, TOOL_DRAFT_GET, TOOL_HOSTS_LIST, TOOL_IDENTITY_CURRENT, TOOL_INBOX_ACK, TOOL_INBOX_NEXT, TOOL_JOIN_REQUESTS_LIST, TOOL_JOIN_REQUEST_REVIEW, TOOL_PROFILE_UPDATE, TOOL_ROOMS_LIST, TOOL_ROOMS_SEARCH, TOOL_ROOM_CREATE, TOOL_ROOM_JOIN, TOOL_ROOM_JOIN_REQUEST, TOOL_ROOM_JOIN_WHEN_APPROVED, TOOL_ROOM_LEAVE, TOOL_ROOM_MARK_READ, TOOL_ROOM_MEMBERS_LIST, TOOL_ROOM_MEMBER_GET, TOOL_ROOM_OPEN, TOOL_ROOM_SEND_MESSAGE, TOOL_ROOM_STATE, TOOL_ROOM_SUBMIT_EVENT, TOOL_ROOM_TIMELINE, TOOL_ROOM_UNREAD, } from "./catalog.js";
14
+ import { invalidPayload, isRecord, normalizeHost, permissionDenied, } from "./internal.js";
15
+ import { applyRecordProjection, eventTypeAdvancesRoomHead, inboxEntryReady, isDuplicateRecord, materializeCreator, membershipFilter, recordAdvancesRoomHead, validateNextRecord, validateRecordBasePrecondition, } from "./projection.js";
16
+ import { LocalConnectorState } from "./state.js";
17
+ import { roomSummaryFromResponse, timelineItemFromRecord, } from "./views.js";
18
+ function roomKeyString(key) {
19
+ return `${key.host} ${key.roomId}`;
20
+ }
21
+ function newLocalRoomState(host, room) {
22
+ return {
23
+ host,
24
+ room,
25
+ headSeq: 0,
26
+ syncedSeq: 0,
27
+ subscribed: false,
28
+ members: new Map(),
29
+ timeline: [],
30
+ records: [],
31
+ readSeq: 0,
32
+ };
33
+ }
34
+ function unreadCount(room) {
35
+ return room.timeline.filter((item) => item.seq > room.readSeq).length;
36
+ }
37
+ // ── Room metadata getters: prefer the host-materialized value, fall back to
38
+ // the signed room.create payload (older hosts may omit derived fields).
39
+ function roomCreatePayload(room) {
40
+ return room.envelope?.event.payload;
41
+ }
42
+ function roomTopic(room) {
43
+ return room.topic ?? roomCreatePayload(room)?.topic;
44
+ }
45
+ function roomAgenda(room) {
46
+ return room.agenda ?? roomCreatePayload(room)?.agenda;
47
+ }
48
+ function roomGuidance(room) {
49
+ return room.guidance ?? roomCreatePayload(room)?.guidance;
50
+ }
51
+ function roomVisibility(room) {
52
+ return room.visibility ?? roomCreatePayload(room)?.visibility;
53
+ }
54
+ function roomStartTime(room) {
55
+ return room.start_time ?? roomCreatePayload(room)?.start_time;
56
+ }
57
+ function roomEndTime(room) {
58
+ return room.end_time ?? roomCreatePayload(room)?.end_time;
59
+ }
60
+ function roomTags(room) {
61
+ if (room.tags && room.tags.length > 0)
62
+ return room.tags;
63
+ return roomCreatePayload(room)?.tags ?? [];
64
+ }
65
+ function roomLanguage(room) {
66
+ return room.language ?? roomCreatePayload(room)?.language;
67
+ }
68
+ function roomPolicy(room) {
69
+ return room.policy ?? roomCreatePayload(room)?.policy;
70
+ }
71
+ function roomResponseHead(room) {
72
+ return room.head ? [room.head.seq, room.head.hash] : [room.seq, room.hash];
73
+ }
74
+ function payloadWithReferences(payload, references) {
75
+ if (references.length === 0)
76
+ return payload;
77
+ const extra = isRecord(payload.extra) ? payload.extra : {};
78
+ extra.references = references;
79
+ payload.extra = extra;
80
+ return payload;
81
+ }
82
+ function messageDraftValue(input) {
83
+ return {
84
+ room_id: input.room_id,
85
+ content: input.content,
86
+ content_type: input.content_type ?? "text/plain",
87
+ mentions: input.mentions ?? [],
88
+ references: input.references ?? [],
89
+ extra: input.extra ?? {},
90
+ };
91
+ }
92
+ function eventDraftValue(input) {
93
+ return {
94
+ room_id: input.room_id,
95
+ type: input.type,
96
+ payload: input.payload,
97
+ mentions: input.mentions ?? [],
98
+ references: input.references ?? [],
99
+ };
100
+ }
101
+ function heldDraftOptions() {
102
+ return ["revise", "send_as_is", "stay_silent", "send_anyway"];
103
+ }
104
+ function profileToMemberProfile(profile) {
105
+ return {
106
+ name: profile.name,
107
+ description: profile.description,
108
+ avatar_url: profile.avatar_url,
109
+ };
110
+ }
111
+ function base64UrlEncode(bytes) {
112
+ return Buffer.from(bytes).toString("base64url");
113
+ }
114
+ function compareStrings(a, b) {
115
+ return a < b ? -1 : a > b ? 1 : 0;
116
+ }
117
+ function parseCursor(cursor) {
118
+ if (cursor === undefined)
119
+ return 0;
120
+ const parsed = Number.parseInt(cursor, 10);
121
+ return Number.isNaN(parsed) ? 0 : parsed;
122
+ }
123
+ function sortedAgentStatuses(statuses) {
124
+ return [...statuses.entries()]
125
+ .sort((a, b) => compareStrings(a[0], b[0]))
126
+ .map(([, status]) => status);
127
+ }
128
+ export class LocalConnector {
129
+ signer;
130
+ state;
131
+ nonces = new ClientNonceManager();
132
+ fetchImpl;
133
+ constructor(signer, options = {}) {
134
+ this.signer = signer;
135
+ this.state = options.state ?? new LocalConnectorState();
136
+ this.fetchImpl = options.fetchImpl ?? fetch;
137
+ }
138
+ agentId() {
139
+ return this.signer.agentId();
140
+ }
141
+ addHost(host) {
142
+ this.state.hosts.set(normalizeHost(host.host), host);
143
+ }
144
+ observeRoom(host, room) {
145
+ const normalized = normalizeHost(host);
146
+ this.ensureHost(normalized);
147
+ const key = { host: normalized, roomId: room.id };
148
+ const keyStr = roomKeyString(key);
149
+ let entry = this.state.rooms.get(keyStr);
150
+ if (!entry) {
151
+ entry = newLocalRoomState(normalized, room);
152
+ this.state.rooms.set(keyStr, entry);
153
+ }
154
+ entry.host = normalized;
155
+ entry.room = room;
156
+ materializeCreator(entry);
157
+ }
158
+ acceptRoomResponse(host, room) {
159
+ const normalized = normalizeHost(host);
160
+ const key = { host: normalized, roomId: room.id };
161
+ this.observeRoom(normalized, room);
162
+ const entry = this.state.rooms.get(roomKeyString(key));
163
+ if (entry) {
164
+ const [headSeq, headHash] = roomResponseHead(entry.room);
165
+ entry.headSeq = headSeq;
166
+ entry.headHash = headHash;
167
+ entry.syncedSeq = entry.room.seq;
168
+ entry.syncedHash = entry.room.hash;
169
+ }
170
+ }
171
+ /** Applies a verified record to the room named by its `room_id` alone. Throws
172
+ * an ambiguity error when the room ID is open on more than one host. */
173
+ applyRecord(record) {
174
+ const key = this.resolveRoomKey(undefined, record.room_id);
175
+ this.applyRecordTo(key, record);
176
+ }
177
+ /** Applies a verified record to the room on the given host. */
178
+ applyHostRecord(host, record) {
179
+ const key = { host: normalizeHost(host), roomId: record.room_id };
180
+ this.applyRecordTo(key, record);
181
+ }
182
+ applyRecordTo(key, record) {
183
+ validateDiscourseEnvelope(record.envelope);
184
+ validateRoomPath(record.envelope, record.room_id);
185
+ verifyServerRecord(record);
186
+ const activeAgent = this.agentId();
187
+ const room = this.state.rooms.get(roomKeyString(key));
188
+ if (!room) {
189
+ throw invalidPayload(`room is not open locally: ${key.roomId}`);
190
+ }
191
+ if (isDuplicateRecord(room, record))
192
+ return;
193
+ validateNextRecord(room, record);
194
+ validateRecordBasePrecondition(room, record);
195
+ const item = timelineItemFromRecord(record);
196
+ const newInbox = [];
197
+ applyRecordProjection(room, record, item, activeAgent, newInbox);
198
+ let clearedStatus;
199
+ if (record.envelope.event.type === eventType.ROOM_MEMBER_REMOVE) {
200
+ const payload = record.envelope.event.payload;
201
+ clearedStatus = payload?.member;
202
+ }
203
+ if (recordAdvancesRoomHead(room, record)) {
204
+ room.headSeq = record.seq;
205
+ room.headHash = record.hash;
206
+ }
207
+ room.syncedSeq = record.seq;
208
+ room.syncedHash = record.hash;
209
+ room.records.push(record);
210
+ room.timeline.push(item);
211
+ // Removal ends membership; the host clears the member's transient status,
212
+ // so drop the local cache entry too.
213
+ if (clearedStatus !== undefined) {
214
+ this.state.agentStatuses.get(roomKeyString(key))?.delete(clearedStatus);
215
+ }
216
+ for (const inboxItem of newInbox)
217
+ this.insertInbox(inboxItem);
218
+ }
219
+ resolveRoomKey(host, roomId) {
220
+ if (host !== undefined)
221
+ return { host: normalizeHost(host), roomId };
222
+ const hosts = [...this.state.rooms.values()]
223
+ .filter((room) => room.room.id === roomId)
224
+ .map((room) => room.host);
225
+ if (hosts.length === 1)
226
+ return { host: hosts[0], roomId };
227
+ if (hosts.length > 1) {
228
+ throw invalidPayload(`room id ${roomId} matches rooms on more than one host; pass host`);
229
+ }
230
+ throw invalidPayload(`room is not open locally: ${roomId}`);
231
+ }
232
+ async callTool(name, input) {
233
+ switch (name) {
234
+ case TOOL_IDENTITY_CURRENT:
235
+ return this.identityCurrent();
236
+ case TOOL_HOSTS_LIST:
237
+ return this.hostsList();
238
+ case TOOL_ROOMS_SEARCH:
239
+ return this.roomsSearch(input);
240
+ case TOOL_ROOMS_LIST:
241
+ return this.roomsList(input);
242
+ case TOOL_ROOM_OPEN:
243
+ return this.roomOpen(input);
244
+ case TOOL_ROOM_STATE:
245
+ return this.roomStateTool(input);
246
+ case TOOL_ROOM_MEMBERS_LIST:
247
+ return this.roomMembersList(input);
248
+ case TOOL_ROOM_MEMBER_GET:
249
+ return this.roomMemberGet(input);
250
+ case TOOL_AGENT_STATUS_LIST:
251
+ return this.agentStatusList(input);
252
+ case TOOL_AGENT_STATUS_GET:
253
+ return this.agentStatusGet(input);
254
+ case TOOL_AGENT_STATUS_SET:
255
+ return this.agentStatusSet(input);
256
+ case TOOL_AGENT_STATUS_CLEAR:
257
+ return this.agentStatusClear(input);
258
+ case TOOL_ROOM_TIMELINE:
259
+ return this.roomTimeline(input);
260
+ case TOOL_ROOM_UNREAD:
261
+ return this.roomUnread(input);
262
+ case TOOL_ROOM_MARK_READ:
263
+ return this.roomMarkRead(input);
264
+ case TOOL_INBOX_NEXT:
265
+ return this.inboxNext(input);
266
+ case TOOL_INBOX_ACK:
267
+ return this.inboxAck(input);
268
+ case TOOL_DRAFTS_LIST:
269
+ return this.draftsList(input);
270
+ case TOOL_DRAFT_GET:
271
+ return this.draftGet(input);
272
+ case TOOL_DRAFT_COMMIT:
273
+ return this.draftCommit(input);
274
+ case TOOL_DRAFT_DROP:
275
+ return this.draftDrop(input);
276
+ case TOOL_PROFILE_UPDATE:
277
+ return this.profileUpdate(input);
278
+ case TOOL_ROOM_CREATE:
279
+ return this.roomCreate(input);
280
+ case TOOL_ROOM_JOIN:
281
+ return this.roomJoin(input);
282
+ case TOOL_ROOM_JOIN_REQUEST:
283
+ return this.roomJoinRequest(input);
284
+ case TOOL_ROOM_JOIN_WHEN_APPROVED:
285
+ return this.roomJoinWhenApproved(input);
286
+ case TOOL_ROOM_LEAVE:
287
+ return this.roomLeave(input);
288
+ case TOOL_ROOM_SEND_MESSAGE:
289
+ return this.roomSendMessage(input);
290
+ case TOOL_ROOM_SUBMIT_EVENT:
291
+ return this.roomSubmitEvent(input);
292
+ case TOOL_JOIN_REQUESTS_LIST:
293
+ return this.joinRequestsList(input);
294
+ case TOOL_JOIN_REQUEST_REVIEW:
295
+ return this.joinRequestReview(input);
296
+ default:
297
+ throw invalidPayload(`unknown local connector tool: ${name}`);
298
+ }
299
+ }
300
+ identityCurrent() {
301
+ const agentId = this.agentId();
302
+ return {
303
+ agent_id: agentId,
304
+ public_key: base64UrlEncode(publicKeyBytes(agentId)),
305
+ profiles: [...this.state.profiles.keys()].sort(compareStrings),
306
+ hosts: this.sortedHosts(),
307
+ };
308
+ }
309
+ hostsList() {
310
+ return { hosts: this.sortedHosts() };
311
+ }
312
+ async roomsSearch(input) {
313
+ const host = normalizeHost(input.host);
314
+ this.requireAllowedHost(host);
315
+ const rooms = await this.discourse(host).publicRooms({
316
+ status: input.status,
317
+ tag: input.tag,
318
+ keyword: input.keyword,
319
+ creator: input.creator,
320
+ startsAfter: input.starts_after,
321
+ endsBefore: input.ends_before,
322
+ language: input.language,
323
+ limit: input.limit,
324
+ cursor: input.cursor,
325
+ });
326
+ for (const room of rooms)
327
+ this.observeRoom(host, room);
328
+ const summaries = rooms.map((room) => this.summaryForResponse(host, room));
329
+ return { rooms: summaries };
330
+ }
331
+ roomsList(input) {
332
+ const offset = parseCursor(input.cursor);
333
+ const limit = input.limit ?? 50;
334
+ const agentId = this.agentId();
335
+ const rooms = [...this.state.rooms.values()]
336
+ .sort((a, b) => compareStrings(a.host, b.host) ||
337
+ compareStrings(a.room.id, b.room.id))
338
+ .filter((room) => input.status !== undefined ? room.room.status === input.status : true)
339
+ .filter((room) => membershipFilter(room, agentId, input.membership))
340
+ .slice(offset, offset + limit)
341
+ .map((room) => this.summaryForRoom(room));
342
+ return { rooms };
343
+ }
344
+ async roomOpen(input) {
345
+ const host = normalizeHost(input.host);
346
+ this.requireAllowedHost(host);
347
+ const key = { host, roomId: input.room_id };
348
+ const previousSeq = this.state.rooms.get(roomKeyString(key))?.syncedSeq ?? 0;
349
+ if (input.refresh || previousSeq === 0) {
350
+ const client = this.discourse(host);
351
+ const room = await client.room(input.room_id);
352
+ this.observeRoom(host, room);
353
+ const jwt = this.requestJwt(host);
354
+ const records = await client.events(input.room_id, {
355
+ afterSeq: previousSeq > 0 ? previousSeq : undefined,
356
+ jwt,
357
+ });
358
+ for (const record of records)
359
+ this.applyHostRecord(host, record);
360
+ }
361
+ const opened = this.state.rooms.get(roomKeyString(key));
362
+ if (opened)
363
+ opened.subscribed = input.subscribe ?? false;
364
+ const room = this.localRoom(key);
365
+ return {
366
+ room: this.roomStateView(room),
367
+ sync: this.syncState(key),
368
+ active_turn: room.activeTurn,
369
+ };
370
+ }
371
+ async roomStateTool(input) {
372
+ void input.include_types;
373
+ const key = this.resolveRoomKey(input.host, input.room_id);
374
+ if (input.refresh) {
375
+ const host = this.localRoom(key).host;
376
+ return this.roomOpen({ host, room_id: input.room_id, refresh: true });
377
+ }
378
+ const room = this.localRoom(key);
379
+ return { room: this.roomStateView(room), sync: this.syncState(key) };
380
+ }
381
+ roomMembersList(input) {
382
+ const key = this.resolveRoomKey(input.host, input.room_id);
383
+ const room = this.localRoom(key);
384
+ const offset = parseCursor(input.cursor);
385
+ const limit = input.limit ?? 100;
386
+ const members = [...room.members.values()]
387
+ .sort((a, b) => compareStrings(a.agent_id, b.agent_id))
388
+ .filter((member) => input.status !== undefined ? member.status === input.status : true)
389
+ .filter((member) => input.role !== undefined ? member.role === input.role : true)
390
+ .slice(offset, offset + limit)
391
+ .map((member) => ({ ...member }));
392
+ if (input.include_profiles) {
393
+ for (const member of members) {
394
+ if (member.profile === undefined) {
395
+ const profile = this.state.profiles.get(member.agent_id);
396
+ if (profile)
397
+ member.profile = profileToMemberProfile(profile);
398
+ }
399
+ }
400
+ }
401
+ return { members, sync: this.syncState(key) };
402
+ }
403
+ roomMemberGet(input) {
404
+ const key = this.resolveRoomKey(input.host, input.room_id);
405
+ const room = this.localRoom(key);
406
+ const found = room.members.get(input.agent_id);
407
+ if (!found)
408
+ throw invalidPayload("room member not found");
409
+ const member = { ...found };
410
+ if (input.include_profile && member.profile === undefined) {
411
+ const profile = this.state.profiles.get(member.agent_id);
412
+ if (profile)
413
+ member.profile = profileToMemberProfile(profile);
414
+ }
415
+ const recent = input.include_recent_activity
416
+ ? room.timeline
417
+ .filter((item) => item.actor === input.agent_id)
418
+ .slice(-10)
419
+ .reverse()
420
+ : [];
421
+ return { member, recent, sync: this.syncState(key) };
422
+ }
423
+ async agentStatusList(input) {
424
+ const key = this.resolveRoomKey(input.host, input.room_id);
425
+ const keyStr = roomKeyString(key);
426
+ if (!input.refresh) {
427
+ const cached = this.state.agentStatuses.get(keyStr);
428
+ if (cached) {
429
+ return {
430
+ statuses: sortedAgentStatuses(cached),
431
+ sync: this.syncState(key),
432
+ };
433
+ }
434
+ }
435
+ const host = this.allowedRoomHost(key);
436
+ const jwt = this.requestJwt(host);
437
+ const response = await this.discourse(host).agentStatuses(input.room_id, jwt);
438
+ const statuses = new Map();
439
+ for (const status of response.statuses) {
440
+ statuses.set(status.agent_id, status);
441
+ }
442
+ this.state.agentStatuses.set(keyStr, statuses);
443
+ return { statuses: sortedAgentStatuses(statuses), sync: this.syncState(key) };
444
+ }
445
+ async agentStatusGet(input) {
446
+ const key = this.resolveRoomKey(input.host, input.room_id);
447
+ const keyStr = roomKeyString(key);
448
+ if (!input.refresh) {
449
+ const cached = this.state.agentStatuses.get(keyStr)?.get(input.agent_id);
450
+ if (cached)
451
+ return { status: cached, sync: this.syncState(key) };
452
+ }
453
+ const host = this.allowedRoomHost(key);
454
+ const jwt = this.requestJwt(host);
455
+ const response = await this.discourse(host).agentStatus(input.room_id, input.agent_id, jwt);
456
+ let statuses = this.state.agentStatuses.get(keyStr);
457
+ if (!statuses) {
458
+ statuses = new Map();
459
+ this.state.agentStatuses.set(keyStr, statuses);
460
+ }
461
+ statuses.set(response.status.agent_id, response.status);
462
+ return { status: response.status, sync: this.syncState(key) };
463
+ }
464
+ async agentStatusSet(input) {
465
+ const key = this.resolveRoomKey(input.host, input.room_id);
466
+ const keyStr = roomKeyString(key);
467
+ const host = this.allowedRoomHost(key);
468
+ const jwt = this.requestJwt(host);
469
+ const request = {
470
+ state: input.state,
471
+ summary: input.summary,
472
+ seen_seq: input.seen_seq,
473
+ seen_hash: input.seen_hash,
474
+ claim_id: input.claim_id,
475
+ activity: input.activity,
476
+ expires_at: input.expires_at,
477
+ extra: input.extra,
478
+ };
479
+ const status = await this.discourse(host).setAgentStatus(input.room_id, jwt, request);
480
+ let statuses = this.state.agentStatuses.get(keyStr);
481
+ if (!statuses) {
482
+ statuses = new Map();
483
+ this.state.agentStatuses.set(keyStr, statuses);
484
+ }
485
+ statuses.set(status.agent_id, status);
486
+ return { status, sync: this.syncState(key) };
487
+ }
488
+ async agentStatusClear(input) {
489
+ const key = this.resolveRoomKey(input.host, input.room_id);
490
+ const host = this.allowedRoomHost(key);
491
+ const jwt = this.requestJwt(host);
492
+ const request = {
493
+ state: "away",
494
+ expires_at: unixTimeMillis() - 1,
495
+ };
496
+ await this.discourse(host).setAgentStatus(input.room_id, jwt, request);
497
+ this.state.agentStatuses.get(roomKeyString(key))?.delete(this.agentId());
498
+ return { cleared: true, room_id: input.room_id };
499
+ }
500
+ roomTimeline(input) {
501
+ void input.refresh;
502
+ void input.include_records;
503
+ const key = this.resolveRoomKey(input.host, input.room_id);
504
+ const room = this.localRoom(key);
505
+ const items = room.timeline
506
+ .filter((item) => input.after_seq !== undefined ? item.seq > input.after_seq : true)
507
+ .filter((item) => input.before_seq !== undefined ? item.seq < input.before_seq : true)
508
+ .filter((item) => input.types !== undefined ? input.types.includes(item.type) : true)
509
+ .filter((item) => input.actors !== undefined ? input.actors.includes(item.actor) : true)
510
+ .filter((item) => !input.unread_only || item.seq > room.readSeq)
511
+ .slice(0, input.limit ?? 50);
512
+ const nextAfterSeq = items.length > 0 ? items[items.length - 1].seq : undefined;
513
+ return {
514
+ items,
515
+ sync: this.syncState(key),
516
+ next_after_seq: nextAfterSeq,
517
+ };
518
+ }
519
+ roomUnread(input) {
520
+ const key = this.resolveRoomKey(input.host, input.room_id);
521
+ const room = this.localRoom(key);
522
+ let items = room.timeline
523
+ .filter((item) => item.seq > room.readSeq)
524
+ .slice(0, input.limit ?? 50);
525
+ const throughSeq = items.length > 0 ? items[items.length - 1].seq : undefined;
526
+ if (input.mark_read) {
527
+ if (throughSeq !== undefined)
528
+ room.readSeq = throughSeq;
529
+ items =
530
+ throughSeq !== undefined
531
+ ? room.timeline.filter((item) => item.seq <= throughSeq)
532
+ : [];
533
+ }
534
+ return {
535
+ items,
536
+ unread_count: unreadCount(room),
537
+ sync: this.syncState(key),
538
+ };
539
+ }
540
+ roomMarkRead(input) {
541
+ const key = this.resolveRoomKey(input.host, input.room_id);
542
+ const room = this.localRoom(key);
543
+ room.readSeq = Math.max(room.readSeq, input.through_seq);
544
+ return {
545
+ room_id: input.room_id,
546
+ read_seq: room.readSeq,
547
+ unread_count: unreadCount(room),
548
+ };
549
+ }
550
+ inboxNext(input) {
551
+ void input.wait_ms;
552
+ const now = unixTimeMillis();
553
+ const ids = [...this.state.inbox.entries()]
554
+ .sort((a, b) => compareStrings(a[0], b[0]))
555
+ .filter(([, entry]) => inboxEntryReady(entry, now))
556
+ .filter(([, entry]) => input.room_id !== undefined
557
+ ? entry.item.room_id === input.room_id
558
+ : true)
559
+ .filter(([, entry]) => input.kinds !== undefined
560
+ ? input.kinds.includes(entry.item.kind)
561
+ : true)
562
+ .map(([id]) => id)
563
+ .slice(0, input.limit ?? 10);
564
+ const items = [];
565
+ for (const id of ids) {
566
+ const entry = this.state.inbox.get(id);
567
+ if (entry) {
568
+ items.push(entry.item);
569
+ if (input.claim)
570
+ entry.state = { kind: "claimed" };
571
+ }
572
+ }
573
+ return { items, pending_count: this.pendingInboxCount() };
574
+ }
575
+ inboxAck(input) {
576
+ const acknowledged = [];
577
+ for (const id of input.ids) {
578
+ const entry = this.state.inbox.get(id);
579
+ if (entry) {
580
+ entry.state =
581
+ input.action === "defer"
582
+ ? { kind: "deferred", until: input.defer_until ?? unixTimeMillis() }
583
+ : { kind: "acknowledged" };
584
+ acknowledged.push(id);
585
+ }
586
+ }
587
+ return { acknowledged, pending_count: this.pendingInboxCount() };
588
+ }
589
+ draftsList(input) {
590
+ const offset = parseCursor(input.cursor);
591
+ const limit = input.limit ?? 50;
592
+ const host = input.host !== undefined ? normalizeHost(input.host) : undefined;
593
+ const drafts = [...this.state.drafts.values()]
594
+ .sort((a, b) => compareStrings(a.draft.id, b.draft.id))
595
+ .filter((entry) => input.room_id !== undefined
596
+ ? entry.draft.room_id === input.room_id
597
+ : true)
598
+ .filter((entry) => host !== undefined ? entry.draft.current_sync.host === host : true)
599
+ .slice(offset, offset + limit + 1)
600
+ .map((entry) => entry.draft);
601
+ let nextCursor;
602
+ if (drafts.length > limit) {
603
+ drafts.pop();
604
+ nextCursor = String(offset + limit);
605
+ }
606
+ return { drafts, next_cursor: nextCursor };
607
+ }
608
+ draftGet(input) {
609
+ const entry = this.state.drafts.get(input.draft_id);
610
+ if (!entry)
611
+ throw invalidPayload("draft not found");
612
+ const key = {
613
+ host: entry.draft.current_sync.host,
614
+ roomId: entry.draft.room_id,
615
+ };
616
+ return {
617
+ draft: entry.draft,
618
+ changes: this.roomChangesSince(key, entry.draft.base_seq),
619
+ sync: this.syncState(key),
620
+ };
621
+ }
622
+ async draftCommit(input) {
623
+ const entry = this.state.drafts.get(input.draft_id);
624
+ if (!entry)
625
+ throw invalidPayload("draft not found");
626
+ if (input.action === "stay_silent") {
627
+ this.state.drafts.delete(input.draft_id);
628
+ return { status: "dropped", draft_id: input.draft_id };
629
+ }
630
+ let result;
631
+ if (entry.request.kind === "message") {
632
+ const request = { ...entry.request.input };
633
+ if (input.action === "send_anyway") {
634
+ request.base_seq = undefined;
635
+ request.base_hash = undefined;
636
+ request.on_head_mismatch = "send_anyway";
637
+ result = await this.submitMessageUnchecked(request);
638
+ }
639
+ else {
640
+ if (input.action === "revise") {
641
+ if (input.content !== undefined)
642
+ request.content = input.content;
643
+ if (input.content_type !== undefined)
644
+ request.content_type = input.content_type;
645
+ if (input.mentions !== undefined)
646
+ request.mentions = input.mentions;
647
+ if (input.references !== undefined)
648
+ request.references = input.references;
649
+ if (input.extra !== undefined)
650
+ request.extra = input.extra;
651
+ }
652
+ request.base_seq = input.base_seq;
653
+ request.base_hash = input.base_hash;
654
+ request.on_head_mismatch = input.on_head_mismatch;
655
+ result = await this.roomSendMessage(request);
656
+ }
657
+ }
658
+ else {
659
+ const request = { ...entry.request.input };
660
+ if (input.action === "send_anyway") {
661
+ request.base_seq = undefined;
662
+ request.base_hash = undefined;
663
+ request.on_head_mismatch = "send_anyway";
664
+ result = await this.submitEventUnchecked(request);
665
+ }
666
+ else {
667
+ if (input.action === "revise") {
668
+ if (input.type !== undefined)
669
+ request.type = input.type;
670
+ if (input.payload !== undefined)
671
+ request.payload = input.payload;
672
+ if (input.mentions !== undefined)
673
+ request.mentions = input.mentions;
674
+ if (input.references !== undefined)
675
+ request.references = input.references;
676
+ }
677
+ request.base_seq = input.base_seq;
678
+ request.base_hash = input.base_hash;
679
+ request.on_head_mismatch = input.on_head_mismatch;
680
+ result = await this.roomSubmitEvent(request);
681
+ }
682
+ }
683
+ if (result.status === "sent" || result.status === "held") {
684
+ this.state.drafts.delete(input.draft_id);
685
+ }
686
+ return result;
687
+ }
688
+ draftDrop(input) {
689
+ this.state.drafts.delete(input.draft_id);
690
+ return {
691
+ status: "dropped",
692
+ draft_id: input.draft_id,
693
+ pending_count: this.state.drafts.size,
694
+ };
695
+ }
696
+ async profileUpdate(input) {
697
+ if (!isRecord(input.profile)) {
698
+ throw invalidPayload("profile must be an object");
699
+ }
700
+ const profile = { ...input.profile };
701
+ // payload.id is always the active Agent ID; reject an input that names a
702
+ // different agent instead of silently rewriting it.
703
+ const activeId = this.agentId();
704
+ if (profile.id === undefined) {
705
+ profile.id = activeId;
706
+ }
707
+ else if (profile.id !== activeId) {
708
+ throw invalidPayload("profile.id must be the active Agent ID");
709
+ }
710
+ const envelope = this.signProfileUpdate(profile);
711
+ const materialized = await this.profileClient(input.profile_service).submitProfileUpdate(envelope);
712
+ this.state.profiles.set(materialized.id, materialized);
713
+ return { profile: materialized, envelope };
714
+ }
715
+ async roomCreate(input) {
716
+ const host = normalizeHost(input.host);
717
+ this.requireAllowedHost(host);
718
+ const payload = {
719
+ topic: input.topic,
720
+ visibility: input.visibility,
721
+ start_time: input.start_time,
722
+ end_time: input.end_time,
723
+ agenda: input.agenda,
724
+ guidance: input.guidance,
725
+ tags: input.tags,
726
+ language: input.language,
727
+ policy: input.policy,
728
+ types: input.types,
729
+ };
730
+ const envelope = this.signRoomCreate(payload);
731
+ const room = await this.discourse(host).createRoom(envelope);
732
+ if (!room.envelope)
733
+ room.envelope = envelope;
734
+ this.acceptRoomResponse(host, room);
735
+ const key = { host, roomId: room.id };
736
+ return {
737
+ room: this.roomStateView(this.localRoom(key)),
738
+ envelope,
739
+ sync: this.syncState(key),
740
+ };
741
+ }
742
+ async roomJoin(input) {
743
+ const roomId = input.room_id;
744
+ let key;
745
+ if (input.host !== undefined) {
746
+ const host = normalizeHost(input.host);
747
+ this.requireAllowedHost(host);
748
+ key = { host, roomId };
749
+ }
750
+ else {
751
+ key = this.resolveRoomKey(undefined, roomId);
752
+ this.requireAllowedHost(key.host);
753
+ }
754
+ const host = key.host;
755
+ if (!this.state.rooms.has(roomKeyString(key))) {
756
+ const room = await this.discourse(host).room(roomId);
757
+ this.acceptRoomResponse(host, room);
758
+ }
759
+ if (input.request_id !== undefined) {
760
+ const status = await this.approvedJoinRequest(host, roomId, input.request_id);
761
+ const approvedRole = status.approved_role ?? status.request.role;
762
+ // The completion call signs room.join with the approved role; a differing
763
+ // input role is a mismatch error, never a silent substitution.
764
+ if (input.role !== approvedRole) {
765
+ throw invalidPayload(`join_request_role_mismatch: approved role is ${approvedRole}`);
766
+ }
767
+ const payload = {
768
+ request_id: input.request_id,
769
+ role: approvedRole,
770
+ };
771
+ return this.completeJoin(host, key, payload);
772
+ }
773
+ if (roomVisibility(this.localRoom(key).room) === "public") {
774
+ const payload = {
775
+ request_id: undefined,
776
+ role: input.role,
777
+ perspective: input.perspective,
778
+ };
779
+ return this.completeJoin(host, key, payload);
780
+ }
781
+ const jwt = this.requestJwt(host);
782
+ const request = {
783
+ role: input.role,
784
+ perspective: input.perspective,
785
+ reason: input.reason,
786
+ extra: input.extra,
787
+ };
788
+ const status = await this.discourse(host).requestJoin(roomId, jwt, request);
789
+ this.pushJoinRequest(key, status);
790
+ let sync;
791
+ try {
792
+ sync = this.syncState(key);
793
+ }
794
+ catch {
795
+ sync = undefined;
796
+ }
797
+ return { status: "approval_required", join_request: status, sync };
798
+ }
799
+ async roomJoinRequest(input) {
800
+ const host = normalizeHost(input.host);
801
+ this.requireAllowedHost(host);
802
+ const jwt = this.requestJwt(host);
803
+ const request = {
804
+ role: input.role,
805
+ perspective: input.perspective,
806
+ reason: input.reason,
807
+ extra: input.extra,
808
+ };
809
+ const status = await this.discourse(host).requestJoin(input.room_id, jwt, request);
810
+ this.pushJoinRequest({ host, roomId: input.room_id }, status);
811
+ return { join_request: status };
812
+ }
813
+ async roomJoinWhenApproved(input) {
814
+ const key = this.resolveRoomKey(input.host, input.room_id);
815
+ const host = this.allowedRoomHost(key);
816
+ const status = await this.approvedJoinRequest(host, input.room_id, input.request_id);
817
+ const role = status.approved_role ?? status.request.role;
818
+ const payload = {
819
+ request_id: input.request_id,
820
+ role,
821
+ };
822
+ const result = await this.completeJoin(host, key, payload);
823
+ return { record: result.record, member: result.member, sync: result.sync };
824
+ }
825
+ async roomLeave(input) {
826
+ const key = this.resolveRoomKey(input.host, input.room_id);
827
+ const host = this.allowedRoomHost(key);
828
+ const payload = {
829
+ reason: input.reason,
830
+ references: [],
831
+ extra: {},
832
+ };
833
+ const envelope = this.signRoomEvent(eventType.ROOM_LEAVE, key, undefined, undefined, [], payload);
834
+ const record = await this.discourse(host).leaveRoom(input.room_id, envelope);
835
+ this.applyHostRecord(host, record);
836
+ return { record, sync: this.syncState(key) };
837
+ }
838
+ async roomSendMessage(input) {
839
+ const key = this.resolveRoomKey(input.host, input.room_id);
840
+ const held = this.headMismatchMessageResult(key, input);
841
+ if (held)
842
+ return held;
843
+ return this.submitMessageUnchecked(input);
844
+ }
845
+ async submitMessageUnchecked(input) {
846
+ const key = this.resolveRoomKey(input.host, input.room_id);
847
+ const host = this.allowedRoomHost(key);
848
+ const payload = {
849
+ content_type: input.content_type ?? "text/plain",
850
+ content: input.content,
851
+ };
852
+ if (input.references && input.references.length > 0) {
853
+ payload.references = input.references;
854
+ }
855
+ if (input.extra && Object.keys(input.extra).length > 0) {
856
+ payload.extra = input.extra;
857
+ }
858
+ const envelope = this.signRoomEvent(eventType.MESSAGE_CREATE, key, input.base_seq, input.base_hash, input.mentions ?? [], payload);
859
+ const record = await this.discourse(host).submitEvent(input.room_id, envelope);
860
+ this.applyHostRecord(host, record);
861
+ const item = this.timelineItemByEvent(key, record.envelope.hash);
862
+ return {
863
+ status: "sent",
864
+ record: record,
865
+ item,
866
+ sync: this.syncState(key),
867
+ };
868
+ }
869
+ async roomSubmitEvent(input) {
870
+ // For signal-kind writes — including the membership events — the base is
871
+ // only an anchor: never hold the draft, ignore on_head_mismatch.
872
+ const key = this.resolveRoomKey(input.host, input.room_id);
873
+ const room = this.state.rooms.get(roomKeyString(key));
874
+ const advancesHead = room
875
+ ? eventTypeAdvancesRoomHead(room, input.type)
876
+ : true;
877
+ if (advancesHead) {
878
+ const held = this.headMismatchEventResult(key, input);
879
+ if (held)
880
+ return held;
881
+ }
882
+ return this.submitEventUnchecked(input);
883
+ }
884
+ async submitEventUnchecked(input) {
885
+ const key = this.resolveRoomKey(input.host, input.room_id);
886
+ const host = this.allowedRoomHost(key);
887
+ const payload = payloadWithReferences(input.payload, input.references ?? []);
888
+ const envelope = this.signRoomEvent(input.type, key, input.base_seq, input.base_hash, input.mentions ?? [], payload);
889
+ const record = await this.discourse(host).submitEvent(input.room_id, envelope);
890
+ this.applyHostRecord(host, record);
891
+ const item = this.timelineItemByEvent(key, record.envelope.hash);
892
+ return {
893
+ status: "sent",
894
+ record: record,
895
+ item,
896
+ sync: this.syncState(key),
897
+ };
898
+ }
899
+ async joinRequestsList(input) {
900
+ const key = this.resolveRoomKey(input.host, input.room_id);
901
+ const host = this.allowedRoomHost(key);
902
+ const jwt = this.requestJwt(host);
903
+ let requests = await this.discourse(host).joinRequests(input.room_id, jwt);
904
+ if (input.status !== undefined) {
905
+ requests = requests.filter((request) => request.status === input.status);
906
+ }
907
+ const offset = parseCursor(input.cursor);
908
+ if (offset > 0)
909
+ requests = requests.slice(offset);
910
+ if (input.limit !== undefined)
911
+ requests = requests.slice(0, input.limit);
912
+ this.state.joinRequests.set(roomKeyString(key), requests);
913
+ return { join_requests: requests };
914
+ }
915
+ async joinRequestReview(input) {
916
+ const key = this.resolveRoomKey(input.host, input.room_id);
917
+ const host = this.allowedRoomHost(key);
918
+ const jwt = this.requestJwt(host);
919
+ const status = await this.discourse(host).joinRequest(input.room_id, input.request_id, jwt);
920
+ const payload = {
921
+ request: status.request,
922
+ decision: input.decision,
923
+ role: input.role,
924
+ reason: input.reason,
925
+ extra: {},
926
+ };
927
+ const envelope = this.signRoomEvent(eventType.ROOM_JOIN_REVIEW, key, undefined, undefined, [], payload);
928
+ const record = await this.discourse(host).submitEvent(input.room_id, envelope);
929
+ this.applyHostRecord(host, record);
930
+ return { record, sync: this.syncState(key) };
931
+ }
932
+ // ── Join helpers shared by the join tools.
933
+ async approvedJoinRequest(host, roomId, requestId) {
934
+ const jwt = this.requestJwt(host);
935
+ const status = await this.discourse(host).joinRequest(roomId, requestId, jwt);
936
+ if (status.request.applicant !== this.agentId()) {
937
+ throw invalidPayload("join request belongs to another agent");
938
+ }
939
+ if (status.status !== "approved") {
940
+ throw invalidPayload("join request is not approved");
941
+ }
942
+ return status;
943
+ }
944
+ async completeJoin(host, key, payload) {
945
+ const envelope = this.signRoomEvent(eventType.ROOM_JOIN, key, undefined, undefined, [], payload);
946
+ const record = await this.discourse(host).joinRoom(key.roomId, envelope);
947
+ this.applyHostRecord(host, record);
948
+ const member = this.localRoom(key).members.get(this.agentId());
949
+ if (!member)
950
+ throw invalidPayload("joined member not materialized");
951
+ return {
952
+ status: "joined",
953
+ record: record,
954
+ member,
955
+ sync: this.syncState(key),
956
+ };
957
+ }
958
+ pushJoinRequest(key, status) {
959
+ const keyStr = roomKeyString(key);
960
+ const existing = this.state.joinRequests.get(keyStr);
961
+ if (existing)
962
+ existing.push(status);
963
+ else
964
+ this.state.joinRequests.set(keyStr, [status]);
965
+ }
966
+ // ── Signing.
967
+ signProfileUpdate(payload) {
968
+ const event = profileUpdateEvent(this.agentId(), unixTimeMillis(), this.nonces.nextNonce(), payload);
969
+ return this.signer.signEvent(event);
970
+ }
971
+ signRoomCreate(payload) {
972
+ const event = roomCreateEvent(this.agentId(), unixTimeMillis(), this.nonces.nextNonce(), payload);
973
+ const envelope = this.signer.signEvent(event);
974
+ validateDiscourseEnvelope(envelope);
975
+ return envelope;
976
+ }
977
+ signRoomEvent(type, key, baseSeq, baseHash, mentions, payload) {
978
+ const host = this.localRoom(key).host;
979
+ this.requireAllowedHost(host);
980
+ const [resolvedSeq, resolvedHash] = this.roomHeadForWrite(key, baseSeq, baseHash);
981
+ const event = withMentions(discourseEvent(type, this.agentId(), unixTimeMillis(), this.nonces.nextNonce(), key.roomId, resolvedSeq, resolvedHash, payload), mentions);
982
+ const envelope = this.signer.signEvent(event);
983
+ validateDiscourseEnvelope(envelope);
984
+ return envelope;
985
+ }
986
+ roomHeadForWrite(key, baseSeq, baseHash) {
987
+ if (baseSeq !== undefined && baseHash !== undefined) {
988
+ if (baseSeq > 0 && baseHash.trim() !== "")
989
+ return [baseSeq, baseHash];
990
+ throw invalidPayload("base_seq and base_hash must identify a valid room head");
991
+ }
992
+ if (baseSeq === undefined && baseHash === undefined) {
993
+ const sync = this.syncState(key);
994
+ if (sync.head_seq === 0 || sync.head_hash.trim() === "") {
995
+ throw invalidPayload("current room head is not known locally");
996
+ }
997
+ return [sync.head_seq, sync.head_hash];
998
+ }
999
+ throw invalidPayload("base_seq and base_hash must be provided together");
1000
+ }
1001
+ requestJwt(host) {
1002
+ // The request JWT aud is always the origin of the host API.
1003
+ const audience = serviceOrigin(host);
1004
+ const claims = createRequestJwtClaims(this.agentId(), createRequestBinding(audience), unixTimeSecs(), DEFAULT_REQUEST_JWT_TTL_SECS);
1005
+ return this.signer.signRequestJwt(claims);
1006
+ }
1007
+ // ── Head-mismatch handling and draft holding.
1008
+ headMismatchMessageResult(key, input) {
1009
+ const headMismatch = this.headMismatchWriteState(key, input.base_seq, input.base_hash);
1010
+ if (!headMismatch)
1011
+ return undefined;
1012
+ switch (input.on_head_mismatch ?? "hold") {
1013
+ case "send_anyway":
1014
+ input.base_seq = undefined;
1015
+ input.base_hash = undefined;
1016
+ return undefined;
1017
+ case "reject":
1018
+ return this.rejectedHeadMismatchResult(headMismatch);
1019
+ default:
1020
+ return this.holdMessageDraft(input, headMismatch);
1021
+ }
1022
+ }
1023
+ headMismatchEventResult(key, input) {
1024
+ const headMismatch = this.headMismatchWriteState(key, input.base_seq, input.base_hash);
1025
+ if (!headMismatch)
1026
+ return undefined;
1027
+ switch (input.on_head_mismatch ?? "hold") {
1028
+ case "send_anyway":
1029
+ input.base_seq = undefined;
1030
+ input.base_hash = undefined;
1031
+ return undefined;
1032
+ case "reject":
1033
+ return this.rejectedHeadMismatchResult(headMismatch);
1034
+ default:
1035
+ return this.holdEventDraft(input, headMismatch);
1036
+ }
1037
+ }
1038
+ headMismatchWriteState(key, baseSeq, baseHash) {
1039
+ if (baseSeq === undefined && baseHash === undefined)
1040
+ return undefined;
1041
+ const sync = this.syncState(key);
1042
+ const seqMismatch = baseSeq !== undefined && baseSeq !== sync.head_seq;
1043
+ const hashMismatch = baseHash !== undefined && sync.head_hash !== baseHash;
1044
+ if (!seqMismatch && !hashMismatch)
1045
+ return undefined;
1046
+ return { sync, changes: this.roomChangesSince(key, baseSeq) };
1047
+ }
1048
+ rejectedHeadMismatchResult(headMismatch) {
1049
+ return {
1050
+ status: "rejected",
1051
+ reason: "room_head_mismatch",
1052
+ changes: headMismatch.changes,
1053
+ sync: headMismatch.sync,
1054
+ };
1055
+ }
1056
+ holdMessageDraft(input, headMismatch) {
1057
+ input.host = headMismatch.sync.host;
1058
+ const draftId = this.nextDraftId(input.room_id);
1059
+ const draft = {
1060
+ id: draftId,
1061
+ room_id: input.room_id,
1062
+ kind: "message",
1063
+ created_at: unixTimeMillis(),
1064
+ base_seq: input.base_seq,
1065
+ base_hash: input.base_hash,
1066
+ current_sync: headMismatch.sync,
1067
+ draft: messageDraftValue(input),
1068
+ reason: "room_head_mismatch",
1069
+ options: heldDraftOptions(),
1070
+ };
1071
+ this.state.drafts.set(draftId, {
1072
+ draft,
1073
+ request: { kind: "message", input },
1074
+ });
1075
+ return {
1076
+ status: "held",
1077
+ reason: "room_head_mismatch",
1078
+ draft,
1079
+ changes: headMismatch.changes,
1080
+ sync: headMismatch.sync,
1081
+ };
1082
+ }
1083
+ holdEventDraft(input, headMismatch) {
1084
+ input.host = headMismatch.sync.host;
1085
+ const draftId = this.nextDraftId(input.room_id);
1086
+ const draft = {
1087
+ id: draftId,
1088
+ room_id: input.room_id,
1089
+ kind: "event",
1090
+ created_at: unixTimeMillis(),
1091
+ base_seq: input.base_seq,
1092
+ base_hash: input.base_hash,
1093
+ current_sync: headMismatch.sync,
1094
+ draft: eventDraftValue(input),
1095
+ reason: "room_head_mismatch",
1096
+ options: heldDraftOptions(),
1097
+ };
1098
+ this.state.drafts.set(draftId, {
1099
+ draft,
1100
+ request: { kind: "event", input },
1101
+ });
1102
+ return {
1103
+ status: "held",
1104
+ reason: "room_head_mismatch",
1105
+ draft,
1106
+ changes: headMismatch.changes,
1107
+ sync: headMismatch.sync,
1108
+ };
1109
+ }
1110
+ roomChangesSince(key, baseSeq) {
1111
+ const room = this.localRoom(key);
1112
+ if (baseSeq !== undefined) {
1113
+ return room.timeline.filter((item) => item.seq > baseSeq);
1114
+ }
1115
+ return room.timeline.slice(-20);
1116
+ }
1117
+ nextDraftId(roomId) {
1118
+ const room = [...roomId]
1119
+ .map((ch) => (/[A-Za-z0-9_-]/.test(ch) ? ch : "_"))
1120
+ .join("");
1121
+ return `draft_${room}_${this.state.drafts.size + 1}`;
1122
+ }
1123
+ // ── Views.
1124
+ syncState(key) {
1125
+ const room = this.localRoom(key);
1126
+ const headHash = room.headHash ?? room.room.head?.hash ?? room.room.hash;
1127
+ return {
1128
+ host: room.host,
1129
+ room_id: key.roomId,
1130
+ head_seq: room.headSeq,
1131
+ head_hash: headHash,
1132
+ synced_seq: room.syncedSeq,
1133
+ remote_seq: Math.max(room.room.seq, room.syncedSeq),
1134
+ subscribed: room.subscribed,
1135
+ unread_count: unreadCount(room),
1136
+ pending_inbox_count: this.pendingInboxCount(key.roomId),
1137
+ };
1138
+ }
1139
+ roomStateView(room) {
1140
+ const selfMember = room.members.get(this.agentId());
1141
+ return {
1142
+ host: room.host,
1143
+ room_id: room.room.id,
1144
+ status: room.room.status,
1145
+ visibility: roomVisibility(room.room),
1146
+ topic: roomTopic(room.room),
1147
+ agenda: roomAgenda(room.room),
1148
+ guidance: roomGuidance(room.room),
1149
+ creator: room.room.envelope?.event.actor,
1150
+ created_at: room.room.envelope?.event.created_at,
1151
+ start_time: roomStartTime(room.room),
1152
+ end_time: roomEndTime(room.room),
1153
+ tags: roomTags(room.room),
1154
+ language: roomLanguage(room.room),
1155
+ policy: roomPolicy(room.room),
1156
+ types: room.room.types ?? [],
1157
+ self_member: selfMember,
1158
+ members_count: room.members.size,
1159
+ active_turn: room.activeTurn,
1160
+ unread_count: unreadCount(room),
1161
+ pending_inbox_count: this.pendingInboxCount(room.room.id),
1162
+ };
1163
+ }
1164
+ summaryForRoom(room) {
1165
+ return roomSummaryFromResponse(room.host, room.room, {
1166
+ role: room.members.get(this.agentId())?.role,
1167
+ unreadCount: unreadCount(room),
1168
+ pendingInboxCount: this.pendingInboxCount(room.room.id),
1169
+ });
1170
+ }
1171
+ summaryForResponse(host, room) {
1172
+ const existing = this.state.rooms.get(roomKeyString({ host, roomId: room.id }));
1173
+ if (existing)
1174
+ return this.summaryForRoom(existing);
1175
+ return roomSummaryFromResponse(host, room);
1176
+ }
1177
+ timelineItemByEvent(key, eventId) {
1178
+ const item = this.localRoom(key).timeline.find((entry) => entry.event_id === eventId);
1179
+ if (!item)
1180
+ throw invalidPayload("timeline item not materialized");
1181
+ return item;
1182
+ }
1183
+ // ── Internal helpers.
1184
+ localRoom(key) {
1185
+ const room = this.state.rooms.get(roomKeyString(key));
1186
+ if (!room)
1187
+ throw invalidPayload(`room is not open locally: ${key.roomId}`);
1188
+ return room;
1189
+ }
1190
+ requireAllowedHost(host) {
1191
+ const record = this.state.hosts.get(host);
1192
+ if (!record || !record.allowed)
1193
+ throw permissionDenied();
1194
+ }
1195
+ allowedRoomHost(key) {
1196
+ const host = this.localRoom(key).host;
1197
+ this.requireAllowedHost(host);
1198
+ return host;
1199
+ }
1200
+ ensureHost(host) {
1201
+ if (!this.state.hosts.has(host)) {
1202
+ this.state.hosts.set(host, { host, allowed: false, features: [] });
1203
+ }
1204
+ }
1205
+ insertInbox(item) {
1206
+ if (!this.state.inbox.has(item.id)) {
1207
+ this.state.inbox.set(item.id, { item, state: { kind: "pending" } });
1208
+ }
1209
+ }
1210
+ pendingInboxCount(roomId) {
1211
+ const now = unixTimeMillis();
1212
+ let count = 0;
1213
+ for (const entry of this.state.inbox.values()) {
1214
+ if (!inboxEntryReady(entry, now))
1215
+ continue;
1216
+ if (roomId !== undefined && entry.item.room_id !== roomId)
1217
+ continue;
1218
+ count += 1;
1219
+ }
1220
+ return count;
1221
+ }
1222
+ sortedHosts() {
1223
+ return [...this.state.hosts.entries()]
1224
+ .sort((a, b) => compareStrings(a[0], b[0]))
1225
+ .map(([, host]) => host);
1226
+ }
1227
+ discourse(host) {
1228
+ return new DiscourseClient(host, this.fetchImpl);
1229
+ }
1230
+ profileClient(url) {
1231
+ return new ProfileClient(url, this.fetchImpl);
1232
+ }
1233
+ }
1234
+ //# sourceMappingURL=engine.js.map