@zooid/transport-matrix 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/transport.ts CHANGED
@@ -6,7 +6,7 @@ import { MatrixClient } from './matrix-client.js'
6
6
  import { BotPool } from './bot-pool.js'
7
7
  import { route, isMediaMsgtype, type AgentBinding, type ThreadState } from './router.js'
8
8
  import { stripMention, extractMentions } from './mentions.js'
9
- import { toToolCallBody, toUpdateBody, toPlanBody, toErrorBody } from './event-encoders.js'
9
+ import { toToolCallBody, toUpdateBody, toPlanBody, toAvailableCommandsBody, toErrorBody } from './event-encoders.js'
10
10
  import { classify } from '@zooid/acp-client'
11
11
  import { toMatrixHtml } from './markdown-to-matrix-html.js'
12
12
  import {
@@ -52,6 +52,10 @@ export interface CreateMatrixTransportOptions {
52
52
  media?: MediaClientLike
53
53
  /** Injected attachment writer (defaults to the real writeAttachment). */
54
54
  writeAttachmentFn?: typeof writeAttachment
55
+ /** AS sender-bot MXID (@<sender_localpart>:<server>). Together with the agent
56
+ * bindings this forms the set of "our bot users" whose ad-hoc invites are
57
+ * declined. */
58
+ botUserId?: string
55
59
  }
56
60
 
57
61
  interface SessionContext {
@@ -67,9 +71,12 @@ interface MatrixEvent {
67
71
  origin_server_ts?: number
68
72
  room_id?: string
69
73
  sender?: string
74
+ /** Present on state events (m.room.member → the affected user). */
75
+ state_key?: string
70
76
  content?: Record<string, unknown> & {
71
77
  msgtype?: string
72
78
  body?: string
79
+ membership?: string
73
80
  'm.relates_to'?: { rel_type?: string; event_id?: string }
74
81
  }
75
82
  }
@@ -173,7 +180,7 @@ async function sendMediaError(
173
180
  .sendCustomEvent({
174
181
  roomId: ctx.roomId,
175
182
  asUserId: ctx.agent.userId,
176
- eventType: 'eco.zoon.error',
183
+ eventType: 'dev.zooid.error',
177
184
  content: toErrorBody(
178
185
  {
179
186
  kind: 'error' as const,
@@ -187,7 +194,7 @@ async function sendMediaError(
187
194
  ctx.threadRoot,
188
195
  ),
189
196
  })
190
- .catch((e) => console.warn(`[matrix:${ctx.agent.name}] eco.zoon.error send failed:`, e))
197
+ .catch((e) => console.warn(`[matrix:${ctx.agent.name}] dev.zooid.error send failed:`, e))
191
198
  }
192
199
  const SEEN_EVENT_CAP = 5_000
193
200
 
@@ -216,13 +223,20 @@ function inboundThreadRoot(evt: MatrixEvent): string | undefined {
216
223
  }
217
224
 
218
225
  export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
219
- const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
226
+ const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId } = opts
220
227
  const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS
221
228
  const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS
222
229
  const mediaClient = opts.media
223
230
  const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment
224
231
  const pendingMedia = new PendingMediaStore()
225
232
  const pool = new BotPool(client, bindings)
233
+ const ourBotUserIds = new Set<string>([
234
+ ...(botUserId ? [botUserId] : []),
235
+ ...bindings.map((b) => b.userId),
236
+ ])
237
+ const DECLINE_REASON =
238
+ 'Bots are placed in rooms only by the zooid daemon (workforce-as-code). ' +
239
+ 'Ad-hoc invites are declined — add the bot to the room in zooid.yaml.'
226
240
  const sessions = new Map<string, SessionContext>()
227
241
  const buffers = new Map<string, string>()
228
242
  // Last messageId seen per session's buffer. opencode streams each assistant
@@ -240,38 +254,113 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
240
254
  // Idempotency: appservice transactions are retried on 4xx/5xx/timeout, and
241
255
  // the same event_id can arrive twice. Skip ones we've already taken.
242
256
  const seenEventIds = new Set<string>()
257
+ // Messages flushed per session this turn. Lets the drain loop tell "stream
258
+ // not started yet" (0 flushes, empty buffer → keep waiting) from "turn done,
259
+ // last message already flushed mid-stream" (>0 flushes, empty buffer → stop).
260
+ const flushedCounts = new Map<string, number>()
261
+ // Commands a shim advertises during session load/new — i.e. before runTurn
262
+ // registers the session ctx (sessions.set). Stashed here keyed by sessionId
263
+ // and replayed once the ctx exists, so `available_commands_update` (which is
264
+ // only ever emitted at session establishment, never mid-turn) isn't dropped.
265
+ const pendingCommands = new Map<string, AgentEvent>()
266
+
267
+ // Build the m.text content for a chunk of assistant prose, attaching a
268
+ // formatted_body only when the HTML render adds rich text the plain body
269
+ // can't carry (marked wraps plain prose in <p>…</p>; skip that — most
270
+ // clients render `body` better than a stripped re-encode).
271
+ const buildTextContent = (
272
+ text: string,
273
+ ): { msgtype: string; body: string; [k: string]: unknown } => {
274
+ const content: { msgtype: string; body: string; [k: string]: unknown } = {
275
+ msgtype: 'm.text',
276
+ body: text,
277
+ }
278
+ const html = toMatrixHtml(text)
279
+ if (html) {
280
+ const escapedPlain =
281
+ '<p>' +
282
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') +
283
+ '</p>'
284
+ const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
285
+ if (norm(html) !== norm(escapedPlain)) {
286
+ content.format = 'org.matrix.custom.html'
287
+ content.formatted_body = html
288
+ }
289
+ }
290
+ return content
291
+ }
292
+
293
+ // Flush a session's buffered assistant text as its own Matrix message and
294
+ // clear the buffer. No-op on an empty buffer. The send is chained onto
295
+ // sendQueue so it orders correctly against tool_call/plan events from the
296
+ // same turn. The buffer is cleared synchronously (before the first await),
297
+ // so a chunk for the *next* message that arrives during the send starts
298
+ // fresh. Returns true when a message was enqueued.
299
+ const flushBuffer = (sessionId: string): boolean => {
300
+ const ctx = sessions.get(sessionId)
301
+ const text = buffers.get(sessionId) ?? ''
302
+ if (!ctx || text.length === 0) return false
303
+ buffers.set(sessionId, '')
304
+ flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1)
305
+ const content = buildTextContent(text)
306
+ const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
307
+ try {
308
+ await client.sendMessage({
309
+ roomId: ctx.roomId,
310
+ asUserId: ctx.agent.userId,
311
+ content,
312
+ threadRoot: ctx.threadRoot,
313
+ })
314
+ } catch (err) {
315
+ console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err)
316
+ }
317
+ })
318
+ sendQueue.set(sessionId, tail)
319
+ return true
320
+ }
243
321
 
244
322
  agents.onEvent = async (name, event: AgentEvent) => {
245
323
  const ctx = sessions.get(event.sessionId)
246
324
  if (!ctx) {
247
- console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`)
325
+ // available_commands_update is advertised during ensureSession (session
326
+ // load/new), before runTurn calls sessions.set — so the ctx isn't there
327
+ // yet. Stash the latest roster and replay it once runTurn registers the
328
+ // ctx. Other event types arriving without a ctx are genuinely orphaned
329
+ // (e.g. replayed history for a thread we're not handling) — drop them.
330
+ if (event.type === 'available_commands') {
331
+ pendingCommands.set(event.sessionId, event)
332
+ } else {
333
+ console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`)
334
+ }
248
335
  return
249
336
  }
250
337
 
251
338
  if (event.type === 'agent_message_chunk') {
252
339
  const block = event.content as { type?: string; text?: string; data?: string; mimeType?: string }
253
340
  if (block.type === 'text' && typeof block.text === 'string') {
254
- const current = buffers.get(event.sessionId) ?? ''
255
- // Within a message, tokens carry their own leading spaces, so we
256
- // concatenate raw. Two signals start a *new* message block that must not
257
- // run together with the previous text:
258
- // - an empty chunk (some agents emit one between blocks, e.g. after a
259
- // tool call), or
260
- // - a change in messageId — opencode streams each assistant message
261
- // under its own id and emits no delimiter chunk between them, and the
262
- // first token of the new message has no leading space, so without
263
- // this they weld together ("…one.🅿️").
341
+ // A change in ACP messageId marks the previous assistant message as
342
+ // complete. opencode streams each assistant message under its own id
343
+ // with no delimiter chunk between them, so a change here is the only
344
+ // boundary signal. Flush the previous message as its own Matrix
345
+ // message each ACP message lands separately (and interleaves with
346
+ // tool_call/plan events) instead of welding into one turn-end blob.
264
347
  const prevMessageId = bufferMessageIds.get(event.sessionId)
265
348
  const messageChanged =
266
349
  event.messageId !== undefined &&
267
350
  prevMessageId !== undefined &&
268
351
  event.messageId !== prevMessageId
269
- const needsBreak =
270
- current.length > 0 && (block.text === '' || messageChanged)
271
- const prefix = needsBreak ? '\n\n' : ''
272
- buffers.set(event.sessionId, current + prefix + block.text)
273
352
  if (event.messageId !== undefined)
274
353
  bufferMessageIds.set(event.sessionId, event.messageId)
354
+ // flushBuffer clears the buffer synchronously, so the new message's
355
+ // text below starts fresh.
356
+ if (messageChanged) flushBuffer(event.sessionId)
357
+ // Within a single message, tokens carry their own leading spaces, so we
358
+ // concatenate raw. An empty chunk (some agents emit one between blocks,
359
+ // e.g. after a tool call within the same message) is a paragraph break.
360
+ const current = buffers.get(event.sessionId) ?? ''
361
+ const needsBreak = current.length > 0 && block.text === ''
362
+ const prefix = needsBreak ? '\n\n' : ''
363
+ buffers.set(event.sessionId, current + prefix + block.text)
275
364
  } else if (
276
365
  block.type === 'image' &&
277
366
  typeof block.data === 'string' &&
@@ -310,18 +399,27 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
310
399
  return
311
400
  }
312
401
 
402
+ // An out-of-band event (tool_call / tool_call_update / plan) after some
403
+ // buffered text means that assistant message is complete — flush it first
404
+ // so it lands before this event on the wire, preserving interleaving.
405
+ flushBuffer(event.sessionId)
406
+
313
407
  const eventType =
314
408
  event.type === 'tool_call'
315
- ? 'eco.zoon.tool_call'
409
+ ? 'dev.zooid.tool_call'
316
410
  : event.type === 'tool_call_update'
317
- ? 'eco.zoon.tool_call_update'
318
- : 'eco.zoon.plan'
411
+ ? 'dev.zooid.tool_call_update'
412
+ : event.type === 'available_commands'
413
+ ? 'dev.zooid.available_commands_update'
414
+ : 'dev.zooid.plan'
319
415
  const body =
320
416
  event.type === 'tool_call'
321
417
  ? toToolCallBody(event)
322
418
  : event.type === 'tool_call_update'
323
419
  ? toUpdateBody(event)
324
- : toPlanBody(event)
420
+ : event.type === 'available_commands'
421
+ ? toAvailableCommandsBody(event)
422
+ : toPlanBody(event)
325
423
  body['m.relates_to'] = { rel_type: 'm.thread', event_id: ctx.threadRoot }
326
424
  const tail = (sendQueue.get(event.sessionId) ?? Promise.resolve()).then(async () => {
327
425
  try {
@@ -362,7 +460,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
362
460
  void client.sendCustomEvent({
363
461
  roomId: ctx.roomId,
364
462
  asUserId: ctx.agent.userId,
365
- eventType: 'eco.zoon.approval_request',
463
+ eventType: 'dev.zooid.approval_request',
366
464
  content,
367
465
  })
368
466
  })
@@ -404,7 +502,28 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
404
502
  )
405
503
  continue
406
504
  }
407
- if (evt.type === 'eco.zoon.session_reset') {
505
+ if (evt.type === 'm.room.member' && evt.content?.membership === 'invite') {
506
+ const target = evt.state_key
507
+ const inviter = evt.sender
508
+ if (
509
+ target &&
510
+ evt.room_id &&
511
+ ourBotUserIds.has(target) &&
512
+ (!inviter || !ourBotUserIds.has(inviter))
513
+ ) {
514
+ console.log(
515
+ `[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} ` +
516
+ `from ${inviter ?? 'unknown'}`,
517
+ )
518
+ await client
519
+ .leaveRoom(evt.room_id, target, { reason: DECLINE_REASON })
520
+ .catch((err) =>
521
+ console.warn(`[matrix] leaveRoom(${evt.room_id}, ${target}) failed:`, err),
522
+ )
523
+ }
524
+ continue
525
+ }
526
+ if (evt.type === 'dev.zooid.session_reset') {
408
527
  // Spec § /clear: room-scope reset is unsupported. Only thread-scoped
409
528
  // resets carry a thread relation; drop bare room-level resets silently.
410
529
  const relates = evt.content?.['m.relates_to'] as
@@ -413,10 +532,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
413
532
  const threadRoot =
414
533
  relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
415
534
  if (!threadRoot) {
416
- console.log('[matrix] dropping eco.zoon.session_reset without thread relation')
535
+ console.log('[matrix] dropping dev.zooid.session_reset without thread relation')
417
536
  continue
418
537
  }
419
- console.log(`[matrix] inbound eco.zoon.session_reset in ${evt.room_id} thread=${threadRoot}`)
538
+ console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`)
420
539
  for (const a of bindings) {
421
540
  agents.endSession(a.name, threadRoot)
422
541
  }
@@ -426,7 +545,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
426
545
  // the most-recently-posting agent under the same sessionKey.
427
546
  continue
428
547
  }
429
- if (evt.type === 'eco.zoon.interrupt') {
548
+ if (evt.type === 'dev.zooid.interrupt') {
430
549
  const content = (evt.content ?? {}) as { session_id?: string; reason?: string }
431
550
  // Thread-relation form (client-friendly): /interrupt in a thread sends
432
551
  // an empty event with `m.relates_to: thread/<root>`. Cancel every
@@ -456,7 +575,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
456
575
  }
457
576
  // Legacy form: explicit session_id in content.
458
577
  if (!content.session_id) {
459
- console.warn(`[matrix] eco.zoon.interrupt missing session_id (event_id=${evt.event_id})`)
578
+ console.warn(`[matrix] dev.zooid.interrupt missing session_id (event_id=${evt.event_id})`)
460
579
  continue
461
580
  }
462
581
  const ctx = sessions.get(content.session_id)
@@ -472,7 +591,7 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
472
591
  })
473
592
  continue
474
593
  }
475
- if (evt.type === 'eco.zoon.approval_response') {
594
+ if (evt.type === 'dev.zooid.approval_response') {
476
595
  const content = (evt.content ?? {}) as {
477
596
  approval_id?: string
478
597
  session_id?: string
@@ -596,10 +715,10 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
596
715
  .sendCustomEvent({
597
716
  roomId: evt.room_id,
598
717
  asUserId: a.userId,
599
- eventType: 'eco.zoon.error',
718
+ eventType: 'dev.zooid.error',
600
719
  content: body,
601
720
  })
602
- .catch((e) => console.warn(`[matrix:${a.name}] eco.zoon.error send failed:`, e))
721
+ .catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e))
603
722
  })
604
723
  }
605
724
  }
@@ -637,6 +756,15 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
637
756
  sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot })
638
757
  buffers.set(sessionId, '')
639
758
  bufferMessageIds.delete(sessionId)
759
+ flushedCounts.set(sessionId, 0)
760
+ // Commands the shim advertised during ensureSession (session load/new)
761
+ // arrived before the ctx above existed and were stashed — replay the latest
762
+ // now that the session is fully registered, so the palette actually fills.
763
+ const stashedCommands = pendingCommands.get(sessionId)
764
+ if (stashedCommands) {
765
+ pendingCommands.delete(sessionId)
766
+ void agents.onEvent?.(agent.name, stashedCommands)
767
+ }
640
768
 
641
769
  const roomId = evt.room_id
642
770
  const TYPING_TTL_MS = 30_000
@@ -704,44 +832,23 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
704
832
  while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
705
833
  await delay(drainQuietMs)
706
834
  const next = buffers.get(sessionId) ?? ''
707
- // Stop only when we have content AND it hasn't grown i.e. the
708
- // generation has actually started and is now done. An unchanged
709
- // empty buffer means the stream hasn't started yet; keep waiting.
710
- if (next === drained && next.length > 0) break
835
+ // Stop when the buffer is quiet (unchanged) and either it holds the
836
+ // final message to flush, or we already flushed a message this turn
837
+ // (so an empty, quiet buffer means the turn is genuinely done the
838
+ // last message was flushed mid-stream). An unchanged *empty* buffer
839
+ // with nothing flushed yet means the stream hasn't started; keep
840
+ // waiting up to drainMaxMs.
841
+ if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
842
+ break
711
843
  drained = next
712
844
  }
713
- const text = buffers.get(sessionId) ?? ''
714
- if (text.length > 0) {
715
- const html = toMatrixHtml(text)
716
- const content: { msgtype: string; body: string; [k: string]: unknown } = {
717
- msgtype: 'm.text',
718
- body: text,
719
- }
720
- // Only attach formatted_body when it adds rich-text the plain body
721
- // can't carry. marked wraps plain prose in <p>…</p>; if that's all
722
- // we'd add, skip — most clients render `body` better than a stripped
723
- // re-encode.
724
- if (html) {
725
- const escapedPlain =
726
- '<p>' +
727
- text
728
- .replace(/&/g, '&amp;')
729
- .replace(/</g, '&lt;')
730
- .replace(/>/g, '&gt;') +
731
- '</p>'
732
- const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
733
- if (norm(html) !== norm(escapedPlain)) {
734
- content.format = 'org.matrix.custom.html'
735
- content.formatted_body = html
736
- }
737
- }
738
- await client.sendMessage({
739
- roomId: evt.room_id,
740
- asUserId: agent.userId,
741
- content,
742
- threadRoot, // every reply threads, full stop
743
- })
744
- } else {
845
+ // Flush the final assistant message — the one with no following messageId
846
+ // change or out-of-band event to have triggered an earlier flush.
847
+ flushBuffer(sessionId)
848
+ // Wait for every queued send (mid-turn flushes, tool/plan events, final
849
+ // flush) to settle before tearing the session down.
850
+ await (sendQueue.get(sessionId) ?? Promise.resolve())
851
+ if ((flushedCounts.get(sessionId) ?? 0) === 0) {
745
852
  console.warn(
746
853
  `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`,
747
854
  )
@@ -752,6 +859,8 @@ export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
752
859
  await safePresence('online')
753
860
  buffers.delete(sessionId)
754
861
  bufferMessageIds.delete(sessionId)
862
+ flushedCounts.delete(sessionId)
863
+ sendQueue.delete(sessionId)
755
864
  }
756
865
  }
757
866
 
@@ -36,7 +36,7 @@ describe('buildWorkforceRoster', () => {
36
36
  })
37
37
 
38
38
  describe('publishWorkforce', () => {
39
- it('PUTs eco.zoon.workforce state event on the configured space', async () => {
39
+ it('PUTs dev.zooid.workforce state event on the configured space', async () => {
40
40
  const fetch = vi.fn(async () => new Response('{}', { status: 200 }))
41
41
  const client = new MatrixClient({
42
42
  homeserver: 'https://hs.zoon.local',
@@ -54,7 +54,7 @@ describe('publishWorkforce', () => {
54
54
  expect(fetch).toHaveBeenCalledTimes(1)
55
55
  const [url, init] = fetch.mock.calls[0]!
56
56
  expect(url).toBe(
57
- 'https://hs.zoon.local/_matrix/client/v3/rooms/!space%3Azoon.local/state/eco.zoon.workforce/?user_id=%40zooid%3Azoon.local',
57
+ 'https://hs.zoon.local/_matrix/client/v3/rooms/!space%3Azoon.local/state/dev.zooid.workforce/?user_id=%40zooid%3Azoon.local',
58
58
  )
59
59
  expect(init?.method).toBe('PUT')
60
60
  expect(init?.headers).toMatchObject({ Authorization: 'Bearer as-tok' })
@@ -28,7 +28,7 @@ export async function publishWorkforce(opts: PublishOpts): Promise<void> {
28
28
  await opts.client.sendStateEvent({
29
29
  roomId: opts.spaceRoomId,
30
30
  asUserId: opts.asUserId,
31
- eventType: 'eco.zoon.workforce',
31
+ eventType: 'dev.zooid.workforce',
32
32
  stateKey: '',
33
33
  content: buildWorkforceRoster(opts.agents) as unknown as Record<string, unknown>,
34
34
  })