@zooid/transport-matrix 0.7.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/LICENSE +21 -0
- package/dist/index.d.ts +288 -0
- package/dist/index.js +1076 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -0
- package/src/bot-pool.test.ts +307 -0
- package/src/bot-pool.ts +112 -0
- package/src/context-provider.test.ts +317 -0
- package/src/context-provider.ts +187 -0
- package/src/event-encoders.test.ts +124 -0
- package/src/event-encoders.ts +66 -0
- package/src/index.ts +26 -0
- package/src/markdown-to-matrix-html.test.ts +102 -0
- package/src/markdown-to-matrix-html.ts +41 -0
- package/src/matrix-client.test.ts +307 -0
- package/src/matrix-client.ts +361 -0
- package/src/mentions.test.ts +90 -0
- package/src/mentions.ts +38 -0
- package/src/registration.test.ts +41 -0
- package/src/registration.ts +44 -0
- package/src/router.test.ts +90 -0
- package/src/router.ts +72 -0
- package/src/space-provisioner.test.ts +89 -0
- package/src/space-provisioner.ts +34 -0
- package/src/transport.test.ts +1164 -0
- package/src/transport.ts +521 -0
- package/src/workforce-publisher.test.ts +76 -0
- package/src/workforce-publisher.ts +53 -0
package/src/transport.ts
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
3
|
+
import type { AcpRegistry, ApprovalCorrelator, RegisteredApproval } from '@zooid/core'
|
|
4
|
+
import type { AgentEvent } from '@zooid/acp-client'
|
|
5
|
+
import { MatrixClient } from './matrix-client.js'
|
|
6
|
+
import { BotPool } from './bot-pool.js'
|
|
7
|
+
import { route, type AgentBinding, type ThreadState } from './router.js'
|
|
8
|
+
import { stripMention, extractMentions } from './mentions.js'
|
|
9
|
+
import { toToolCallBody, toUpdateBody, toPlanBody } from './event-encoders.js'
|
|
10
|
+
import { toMatrixHtml } from './markdown-to-matrix-html.js'
|
|
11
|
+
|
|
12
|
+
export interface CreateMatrixTransportOptions {
|
|
13
|
+
agents: AcpRegistry
|
|
14
|
+
approvals: ApprovalCorrelator
|
|
15
|
+
client: MatrixClient
|
|
16
|
+
bindings: AgentBinding[]
|
|
17
|
+
hsToken: string
|
|
18
|
+
/** Admin Matrix user ID. When set, BotPool.bootstrap invites this user into rooms it creates. */
|
|
19
|
+
adminUserId?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SessionContext {
|
|
23
|
+
agent: AgentBinding
|
|
24
|
+
roomId: string
|
|
25
|
+
/** Always set — every session is thread-scoped via agent-promotion. */
|
|
26
|
+
threadRoot: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface MatrixEvent {
|
|
30
|
+
type?: string
|
|
31
|
+
event_id?: string
|
|
32
|
+
origin_server_ts?: number
|
|
33
|
+
room_id?: string
|
|
34
|
+
sender?: string
|
|
35
|
+
content?: Record<string, unknown> & {
|
|
36
|
+
msgtype?: string
|
|
37
|
+
body?: string
|
|
38
|
+
'm.relates_to'?: { rel_type?: string; event_id?: string }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const STARTUP_GRACE_MS = 5_000
|
|
43
|
+
const SEEN_EVENT_CAP = 5_000
|
|
44
|
+
|
|
45
|
+
function inboundThreadRoot(evt: MatrixEvent): string | undefined {
|
|
46
|
+
const r = evt.content?.['m.relates_to']
|
|
47
|
+
return r?.rel_type === 'm.thread' && r.event_id ? r.event_id : undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createMatrixTransport(opts: CreateMatrixTransportOptions) {
|
|
51
|
+
const { agents, approvals, client, bindings, hsToken, adminUserId } = opts
|
|
52
|
+
const pool = new BotPool(client, bindings)
|
|
53
|
+
const sessions = new Map<string, SessionContext>()
|
|
54
|
+
const buffers = new Map<string, string>()
|
|
55
|
+
// Per-session promise tail so out-of-band events (tool_call, plan, etc.)
|
|
56
|
+
// serialize on the wire even though the ACP producer doesn't await us.
|
|
57
|
+
const sendQueue = new Map<string, Promise<void>>()
|
|
58
|
+
// Thread participation index: keyed by thread root event_id.
|
|
59
|
+
const threadStates = new Map<string, ThreadState>()
|
|
60
|
+
// Drop events older than this — Tuwunel may replay a backlog after the
|
|
61
|
+
// daemon was offline, and we don't want yesterday's "@docs hi" to fire now.
|
|
62
|
+
const cutoffTs = Date.now() - STARTUP_GRACE_MS
|
|
63
|
+
// Idempotency: appservice transactions are retried on 4xx/5xx/timeout, and
|
|
64
|
+
// the same event_id can arrive twice. Skip ones we've already taken.
|
|
65
|
+
const seenEventIds = new Set<string>()
|
|
66
|
+
|
|
67
|
+
agents.onEvent = async (name, event: AgentEvent) => {
|
|
68
|
+
const ctx = sessions.get(event.sessionId)
|
|
69
|
+
if (!ctx) {
|
|
70
|
+
console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (event.type === 'agent_message_chunk') {
|
|
75
|
+
const block = event.content as { type?: string; text?: string }
|
|
76
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
77
|
+
buffers.set(event.sessionId, (buffers.get(event.sessionId) ?? '') + block.text)
|
|
78
|
+
} else {
|
|
79
|
+
console.warn(`[matrix:${name}] dropped chunk block type=${block.type}`, block)
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const eventType =
|
|
85
|
+
event.type === 'tool_call'
|
|
86
|
+
? 'eco.zoon.tool_call'
|
|
87
|
+
: event.type === 'tool_call_update'
|
|
88
|
+
? 'eco.zoon.tool_call_update'
|
|
89
|
+
: 'eco.zoon.plan'
|
|
90
|
+
const body =
|
|
91
|
+
event.type === 'tool_call'
|
|
92
|
+
? toToolCallBody(event)
|
|
93
|
+
: event.type === 'tool_call_update'
|
|
94
|
+
? toUpdateBody(event)
|
|
95
|
+
: toPlanBody(event)
|
|
96
|
+
body['m.relates_to'] = { rel_type: 'm.thread', event_id: ctx.threadRoot }
|
|
97
|
+
const tail = (sendQueue.get(event.sessionId) ?? Promise.resolve()).then(async () => {
|
|
98
|
+
try {
|
|
99
|
+
await client.sendCustomEvent({
|
|
100
|
+
roomId: ctx.roomId,
|
|
101
|
+
asUserId: ctx.agent.userId,
|
|
102
|
+
eventType,
|
|
103
|
+
content: body,
|
|
104
|
+
})
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.warn(`[matrix:${name}] sendCustomEvent(${eventType}) failed:`, err)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
sendQueue.set(event.sessionId, tail)
|
|
110
|
+
await tail
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
agents.onApprovalRequest = async (name, req) => {
|
|
114
|
+
const handle = approvals.register(name, (req as { sessionId: string }).sessionId, req, {
|
|
115
|
+
timeoutMs: agents.getApprovalTimeoutMs(name),
|
|
116
|
+
})
|
|
117
|
+
return handle.decisionPromise
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
approvals.on('registered', (handle: RegisteredApproval) => {
|
|
121
|
+
const ctx = sessions.get(handle.sessionId)
|
|
122
|
+
if (!ctx) return
|
|
123
|
+
const content: Record<string, unknown> = {
|
|
124
|
+
approval_id: handle.approvalId,
|
|
125
|
+
session_id: handle.sessionId,
|
|
126
|
+
tool_call_id: handle.toolCallId,
|
|
127
|
+
options: handle.options,
|
|
128
|
+
}
|
|
129
|
+
content['m.relates_to'] = { rel_type: 'm.thread', event_id: ctx.threadRoot }
|
|
130
|
+
if (handle.toolKind !== undefined) content.tool_kind = handle.toolKind
|
|
131
|
+
if (handle.toolTitle !== undefined) content.tool_title = handle.toolTitle
|
|
132
|
+
if (handle.toolInput !== undefined) content.tool_input = handle.toolInput
|
|
133
|
+
void client.sendCustomEvent({
|
|
134
|
+
roomId: ctx.roomId,
|
|
135
|
+
asUserId: ctx.agent.userId,
|
|
136
|
+
eventType: 'eco.zoon.approval_request',
|
|
137
|
+
content,
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const app = new Hono()
|
|
142
|
+
|
|
143
|
+
function authOk(authHeader: string | undefined): boolean {
|
|
144
|
+
const h = authHeader ?? ''
|
|
145
|
+
if (!h.startsWith('Bearer ')) return false
|
|
146
|
+
const got = h.slice(7)
|
|
147
|
+
if (got.length !== hsToken.length) return false
|
|
148
|
+
return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
app.put('/_matrix/app/v1/transactions/:txnId', async (c) => {
|
|
152
|
+
if (!authOk(c.req.header('authorization'))) {
|
|
153
|
+
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
154
|
+
}
|
|
155
|
+
const body = (await c.req.json().catch(() => ({}))) as { events?: MatrixEvent[] }
|
|
156
|
+
for (const evt of body.events ?? []) {
|
|
157
|
+
if (evt.event_id) {
|
|
158
|
+
if (seenEventIds.has(evt.event_id)) {
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
seenEventIds.add(evt.event_id)
|
|
162
|
+
if (seenEventIds.size > SEEN_EVENT_CAP) {
|
|
163
|
+
const first = seenEventIds.values().next().value
|
|
164
|
+
if (first !== undefined) seenEventIds.delete(first)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
evt.origin_server_ts !== undefined &&
|
|
169
|
+
evt.origin_server_ts < cutoffTs &&
|
|
170
|
+
evt.type === 'm.room.message'
|
|
171
|
+
) {
|
|
172
|
+
console.log(
|
|
173
|
+
`[matrix] dropping stale message event ${evt.event_id} ` +
|
|
174
|
+
`(ts=${evt.origin_server_ts}, daemon started at ${cutoffTs + STARTUP_GRACE_MS})`,
|
|
175
|
+
)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if (evt.type === 'eco.zoon.session_reset') {
|
|
179
|
+
// Spec § /clear: room-scope reset is unsupported. Only thread-scoped
|
|
180
|
+
// resets carry a thread relation; drop bare room-level resets silently.
|
|
181
|
+
const relates = evt.content?.['m.relates_to'] as
|
|
182
|
+
| { rel_type?: string; event_id?: string }
|
|
183
|
+
| undefined
|
|
184
|
+
const threadRoot =
|
|
185
|
+
relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
|
|
186
|
+
if (!threadRoot) {
|
|
187
|
+
console.log('[matrix] dropping eco.zoon.session_reset without thread relation')
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
console.log(`[matrix] inbound eco.zoon.session_reset in ${evt.room_id} thread=${threadRoot}`)
|
|
191
|
+
for (const a of bindings) {
|
|
192
|
+
agents.endSession(a.name, threadRoot)
|
|
193
|
+
}
|
|
194
|
+
// NB: keep threadStates intact. Per ZOD039 § /clear, only the agent's
|
|
195
|
+
// session memory is wiped — thread-routing state (participants /
|
|
196
|
+
// root-mentions) must survive so the next bare reply still routes to
|
|
197
|
+
// the most-recently-posting agent under the same sessionKey.
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (evt.type === 'eco.zoon.interrupt') {
|
|
201
|
+
const content = (evt.content ?? {}) as { session_id?: string; reason?: string }
|
|
202
|
+
// Thread-relation form (client-friendly): /interrupt in a thread sends
|
|
203
|
+
// an empty event with `m.relates_to: thread/<root>`. Cancel every
|
|
204
|
+
// session whose threadRoot matches.
|
|
205
|
+
const relates = evt.content?.['m.relates_to'] as
|
|
206
|
+
| { rel_type?: string; event_id?: string }
|
|
207
|
+
| undefined
|
|
208
|
+
const threadRoot =
|
|
209
|
+
relates?.rel_type === 'm.thread' && relates.event_id ? relates.event_id : undefined
|
|
210
|
+
if (threadRoot) {
|
|
211
|
+
const targets: Array<{ sessionId: string; agent: string }> = []
|
|
212
|
+
for (const [sessionId, ctx] of sessions) {
|
|
213
|
+
if (ctx.threadRoot === threadRoot) {
|
|
214
|
+
targets.push({ sessionId, agent: ctx.agent.name })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const t of targets) {
|
|
218
|
+
console.log(
|
|
219
|
+
`[matrix] interrupt session=${t.sessionId} agent=${t.agent} thread=${threadRoot}` +
|
|
220
|
+
(content.reason ? ` reason=${content.reason}` : ''),
|
|
221
|
+
)
|
|
222
|
+
await agents.cancelSession(t.agent, t.sessionId).catch((err) => {
|
|
223
|
+
console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err)
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
continue
|
|
227
|
+
}
|
|
228
|
+
// Legacy form: explicit session_id in content.
|
|
229
|
+
if (!content.session_id) {
|
|
230
|
+
console.warn(`[matrix] eco.zoon.interrupt missing session_id (event_id=${evt.event_id})`)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const ctx = sessions.get(content.session_id)
|
|
234
|
+
if (!ctx) {
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
console.log(
|
|
238
|
+
`[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` +
|
|
239
|
+
(content.reason ? ` reason=${content.reason}` : ''),
|
|
240
|
+
)
|
|
241
|
+
await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
|
|
242
|
+
console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err)
|
|
243
|
+
})
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
if (evt.type === 'eco.zoon.approval_response') {
|
|
247
|
+
const content = (evt.content ?? {}) as {
|
|
248
|
+
approval_id?: string
|
|
249
|
+
session_id?: string
|
|
250
|
+
decision?: string
|
|
251
|
+
option_id?: string
|
|
252
|
+
}
|
|
253
|
+
if (!content.session_id || !content.approval_id || !content.decision) continue
|
|
254
|
+
const decision = content.option_id
|
|
255
|
+
? { decision: content.decision, optionId: content.option_id }
|
|
256
|
+
: { decision: content.decision }
|
|
257
|
+
const ok = approvals.resolve(
|
|
258
|
+
content.session_id,
|
|
259
|
+
content.approval_id,
|
|
260
|
+
decision as never,
|
|
261
|
+
)
|
|
262
|
+
if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`)
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
logInbound(evt)
|
|
266
|
+
// Agent-promotion: top-level inbound event becomes the thread root.
|
|
267
|
+
// For in-thread messages the existing root is preserved.
|
|
268
|
+
const promotedRoot = inboundThreadRoot(evt) ?? evt.event_id
|
|
269
|
+
// Self-heal: if this is a thread reply but we have no in-memory state
|
|
270
|
+
// for the root (e.g. daemon was just restarted), reconstruct it by
|
|
271
|
+
// fetching the thread root + relations from the server.
|
|
272
|
+
const inboundRel = inboundThreadRoot(evt)
|
|
273
|
+
if (
|
|
274
|
+
evt.type === 'm.room.message' &&
|
|
275
|
+
inboundRel &&
|
|
276
|
+
!threadStates.has(inboundRel) &&
|
|
277
|
+
evt.room_id
|
|
278
|
+
) {
|
|
279
|
+
try {
|
|
280
|
+
const rebuilt = await rebuildThreadState(client, evt.room_id, inboundRel, bindings)
|
|
281
|
+
threadStates.set(inboundRel, rebuilt)
|
|
282
|
+
console.log(
|
|
283
|
+
`[matrix] rebuilt threadState for ${inboundRel}: participants=${rebuilt.participants.join(',')} rootMentions=${rebuilt.rootMentions.join(',')}`,
|
|
284
|
+
)
|
|
285
|
+
} catch (err) {
|
|
286
|
+
console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const matches = route(evt, bindings, threadStates)
|
|
290
|
+
// Suppress the no-match warning for events sent by our own bots.
|
|
291
|
+
const senderIsBot = bindings.some((b) => b.userId === evt.sender)
|
|
292
|
+
if (evt.type === 'm.room.message' && matches.length === 0 && !senderIsBot) {
|
|
293
|
+
console.warn(
|
|
294
|
+
`[matrix] no agent matched message in ${evt.room_id} from ${evt.sender}` +
|
|
295
|
+
` (bindings: ${bindings.map((b) => `${b.name}@${b.userId}[${b.trigger}]`).join(', ')})`,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
// Seed thread state for any agent mentions in this event.
|
|
299
|
+
if (matches.length > 0 && promotedRoot) {
|
|
300
|
+
let st = threadStates.get(promotedRoot)
|
|
301
|
+
if (!st) {
|
|
302
|
+
st = { participants: [], rootMentions: [] }
|
|
303
|
+
threadStates.set(promotedRoot, st)
|
|
304
|
+
}
|
|
305
|
+
const msgMentions = new Set(extractMentions(evt as never))
|
|
306
|
+
for (const a of bindings) {
|
|
307
|
+
if (msgMentions.has(a.userId) && !st.rootMentions.includes(a.name)) {
|
|
308
|
+
st.rootMentions.push(a.name)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
for (const a of matches) {
|
|
313
|
+
console.log(`[matrix] → ${a.name} (${a.userId})`)
|
|
314
|
+
void runTurn(a, evt)
|
|
315
|
+
.then(() => {
|
|
316
|
+
if (!promotedRoot) return
|
|
317
|
+
let st = threadStates.get(promotedRoot)
|
|
318
|
+
if (!st) {
|
|
319
|
+
st = { participants: [], rootMentions: [] }
|
|
320
|
+
threadStates.set(promotedRoot, st)
|
|
321
|
+
}
|
|
322
|
+
if (st.participants.at(-1) !== a.name) st.participants.push(a.name)
|
|
323
|
+
})
|
|
324
|
+
.catch((err) => {
|
|
325
|
+
console.error(`[matrix] runTurn failed for ${a.name}:`, err)
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return c.json({})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
app.get('/_matrix/app/v1/users/:userId', (c) => {
|
|
333
|
+
if (!authOk(c.req.header('authorization'))) {
|
|
334
|
+
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
335
|
+
}
|
|
336
|
+
return c.json({})
|
|
337
|
+
})
|
|
338
|
+
app.get('/_matrix/app/v1/rooms/:alias', (c) => {
|
|
339
|
+
if (!authOk(c.req.header('authorization'))) {
|
|
340
|
+
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
341
|
+
}
|
|
342
|
+
return c.json({ errcode: 'M_NOT_FOUND' }, 404)
|
|
343
|
+
})
|
|
344
|
+
app.post('/_matrix/app/v1/ping', (c) => {
|
|
345
|
+
if (!authOk(c.req.header('authorization'))) {
|
|
346
|
+
return c.json({ errcode: 'M_FORBIDDEN' }, 403)
|
|
347
|
+
}
|
|
348
|
+
return c.json({})
|
|
349
|
+
})
|
|
350
|
+
app.get('/healthz', (c) => c.text('ok'))
|
|
351
|
+
|
|
352
|
+
async function runTurn(agent: AgentBinding, evt: MatrixEvent): Promise<void> {
|
|
353
|
+
if (!evt.room_id || !evt.event_id) return
|
|
354
|
+
const inbound = inboundThreadRoot(evt)
|
|
355
|
+
// Agent-promotion: top-level inbound becomes a thread root via the agent's
|
|
356
|
+
// first reply. sessionKey is always a thread root id, never the room id.
|
|
357
|
+
const threadRoot = inbound ?? evt.event_id
|
|
358
|
+
const sessionKey = threadRoot
|
|
359
|
+
const sessionId = await agents.ensureSession(agent.name, sessionKey, evt.room_id)
|
|
360
|
+
sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot })
|
|
361
|
+
buffers.set(sessionId, '')
|
|
362
|
+
|
|
363
|
+
const roomId = evt.room_id
|
|
364
|
+
const TYPING_TTL_MS = 30_000
|
|
365
|
+
const TYPING_REFRESH_MS = 25_000
|
|
366
|
+
const safeTyping = (typing: boolean) =>
|
|
367
|
+
client
|
|
368
|
+
.setTyping({ roomId, asUserId: agent.userId, typing, timeoutMs: TYPING_TTL_MS })
|
|
369
|
+
.catch((err) => console.warn(`[matrix:${agent.name}] setTyping(${typing}) failed:`, err))
|
|
370
|
+
const safePresence = (presence: 'online' | 'unavailable' | 'offline') =>
|
|
371
|
+
client
|
|
372
|
+
.setPresence({ asUserId: agent.userId, presence })
|
|
373
|
+
.catch((err) =>
|
|
374
|
+
console.warn(`[matrix:${agent.name}] setPresence(${presence}) failed:`, err),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
await safeTyping(true)
|
|
378
|
+
await safePresence('unavailable')
|
|
379
|
+
const refresh = setInterval(() => {
|
|
380
|
+
void safeTyping(true)
|
|
381
|
+
}, TYPING_REFRESH_MS)
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const rawBody = evt.content?.body ?? ''
|
|
385
|
+
const promptText = stripMention(rawBody, agent.userId)
|
|
386
|
+
await agents.prompt(agent.name, {
|
|
387
|
+
threadId: sessionKey,
|
|
388
|
+
channelId: evt.room_id,
|
|
389
|
+
content: [{ type: 'text', text: promptText }],
|
|
390
|
+
})
|
|
391
|
+
const text = buffers.get(sessionId) ?? ''
|
|
392
|
+
if (text.length > 0) {
|
|
393
|
+
const html = toMatrixHtml(text)
|
|
394
|
+
const content: { msgtype: string; body: string; [k: string]: unknown } = {
|
|
395
|
+
msgtype: 'm.text',
|
|
396
|
+
body: text,
|
|
397
|
+
}
|
|
398
|
+
// Only attach formatted_body when it adds rich-text the plain body
|
|
399
|
+
// can't carry. marked wraps plain prose in <p>…</p>; if that's all
|
|
400
|
+
// we'd add, skip — most clients render `body` better than a stripped
|
|
401
|
+
// re-encode.
|
|
402
|
+
if (html) {
|
|
403
|
+
const escapedPlain =
|
|
404
|
+
'<p>' +
|
|
405
|
+
text
|
|
406
|
+
.replace(/&/g, '&')
|
|
407
|
+
.replace(/</g, '<')
|
|
408
|
+
.replace(/>/g, '>') +
|
|
409
|
+
'</p>'
|
|
410
|
+
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
|
411
|
+
if (norm(html) !== norm(escapedPlain)) {
|
|
412
|
+
content.format = 'org.matrix.custom.html'
|
|
413
|
+
content.formatted_body = html
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
await client.sendMessage({
|
|
417
|
+
roomId: evt.room_id,
|
|
418
|
+
asUserId: agent.userId,
|
|
419
|
+
content,
|
|
420
|
+
threadRoot, // every reply threads, full stop
|
|
421
|
+
})
|
|
422
|
+
} else {
|
|
423
|
+
console.warn(
|
|
424
|
+
`[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`,
|
|
425
|
+
)
|
|
426
|
+
}
|
|
427
|
+
} finally {
|
|
428
|
+
clearInterval(refresh)
|
|
429
|
+
await safeTyping(false)
|
|
430
|
+
await safePresence('online')
|
|
431
|
+
buffers.delete(sessionId)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
app,
|
|
437
|
+
bootstrap: async (bootstrapOpts: { spaceRoomId?: string; asUserId?: string } = {}) => {
|
|
438
|
+
await pool.bootstrap({ adminUserId, ...bootstrapOpts })
|
|
439
|
+
await Promise.allSettled(
|
|
440
|
+
bindings.map((b) =>
|
|
441
|
+
client.setPresence({ asUserId: b.userId, presence: 'online' }).catch((err) => {
|
|
442
|
+
console.warn(`[matrix:${b.name}] initial setPresence(online) failed:`, err)
|
|
443
|
+
}),
|
|
444
|
+
),
|
|
445
|
+
)
|
|
446
|
+
},
|
|
447
|
+
pool,
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Reconstruct the in-memory ThreadState for a thread root by fetching the
|
|
453
|
+
* root event + its thread relations from the server. Used to recover the
|
|
454
|
+
* implicit-routing rule from ZOD039 § Implicit triggers in threads after a
|
|
455
|
+
* daemon restart wipes the in-memory cache.
|
|
456
|
+
*/
|
|
457
|
+
export async function rebuildThreadState(
|
|
458
|
+
client: MatrixClient,
|
|
459
|
+
roomId: string,
|
|
460
|
+
rootEventId: string,
|
|
461
|
+
bindings: AgentBinding[],
|
|
462
|
+
): Promise<ThreadState> {
|
|
463
|
+
const state: ThreadState = { participants: [], rootMentions: [] }
|
|
464
|
+
// Impersonate an agent that's actually a member of this room (AS reads
|
|
465
|
+
// require room membership). Falling through to the first binding would
|
|
466
|
+
// 403 if that agent never joined the target room.
|
|
467
|
+
const asUser = (bindings.find((b) => b.rooms.includes(roomId)) ?? bindings[0])?.userId
|
|
468
|
+
if (!asUser) return state
|
|
469
|
+
|
|
470
|
+
const root = await client.fetchEvent(roomId, rootEventId, asUser)
|
|
471
|
+
if (root) {
|
|
472
|
+
const rootMentions = new Set(extractMentions(root as never))
|
|
473
|
+
for (const a of bindings) {
|
|
474
|
+
if (rootMentions.has(a.userId) && !state.rootMentions.includes(a.name)) {
|
|
475
|
+
state.rootMentions.push(a.name)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const { chunk: thread } = await client.fetchThreadRelations({
|
|
481
|
+
roomId,
|
|
482
|
+
rootEventId,
|
|
483
|
+
asUserId: asUser,
|
|
484
|
+
})
|
|
485
|
+
// Also seed root-mentions from any subsequent agent @mentions in the thread.
|
|
486
|
+
for (const ev of thread) {
|
|
487
|
+
const mentions = new Set(extractMentions(ev as never))
|
|
488
|
+
for (const a of bindings) {
|
|
489
|
+
if (mentions.has(a.userId) && !state.rootMentions.includes(a.name)) {
|
|
490
|
+
state.rootMentions.push(a.name)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const sender = (ev as { sender?: string }).sender
|
|
494
|
+
const type = (ev as { type?: string }).type
|
|
495
|
+
if (type === 'm.room.message' && sender) {
|
|
496
|
+
const a = bindings.find((b) => b.userId === sender)
|
|
497
|
+
if (a && state.participants.at(-1) !== a.name) state.participants.push(a.name)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return state
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function logInbound(evt: MatrixEvent): void {
|
|
504
|
+
const sender = evt.sender ?? '?'
|
|
505
|
+
const room = evt.room_id ?? '?'
|
|
506
|
+
const type = evt.type ?? '?'
|
|
507
|
+
if (type === 'm.room.message') {
|
|
508
|
+
const body = evt.content?.body ?? ''
|
|
509
|
+
const mentions = (evt.content?.['m.mentions'] as { user_ids?: string[] } | undefined)?.user_ids
|
|
510
|
+
const mentionsStr = mentions?.length ? ` mentions=${JSON.stringify(mentions)}` : ''
|
|
511
|
+
console.log(
|
|
512
|
+
`[matrix] inbound msg in ${room} from ${sender}${mentionsStr}: ${truncate(body, 200)}`,
|
|
513
|
+
)
|
|
514
|
+
} else {
|
|
515
|
+
console.log(`[matrix] inbound ${type} in ${room} from ${sender}`)
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function truncate(s: string, n: number): string {
|
|
520
|
+
return s.length > n ? s.slice(0, n) + '…' : s
|
|
521
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { MatrixClient } from './matrix-client.js'
|
|
3
|
+
import { buildWorkforceRoster, publishWorkforce } from './workforce-publisher.js'
|
|
4
|
+
import type { AgentBinding } from './router.js'
|
|
5
|
+
|
|
6
|
+
const agents: AgentBinding[] = [
|
|
7
|
+
{ name: 'planner', userId: '@planner:zoon.local', rooms: ['!eng:zoon.local'], trigger: 'mention' },
|
|
8
|
+
{ name: 'reviewer', userId: '@reviewer:zoon.local', rooms: ['!eng:zoon.local', '!review:zoon.local'], trigger: 'any' },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
describe('buildWorkforceRoster', () => {
|
|
12
|
+
it('emits version, agents list with user_id/name/rooms', () => {
|
|
13
|
+
const roster = buildWorkforceRoster(agents)
|
|
14
|
+
expect(roster).toEqual({
|
|
15
|
+
version: 1,
|
|
16
|
+
agents: [
|
|
17
|
+
{ user_id: '@planner:zoon.local', name: 'planner', rooms: ['!eng:zoon.local'] },
|
|
18
|
+
{ user_id: '@reviewer:zoon.local', name: 'reviewer', rooms: ['!eng:zoon.local', '!review:zoon.local'] },
|
|
19
|
+
],
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('handles empty workforce', () => {
|
|
24
|
+
expect(buildWorkforceRoster([])).toEqual({ version: 1, agents: [] })
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('publishWorkforce', () => {
|
|
29
|
+
it('PUTs eco.zoon.workforce state event on the configured space', async () => {
|
|
30
|
+
const fetch = vi.fn(async () => new Response('{}', { status: 200 }))
|
|
31
|
+
const client = new MatrixClient({
|
|
32
|
+
homeserver: 'https://hs.zoon.local',
|
|
33
|
+
asToken: 'as-tok',
|
|
34
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
await publishWorkforce({
|
|
38
|
+
client,
|
|
39
|
+
spaceRoomId: '!space:zoon.local',
|
|
40
|
+
asUserId: '@zooid:zoon.local',
|
|
41
|
+
agents,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(fetch).toHaveBeenCalledTimes(1)
|
|
45
|
+
const [url, init] = fetch.mock.calls[0]!
|
|
46
|
+
expect(url).toBe(
|
|
47
|
+
'https://hs.zoon.local/_matrix/client/v3/rooms/!space%3Azoon.local/state/eco.zoon.workforce/?user_id=%40zooid%3Azoon.local',
|
|
48
|
+
)
|
|
49
|
+
expect(init?.method).toBe('PUT')
|
|
50
|
+
expect(init?.headers).toMatchObject({ Authorization: 'Bearer as-tok' })
|
|
51
|
+
expect(JSON.parse(init?.body as string)).toEqual({
|
|
52
|
+
version: 1,
|
|
53
|
+
agents: [
|
|
54
|
+
{ user_id: '@planner:zoon.local', name: 'planner', rooms: ['!eng:zoon.local'] },
|
|
55
|
+
{ user_id: '@reviewer:zoon.local', name: 'reviewer', rooms: ['!eng:zoon.local', '!review:zoon.local'] },
|
|
56
|
+
],
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('throws on non-2xx', async () => {
|
|
61
|
+
const fetch = vi.fn(async () => new Response('forbidden', { status: 403 }))
|
|
62
|
+
const client = new MatrixClient({
|
|
63
|
+
homeserver: 'https://hs.zoon.local',
|
|
64
|
+
asToken: 'as-tok',
|
|
65
|
+
fetch: fetch as unknown as typeof globalThis.fetch,
|
|
66
|
+
})
|
|
67
|
+
await expect(
|
|
68
|
+
publishWorkforce({
|
|
69
|
+
client,
|
|
70
|
+
spaceRoomId: '!space:zoon.local',
|
|
71
|
+
asUserId: '@zooid:zoon.local',
|
|
72
|
+
agents,
|
|
73
|
+
}),
|
|
74
|
+
).rejects.toThrow(/403/)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { MatrixClient } from './matrix-client.js'
|
|
2
|
+
import type { AgentBinding } from './router.js'
|
|
3
|
+
|
|
4
|
+
export interface WorkforceRoster {
|
|
5
|
+
version: 1
|
|
6
|
+
agents: { user_id: string; name: string; rooms: string[] }[]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildWorkforceRoster(agents: AgentBinding[]): WorkforceRoster {
|
|
10
|
+
return {
|
|
11
|
+
version: 1,
|
|
12
|
+
agents: agents.map((a) => ({ user_id: a.userId, name: a.name, rooms: a.rooms })),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PublishOpts {
|
|
17
|
+
client: MatrixClient
|
|
18
|
+
spaceRoomId: string
|
|
19
|
+
asUserId: string
|
|
20
|
+
agents: AgentBinding[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function publishWorkforce(opts: PublishOpts): Promise<void> {
|
|
24
|
+
await opts.client.sendStateEvent({
|
|
25
|
+
roomId: opts.spaceRoomId,
|
|
26
|
+
asUserId: opts.asUserId,
|
|
27
|
+
eventType: 'eco.zoon.workforce',
|
|
28
|
+
stateKey: '',
|
|
29
|
+
content: buildWorkforceRoster(opts.agents) as unknown as Record<string, unknown>,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PublisherHandle {
|
|
34
|
+
reload(): Promise<void>
|
|
35
|
+
stop(): Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StartOpts {
|
|
39
|
+
client: MatrixClient
|
|
40
|
+
spaceRoomId: string
|
|
41
|
+
asUserId: string
|
|
42
|
+
getAgents: () => AgentBinding[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function startWorkforcePublisher(opts: StartOpts): Promise<PublisherHandle> {
|
|
46
|
+
await publishWorkforce({ ...opts, agents: opts.getAgents() })
|
|
47
|
+
return {
|
|
48
|
+
async reload() {
|
|
49
|
+
await publishWorkforce({ ...opts, agents: opts.getAgents() })
|
|
50
|
+
},
|
|
51
|
+
async stop() {},
|
|
52
|
+
}
|
|
53
|
+
}
|