@zooid/transport-matrix 0.8.0 → 0.9.1

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/dist/index.js CHANGED
@@ -125,6 +125,18 @@ var MatrixClient = class {
125
125
  }
126
126
  throw new Error(`invite(${opts.targetUserId}) failed: ${r.status}`);
127
127
  }
128
+ async leaveRoom(roomId, asUserId, opts) {
129
+ const url = `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave?user_id=${encodeURIComponent(asUserId)}`;
130
+ const r = await this.fetch(url, {
131
+ method: "POST",
132
+ headers: {
133
+ Authorization: `Bearer ${this.asToken}`,
134
+ "content-type": "application/json"
135
+ },
136
+ body: JSON.stringify(opts?.reason ? { reason: opts.reason } : {})
137
+ });
138
+ if (!r.ok) throw new Error(`leaveRoom(${roomId}, ${asUserId}) failed: ${r.status}`);
139
+ }
128
140
  async joinRoom(roomIdOrAlias, asUserId) {
129
141
  const url = `${this.homeserver}/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}?user_id=${encodeURIComponent(asUserId)}`;
130
142
  const r = await this.fetch(url, {
@@ -243,6 +255,19 @@ var MatrixClient = class {
243
255
  if (!r.ok) throw new Error(`getJoinedMembers(${roomId}) failed: ${r.status}`);
244
256
  return await r.json();
245
257
  }
258
+ async sync(opts) {
259
+ const params = new URLSearchParams({
260
+ user_id: opts.asUserId,
261
+ timeout: String(opts.timeoutMs ?? 3e4)
262
+ });
263
+ if (opts.since) params.set("since", opts.since);
264
+ const url = `${this.homeserver}/_matrix/client/v3/sync?${params.toString()}`;
265
+ const r = await this.fetch(url, {
266
+ headers: { Authorization: `Bearer ${this.asToken}` }
267
+ });
268
+ if (!r.ok) throw new Error(`sync(${opts.asUserId}) failed: ${r.status}`);
269
+ return r.json();
270
+ }
246
271
  async fetchRoomName(roomId, asUserId) {
247
272
  const url = `${this.homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name/?user_id=${encodeURIComponent(asUserId)}`;
248
273
  const r = await this.fetch(url, {
@@ -272,6 +297,7 @@ var MatrixContextProvider = class {
272
297
  constructor(opts) {
273
298
  this.opts = opts;
274
299
  }
300
+ opts;
275
301
  async getRoomHistory(channelId, hopts) {
276
302
  const { chunk, end } = await this.opts.client.fetchRoomMessages({
277
303
  roomId: channelId,
@@ -431,6 +457,29 @@ function renderRegistration(c) {
431
457
  );
432
458
  }
433
459
 
460
+ // src/identity.ts
461
+ var SLUG_RE = /^[a-z0-9-]+$/;
462
+ var AGENT_KEY_RE = /^[a-z0-9-]+$/;
463
+ var isValidWorkstation = (s) => SLUG_RE.test(s);
464
+ var isValidAgentKey = (s) => AGENT_KEY_RE.test(s);
465
+ function agentMxid(workstation, agent, serverName) {
466
+ if (!isValidWorkstation(workstation))
467
+ throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`);
468
+ if (!isValidAgentKey(agent)) throw new Error(`invalid agent key: ${JSON.stringify(agent)}`);
469
+ return `@${workstation}.${agent}:${serverName}`;
470
+ }
471
+ function splitAgentLocalpart(localpart2) {
472
+ const i = localpart2.indexOf(".");
473
+ if (i <= 0 || i === localpart2.length - 1)
474
+ throw new Error(`not a workstation.agent localpart: ${localpart2}`);
475
+ return { workstation: localpart2.slice(0, i), agent: localpart2.slice(i + 1) };
476
+ }
477
+ function workstationUserNamespace(workstation, serverName) {
478
+ if (!isValidWorkstation(workstation))
479
+ throw new Error(`invalid workstation: ${JSON.stringify(workstation)}`);
480
+ return `@${workstation}\\..*:${serverName}`;
481
+ }
482
+
434
483
  // src/mentions.ts
435
484
  var MATRIX_TO_RE = /https:\/\/matrix\.to\/#\/(@[^"<>\s]+)/g;
436
485
  var RAW_USER_RE = /(@[A-Za-z0-9._\-=/+]+:[A-Za-z0-9.\-]+)/g;
@@ -561,6 +610,8 @@ var BotPool = class {
561
610
  this.client = client;
562
611
  this.agents = agents;
563
612
  }
613
+ client;
614
+ agents;
564
615
  async bootstrap(opts = {}) {
565
616
  const aliasToId = /* @__PURE__ */ new Map();
566
617
  const attachedToSpace = /* @__PURE__ */ new Set();
@@ -606,7 +657,7 @@ var BotPool = class {
606
657
  } else {
607
658
  const colon = room.indexOf(":");
608
659
  const aliasLocalpart = colon > 1 ? room.slice(1, colon) : room.slice(1);
609
- const sender = opts.adminUserId ?? a.userId;
660
+ const sender = opts.asUserId ?? opts.adminUserId ?? a.userId;
610
661
  const userPowerLevels = buildUserPowerLevels(
611
662
  opts.asUserId,
612
663
  opts.adminUserIds,
@@ -729,6 +780,15 @@ function toPlanBody(evt) {
729
780
  entries: evt.entries
730
781
  };
731
782
  }
783
+ function toAvailableCommandsBody(evt) {
784
+ return {
785
+ session_id: evt.sessionId,
786
+ available_commands: evt.commands.map((c) => ({
787
+ name: c.name,
788
+ description: c.description
789
+ }))
790
+ };
791
+ }
732
792
  var RECOVERY_URLS = {
733
793
  auth_missing: "https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over",
734
794
  auth_invalid: "https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over",
@@ -916,6 +976,44 @@ function writeAttachment(input) {
916
976
  return { hostPath, agentPath };
917
977
  }
918
978
 
979
+ // src/sync-loop.ts
980
+ var SyncLoop = class {
981
+ opts;
982
+ running = false;
983
+ constructor(opts) {
984
+ this.opts = opts;
985
+ }
986
+ async tick() {
987
+ const since = this.opts.loadSince();
988
+ const res = await this.opts.client.sync({
989
+ asUserId: this.opts.asUserId,
990
+ since,
991
+ timeoutMs: this.opts.timeoutMs ?? 3e4
992
+ });
993
+ for (const [roomId, roomState] of Object.entries(res.rooms?.join ?? {})) {
994
+ for (const baseEvt of roomState.timeline?.events ?? []) {
995
+ await this.opts.onEvent({ ...baseEvt, room_id: roomId });
996
+ }
997
+ }
998
+ this.opts.saveSince(res.next_batch);
999
+ }
1000
+ async run() {
1001
+ this.running = true;
1002
+ while (this.running) {
1003
+ try {
1004
+ await this.tick();
1005
+ } catch (err) {
1006
+ if (!this.running) break;
1007
+ console.warn(`[sync-loop] ${this.opts.asUserId} tick failed, retrying:`, err);
1008
+ await new Promise((r) => setTimeout(r, this.opts.retryDelayMs ?? 5e3));
1009
+ }
1010
+ }
1011
+ }
1012
+ stop() {
1013
+ this.running = false;
1014
+ }
1015
+ };
1016
+
919
1017
  // src/transport.ts
920
1018
  var STARTUP_GRACE_MS = 5e3;
921
1019
  async function buildMediaBlocks(items, opts) {
@@ -985,7 +1083,7 @@ async function sendMediaError(ctx, _err, message, client) {
985
1083
  await client.sendCustomEvent({
986
1084
  roomId: ctx.roomId,
987
1085
  asUserId: ctx.agent.userId,
988
- eventType: "eco.zoon.error",
1086
+ eventType: "dev.zooid.error",
989
1087
  content: toErrorBody(
990
1088
  {
991
1089
  kind: "error",
@@ -998,7 +1096,7 @@ async function sendMediaError(ctx, _err, message, client) {
998
1096
  },
999
1097
  ctx.threadRoot
1000
1098
  )
1001
- }).catch((e) => console.warn(`[matrix:${ctx.agent.name}] eco.zoon.error send failed:`, e));
1099
+ }).catch((e) => console.warn(`[matrix:${ctx.agent.name}] dev.zooid.error send failed:`, e));
1002
1100
  }
1003
1101
  var SEEN_EVENT_CAP = 5e3;
1004
1102
  var DRAIN_QUIET_MS = 300;
@@ -1009,37 +1107,87 @@ function inboundThreadRoot2(evt) {
1009
1107
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
1010
1108
  }
1011
1109
  function createMatrixTransport(opts) {
1012
- const { agents, approvals, client, bindings, hsToken, adminUserId } = opts;
1110
+ const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId, mode = "appservice" } = opts;
1013
1111
  const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS;
1014
1112
  const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS;
1015
1113
  const mediaClient = opts.media;
1016
1114
  const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment;
1017
1115
  const pendingMedia = new PendingMediaStore();
1018
1116
  const pool = new BotPool(client, bindings);
1117
+ const ourBotUserIds = /* @__PURE__ */ new Set([
1118
+ ...botUserId ? [botUserId] : [],
1119
+ ...bindings.map((b) => b.userId)
1120
+ ]);
1121
+ const DECLINE_REASON = "Bots are placed in rooms only by the zooid daemon (workforce-as-code). Ad-hoc invites are declined \u2014 add the bot to the room in zooid.yaml.";
1019
1122
  const sessions = /* @__PURE__ */ new Map();
1020
1123
  const buffers = /* @__PURE__ */ new Map();
1021
1124
  const bufferMessageIds = /* @__PURE__ */ new Map();
1022
1125
  const sendQueue = /* @__PURE__ */ new Map();
1023
1126
  const threadStates = /* @__PURE__ */ new Map();
1024
- const cutoffTs = Date.now() - STARTUP_GRACE_MS;
1127
+ const cutoffTs = mode === "client" ? Number.NEGATIVE_INFINITY : Date.now() - STARTUP_GRACE_MS;
1025
1128
  const seenEventIds = /* @__PURE__ */ new Set();
1129
+ const flushedCounts = /* @__PURE__ */ new Map();
1130
+ const pendingCommands = /* @__PURE__ */ new Map();
1131
+ const buildTextContent = (text) => {
1132
+ const content = {
1133
+ msgtype: "m.text",
1134
+ body: text
1135
+ };
1136
+ const html = toMatrixHtml(text);
1137
+ if (html) {
1138
+ const escapedPlain = "<p>" + text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") + "</p>";
1139
+ const norm = (s) => s.replace(/\s+/g, " ").trim();
1140
+ if (norm(html) !== norm(escapedPlain)) {
1141
+ content.format = "org.matrix.custom.html";
1142
+ content.formatted_body = html;
1143
+ }
1144
+ }
1145
+ return content;
1146
+ };
1147
+ const flushBuffer = (sessionId) => {
1148
+ const ctx = sessions.get(sessionId);
1149
+ const text = buffers.get(sessionId) ?? "";
1150
+ if (!ctx || text.length === 0) return false;
1151
+ buffers.set(sessionId, "");
1152
+ flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1);
1153
+ const content = buildTextContent(text);
1154
+ const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
1155
+ try {
1156
+ await client.sendMessage({
1157
+ roomId: ctx.roomId,
1158
+ asUserId: ctx.agent.userId,
1159
+ content,
1160
+ threadRoot: ctx.threadRoot
1161
+ });
1162
+ } catch (err) {
1163
+ console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err);
1164
+ }
1165
+ });
1166
+ sendQueue.set(sessionId, tail);
1167
+ return true;
1168
+ };
1026
1169
  agents.onEvent = async (name, event) => {
1027
1170
  const ctx = sessions.get(event.sessionId);
1028
1171
  if (!ctx) {
1029
- console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`);
1172
+ if (event.type === "available_commands") {
1173
+ pendingCommands.set(event.sessionId, event);
1174
+ } else {
1175
+ console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`);
1176
+ }
1030
1177
  return;
1031
1178
  }
1032
1179
  if (event.type === "agent_message_chunk") {
1033
1180
  const block = event.content;
1034
1181
  if (block.type === "text" && typeof block.text === "string") {
1035
- const current = buffers.get(event.sessionId) ?? "";
1036
1182
  const prevMessageId = bufferMessageIds.get(event.sessionId);
1037
1183
  const messageChanged = event.messageId !== void 0 && prevMessageId !== void 0 && event.messageId !== prevMessageId;
1038
- const needsBreak = current.length > 0 && (block.text === "" || messageChanged);
1039
- const prefix = needsBreak ? "\n\n" : "";
1040
- buffers.set(event.sessionId, current + prefix + block.text);
1041
1184
  if (event.messageId !== void 0)
1042
1185
  bufferMessageIds.set(event.sessionId, event.messageId);
1186
+ if (messageChanged) flushBuffer(event.sessionId);
1187
+ const current = buffers.get(event.sessionId) ?? "";
1188
+ const needsBreak = current.length > 0 && block.text === "";
1189
+ const prefix = needsBreak ? "\n\n" : "";
1190
+ buffers.set(event.sessionId, current + prefix + block.text);
1043
1191
  } else if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string" && mediaClient) {
1044
1192
  const ctx2 = sessions.get(event.sessionId);
1045
1193
  if (ctx2) {
@@ -1068,8 +1216,9 @@ function createMatrixTransport(opts) {
1068
1216
  }
1069
1217
  return;
1070
1218
  }
1071
- const eventType = event.type === "tool_call" ? "eco.zoon.tool_call" : event.type === "tool_call_update" ? "eco.zoon.tool_call_update" : "eco.zoon.plan";
1072
- const body = event.type === "tool_call" ? toToolCallBody(event) : event.type === "tool_call_update" ? toUpdateBody(event) : toPlanBody(event);
1219
+ flushBuffer(event.sessionId);
1220
+ const eventType = event.type === "tool_call" ? "dev.zooid.tool_call" : event.type === "tool_call_update" ? "dev.zooid.tool_call_update" : event.type === "available_commands" ? "dev.zooid.available_commands_update" : "dev.zooid.plan";
1221
+ const body = event.type === "tool_call" ? toToolCallBody(event) : event.type === "tool_call_update" ? toUpdateBody(event) : event.type === "available_commands" ? toAvailableCommandsBody(event) : toPlanBody(event);
1073
1222
  body["m.relates_to"] = { rel_type: "m.thread", event_id: ctx.threadRoot };
1074
1223
  const tail = (sendQueue.get(event.sessionId) ?? Promise.resolve()).then(async () => {
1075
1224
  try {
@@ -1108,185 +1257,201 @@ function createMatrixTransport(opts) {
1108
1257
  void client.sendCustomEvent({
1109
1258
  roomId: ctx.roomId,
1110
1259
  asUserId: ctx.agent.userId,
1111
- eventType: "eco.zoon.approval_request",
1260
+ eventType: "dev.zooid.approval_request",
1112
1261
  content
1113
1262
  });
1114
1263
  });
1115
- const app = new Hono();
1116
- function authOk(authHeader) {
1117
- const h = authHeader ?? "";
1118
- if (!h.startsWith("Bearer ")) return false;
1119
- const got = h.slice(7);
1120
- if (got.length !== hsToken.length) return false;
1121
- return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken));
1122
- }
1123
- app.put("/_matrix/app/v1/transactions/:txnId", async (c) => {
1124
- if (!authOk(c.req.header("authorization"))) {
1125
- return c.json({ errcode: "M_FORBIDDEN" }, 403);
1126
- }
1127
- const body = await c.req.json().catch(() => ({}));
1128
- for (const evt of body.events ?? []) {
1129
- if (evt.event_id) {
1130
- if (seenEventIds.has(evt.event_id)) {
1131
- continue;
1132
- }
1133
- seenEventIds.add(evt.event_id);
1134
- if (seenEventIds.size > SEEN_EVENT_CAP) {
1135
- const first = seenEventIds.values().next().value;
1136
- if (first !== void 0) seenEventIds.delete(first);
1137
- }
1264
+ async function handleInboundEvent(evt) {
1265
+ if (evt.event_id) {
1266
+ if (seenEventIds.has(evt.event_id)) {
1267
+ return;
1138
1268
  }
1139
- if (evt.origin_server_ts !== void 0 && evt.origin_server_ts < cutoffTs && evt.type === "m.room.message") {
1269
+ seenEventIds.add(evt.event_id);
1270
+ if (seenEventIds.size > SEEN_EVENT_CAP) {
1271
+ const first = seenEventIds.values().next().value;
1272
+ if (first !== void 0) seenEventIds.delete(first);
1273
+ }
1274
+ }
1275
+ if (evt.origin_server_ts !== void 0 && evt.origin_server_ts < cutoffTs && evt.type === "m.room.message") {
1276
+ console.log(
1277
+ `[matrix] dropping stale message event ${evt.event_id} (ts=${evt.origin_server_ts}, daemon started at ${cutoffTs + STARTUP_GRACE_MS})`
1278
+ );
1279
+ return;
1280
+ }
1281
+ if (evt.type === "m.room.member" && evt.content?.membership === "invite") {
1282
+ const target = evt.state_key;
1283
+ const inviter = evt.sender;
1284
+ if (target && evt.room_id && ourBotUserIds.has(target) && (!inviter || !ourBotUserIds.has(inviter))) {
1140
1285
  console.log(
1141
- `[matrix] dropping stale message event ${evt.event_id} (ts=${evt.origin_server_ts}, daemon started at ${cutoffTs + STARTUP_GRACE_MS})`
1286
+ `[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} from ${inviter ?? "unknown"}`
1287
+ );
1288
+ await client.leaveRoom(evt.room_id, target, { reason: DECLINE_REASON }).catch(
1289
+ (err) => console.warn(`[matrix] leaveRoom(${evt.room_id}, ${target}) failed:`, err)
1142
1290
  );
1143
- continue;
1144
1291
  }
1145
- if (evt.type === "eco.zoon.session_reset") {
1146
- const relates = evt.content?.["m.relates_to"];
1147
- const threadRoot = relates?.rel_type === "m.thread" && relates.event_id ? relates.event_id : void 0;
1148
- if (!threadRoot) {
1149
- console.log("[matrix] dropping eco.zoon.session_reset without thread relation");
1150
- continue;
1151
- }
1152
- console.log(`[matrix] inbound eco.zoon.session_reset in ${evt.room_id} thread=${threadRoot}`);
1153
- for (const a of bindings) {
1154
- agents.endSession(a.name, threadRoot);
1155
- }
1156
- continue;
1292
+ return;
1293
+ }
1294
+ if (evt.type === "dev.zooid.session_reset") {
1295
+ const relates = evt.content?.["m.relates_to"];
1296
+ const threadRoot = relates?.rel_type === "m.thread" && relates.event_id ? relates.event_id : void 0;
1297
+ if (!threadRoot) {
1298
+ console.log("[matrix] dropping dev.zooid.session_reset without thread relation");
1299
+ return;
1157
1300
  }
1158
- if (evt.type === "eco.zoon.interrupt") {
1159
- const content = evt.content ?? {};
1160
- const relates = evt.content?.["m.relates_to"];
1161
- const threadRoot = relates?.rel_type === "m.thread" && relates.event_id ? relates.event_id : void 0;
1162
- if (threadRoot) {
1163
- const targets = [];
1164
- for (const [sessionId, ctx2] of sessions) {
1165
- if (ctx2.threadRoot === threadRoot) {
1166
- targets.push({ sessionId, agent: ctx2.agent.name });
1167
- }
1168
- }
1169
- for (const t of targets) {
1170
- console.log(
1171
- `[matrix] interrupt session=${t.sessionId} agent=${t.agent} thread=${threadRoot}` + (content.reason ? ` reason=${content.reason}` : "")
1172
- );
1173
- await agents.cancelSession(t.agent, t.sessionId).catch((err) => {
1174
- console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err);
1175
- });
1301
+ console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`);
1302
+ for (const a of bindings) {
1303
+ agents.endSession(a.name, threadRoot);
1304
+ }
1305
+ return;
1306
+ }
1307
+ if (evt.type === "dev.zooid.interrupt") {
1308
+ const content = evt.content ?? {};
1309
+ const relates = evt.content?.["m.relates_to"];
1310
+ const threadRoot = relates?.rel_type === "m.thread" && relates.event_id ? relates.event_id : void 0;
1311
+ if (threadRoot) {
1312
+ const targets = [];
1313
+ for (const [sessionId, ctx2] of sessions) {
1314
+ if (ctx2.threadRoot === threadRoot) {
1315
+ targets.push({ sessionId, agent: ctx2.agent.name });
1176
1316
  }
1177
- continue;
1178
- }
1179
- if (!content.session_id) {
1180
- console.warn(`[matrix] eco.zoon.interrupt missing session_id (event_id=${evt.event_id})`);
1181
- continue;
1182
1317
  }
1183
- const ctx = sessions.get(content.session_id);
1184
- if (!ctx) {
1185
- continue;
1318
+ for (const t of targets) {
1319
+ console.log(
1320
+ `[matrix] interrupt session=${t.sessionId} agent=${t.agent} thread=${threadRoot}` + (content.reason ? ` reason=${content.reason}` : "")
1321
+ );
1322
+ await agents.cancelSession(t.agent, t.sessionId).catch((err) => {
1323
+ console.error(`[matrix] cancelSession(${t.agent}, ${t.sessionId}) failed:`, err);
1324
+ });
1186
1325
  }
1187
- console.log(
1188
- `[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` + (content.reason ? ` reason=${content.reason}` : "")
1189
- );
1190
- await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
1191
- console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err);
1192
- });
1193
- continue;
1326
+ return;
1327
+ }
1328
+ if (!content.session_id) {
1329
+ console.warn(`[matrix] dev.zooid.interrupt missing session_id (event_id=${evt.event_id})`);
1330
+ return;
1194
1331
  }
1195
- if (evt.type === "eco.zoon.approval_response") {
1196
- const content = evt.content ?? {};
1197
- if (!content.session_id || !content.approval_id || !content.decision) continue;
1198
- const decision = content.option_id ? { decision: content.decision, optionId: content.option_id } : { decision: content.decision };
1199
- const ok = approvals.resolve(
1200
- content.session_id,
1201
- content.approval_id,
1202
- decision
1332
+ const ctx = sessions.get(content.session_id);
1333
+ if (!ctx) {
1334
+ return;
1335
+ }
1336
+ console.log(
1337
+ `[matrix] interrupt session=${content.session_id} agent=${ctx.agent.name}` + (content.reason ? ` reason=${content.reason}` : "")
1338
+ );
1339
+ await agents.cancelSession(ctx.agent.name, content.session_id).catch((err) => {
1340
+ console.error(`[matrix] cancelSession(${ctx.agent.name}, ${content.session_id}) failed:`, err);
1341
+ });
1342
+ return;
1343
+ }
1344
+ if (evt.type === "dev.zooid.approval_response") {
1345
+ const content = evt.content ?? {};
1346
+ if (!content.session_id || !content.approval_id || !content.decision) return;
1347
+ const decision = content.option_id ? { decision: content.decision, optionId: content.option_id } : { decision: content.decision };
1348
+ const ok = approvals.resolve(
1349
+ content.session_id,
1350
+ content.approval_id,
1351
+ decision
1352
+ );
1353
+ if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`);
1354
+ return;
1355
+ }
1356
+ logInbound(evt);
1357
+ if (evt.type === "m.room.message" && isMediaMsgtype(evt.content?.msgtype) && evt.room_id && evt.event_id && evt.sender && evt.content?.url && !bindings.some((b) => b.userId === evt.sender)) {
1358
+ pendingMedia.add(evt.room_id, inboundThreadRoot2(evt), {
1359
+ eventId: evt.event_id,
1360
+ sender: evt.sender,
1361
+ msgtype: evt.content.msgtype,
1362
+ body: evt.content.body ?? "",
1363
+ filename: evt.content.filename,
1364
+ url: evt.content.url,
1365
+ info: evt.content.info
1366
+ });
1367
+ return;
1368
+ }
1369
+ const promotedRoot = inboundThreadRoot2(evt) ?? evt.event_id;
1370
+ const inboundRel = inboundThreadRoot2(evt);
1371
+ if (evt.type === "m.room.message" && inboundRel && !threadStates.has(inboundRel) && evt.room_id) {
1372
+ try {
1373
+ const rebuilt = await rebuildThreadState(client, evt.room_id, inboundRel, bindings);
1374
+ threadStates.set(inboundRel, rebuilt);
1375
+ console.log(
1376
+ `[matrix] rebuilt threadState for ${inboundRel}: participants=${rebuilt.participants.join(",")} rootMentions=${rebuilt.rootMentions.join(",")}`
1203
1377
  );
1204
- if (!ok) console.warn(`[matrix] unknown approval ${content.approval_id}`);
1205
- continue;
1378
+ } catch (err) {
1379
+ console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err);
1206
1380
  }
1207
- logInbound(evt);
1208
- if (evt.type === "m.room.message" && isMediaMsgtype(evt.content?.msgtype) && evt.room_id && evt.event_id && evt.sender && evt.content?.url && !bindings.some((b) => b.userId === evt.sender)) {
1209
- pendingMedia.add(evt.room_id, inboundThreadRoot2(evt), {
1210
- eventId: evt.event_id,
1211
- sender: evt.sender,
1212
- msgtype: evt.content.msgtype,
1213
- body: evt.content.body ?? "",
1214
- filename: evt.content.filename,
1215
- url: evt.content.url,
1216
- info: evt.content.info
1217
- });
1218
- continue;
1381
+ }
1382
+ const matches = route(evt, bindings, threadStates);
1383
+ const senderIsBot = bindings.some((b) => b.userId === evt.sender);
1384
+ if (evt.type === "m.room.message" && matches.length === 0 && !senderIsBot) {
1385
+ console.warn(
1386
+ `[matrix] no agent matched message in ${evt.room_id} from ${evt.sender} (bindings: ${bindings.map((b) => `${b.name}@${b.userId}[${b.trigger}]`).join(", ")})`
1387
+ );
1388
+ }
1389
+ if (matches.length > 0 && promotedRoot) {
1390
+ let st = threadStates.get(promotedRoot);
1391
+ if (!st) {
1392
+ st = { participants: [], rootMentions: [] };
1393
+ threadStates.set(promotedRoot, st);
1219
1394
  }
1220
- const promotedRoot = inboundThreadRoot2(evt) ?? evt.event_id;
1221
- const inboundRel = inboundThreadRoot2(evt);
1222
- if (evt.type === "m.room.message" && inboundRel && !threadStates.has(inboundRel) && evt.room_id) {
1223
- try {
1224
- const rebuilt = await rebuildThreadState(client, evt.room_id, inboundRel, bindings);
1225
- threadStates.set(inboundRel, rebuilt);
1226
- console.log(
1227
- `[matrix] rebuilt threadState for ${inboundRel}: participants=${rebuilt.participants.join(",")} rootMentions=${rebuilt.rootMentions.join(",")}`
1228
- );
1229
- } catch (err) {
1230
- console.warn(`[matrix] failed to rebuild threadState for ${inboundRel}:`, err);
1395
+ const msgMentions = new Set(extractMentions(evt));
1396
+ for (const a of bindings) {
1397
+ if (msgMentions.has(a.userId) && !st.rootMentions.includes(a.name)) {
1398
+ st.rootMentions.push(a.name);
1231
1399
  }
1232
1400
  }
1233
- const matches = route(evt, bindings, threadStates);
1234
- const senderIsBot = bindings.some((b) => b.userId === evt.sender);
1235
- if (evt.type === "m.room.message" && matches.length === 0 && !senderIsBot) {
1236
- console.warn(
1237
- `[matrix] no agent matched message in ${evt.room_id} from ${evt.sender} (bindings: ${bindings.map((b) => `${b.name}@${b.userId}[${b.trigger}]`).join(", ")})`
1238
- );
1239
- }
1240
- if (matches.length > 0 && promotedRoot) {
1401
+ }
1402
+ for (const a of matches) {
1403
+ console.log(`[matrix] \u2192 ${a.name} (${a.userId})`);
1404
+ void runTurn(a, evt).then(() => {
1405
+ if (!promotedRoot) return;
1241
1406
  let st = threadStates.get(promotedRoot);
1242
1407
  if (!st) {
1243
1408
  st = { participants: [], rootMentions: [] };
1244
1409
  threadStates.set(promotedRoot, st);
1245
1410
  }
1246
- const msgMentions = new Set(extractMentions(evt));
1247
- for (const a of bindings) {
1248
- if (msgMentions.has(a.userId) && !st.rootMentions.includes(a.name)) {
1249
- st.rootMentions.push(a.name);
1250
- }
1251
- }
1252
- }
1253
- for (const a of matches) {
1254
- console.log(`[matrix] \u2192 ${a.name} (${a.userId})`);
1255
- void runTurn(a, evt).then(() => {
1256
- if (!promotedRoot) return;
1257
- let st = threadStates.get(promotedRoot);
1258
- if (!st) {
1259
- st = { participants: [], rootMentions: [] };
1260
- threadStates.set(promotedRoot, st);
1261
- }
1262
- if (st.participants.at(-1) !== a.name) st.participants.push(a.name);
1263
- }).catch((err) => {
1264
- console.error(`[matrix] runTurn failed for ${a.name}:`, err);
1265
- const c2 = classify(err);
1266
- const threadRoot = inboundThreadRoot2(evt) ?? evt.event_id;
1267
- if (!threadRoot || !evt.room_id) return;
1268
- const body2 = toErrorBody(
1269
- {
1270
- kind: "error",
1271
- agentId: a.name,
1272
- sessionId: null,
1273
- turnId: null,
1274
- code: c2.code,
1275
- message: err instanceof Error ? err.message : String(err),
1276
- detail: err instanceof Error && err.stack ? err.stack.slice(0, 2e3) : void 0,
1277
- transient: c2.transient,
1278
- acp_error: c2.acp_error
1279
- },
1280
- threadRoot
1281
- );
1282
- void client.sendCustomEvent({
1283
- roomId: evt.room_id,
1284
- asUserId: a.userId,
1285
- eventType: "eco.zoon.error",
1286
- content: body2
1287
- }).catch((e) => console.warn(`[matrix:${a.name}] eco.zoon.error send failed:`, e));
1288
- });
1289
- }
1411
+ if (st.participants.at(-1) !== a.name) st.participants.push(a.name);
1412
+ }).catch((err) => {
1413
+ console.error(`[matrix] runTurn failed for ${a.name}:`, err);
1414
+ const c = classify(err);
1415
+ const threadRoot = inboundThreadRoot2(evt) ?? evt.event_id;
1416
+ if (!threadRoot || !evt.room_id) return;
1417
+ const body = toErrorBody(
1418
+ {
1419
+ kind: "error",
1420
+ agentId: a.name,
1421
+ sessionId: null,
1422
+ turnId: null,
1423
+ code: c.code,
1424
+ message: err instanceof Error ? err.message : String(err),
1425
+ detail: err instanceof Error && err.stack ? err.stack.slice(0, 2e3) : void 0,
1426
+ transient: c.transient,
1427
+ acp_error: c.acp_error
1428
+ },
1429
+ threadRoot
1430
+ );
1431
+ void client.sendCustomEvent({
1432
+ roomId: evt.room_id,
1433
+ asUserId: a.userId,
1434
+ eventType: "dev.zooid.error",
1435
+ content: body
1436
+ }).catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e));
1437
+ });
1438
+ }
1439
+ }
1440
+ const app = new Hono();
1441
+ function authOk(authHeader) {
1442
+ const h = authHeader ?? "";
1443
+ if (!h.startsWith("Bearer ")) return false;
1444
+ const got = h.slice(7);
1445
+ if (got.length !== hsToken.length) return false;
1446
+ return timingSafeEqual(Buffer.from(got), Buffer.from(hsToken));
1447
+ }
1448
+ app.put("/_matrix/app/v1/transactions/:txnId", async (c) => {
1449
+ if (!authOk(c.req.header("authorization"))) {
1450
+ return c.json({ errcode: "M_FORBIDDEN" }, 403);
1451
+ }
1452
+ const body = await c.req.json().catch(() => ({}));
1453
+ for (const evt of body.events ?? []) {
1454
+ await handleInboundEvent(evt);
1290
1455
  }
1291
1456
  return c.json({});
1292
1457
  });
@@ -1318,6 +1483,12 @@ function createMatrixTransport(opts) {
1318
1483
  sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot });
1319
1484
  buffers.set(sessionId, "");
1320
1485
  bufferMessageIds.delete(sessionId);
1486
+ flushedCounts.set(sessionId, 0);
1487
+ const stashedCommands = pendingCommands.get(sessionId);
1488
+ if (stashedCommands) {
1489
+ pendingCommands.delete(sessionId);
1490
+ void agents.onEvent?.(agent.name, stashedCommands);
1491
+ }
1321
1492
  const roomId = evt.room_id;
1322
1493
  const TYPING_TTL_MS = 3e4;
1323
1494
  const TYPING_REFRESH_MS = 25e3;
@@ -1363,32 +1534,13 @@ function createMatrixTransport(opts) {
1363
1534
  while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
1364
1535
  await delay(drainQuietMs);
1365
1536
  const next = buffers.get(sessionId) ?? "";
1366
- if (next === drained && next.length > 0) break;
1537
+ if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
1538
+ break;
1367
1539
  drained = next;
1368
1540
  }
1369
- const text = buffers.get(sessionId) ?? "";
1370
- if (text.length > 0) {
1371
- const html = toMatrixHtml(text);
1372
- const content = {
1373
- msgtype: "m.text",
1374
- body: text
1375
- };
1376
- if (html) {
1377
- const escapedPlain = "<p>" + text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") + "</p>";
1378
- const norm = (s) => s.replace(/\s+/g, " ").trim();
1379
- if (norm(html) !== norm(escapedPlain)) {
1380
- content.format = "org.matrix.custom.html";
1381
- content.formatted_body = html;
1382
- }
1383
- }
1384
- await client.sendMessage({
1385
- roomId: evt.room_id,
1386
- asUserId: agent.userId,
1387
- content,
1388
- threadRoot
1389
- // every reply threads, full stop
1390
- });
1391
- } else {
1541
+ flushBuffer(sessionId);
1542
+ await (sendQueue.get(sessionId) ?? Promise.resolve());
1543
+ if ((flushedCounts.get(sessionId) ?? 0) === 0) {
1392
1544
  console.warn(
1393
1545
  `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`
1394
1546
  );
@@ -1399,10 +1551,22 @@ function createMatrixTransport(opts) {
1399
1551
  await safePresence("online");
1400
1552
  buffers.delete(sessionId);
1401
1553
  bufferMessageIds.delete(sessionId);
1554
+ flushedCounts.delete(sessionId);
1555
+ sendQueue.delete(sessionId);
1402
1556
  }
1403
1557
  }
1558
+ const syncLoops = mode === "client" ? bindings.map(
1559
+ (b) => new SyncLoop({
1560
+ client,
1561
+ asUserId: b.userId,
1562
+ loadSince: () => opts.loadSince?.(b.userId) ?? null,
1563
+ saveSince: (since) => opts.saveSince?.(b.userId, since),
1564
+ onEvent: (evt) => handleInboundEvent(evt)
1565
+ })
1566
+ ) : void 0;
1404
1567
  return {
1405
1568
  app,
1569
+ syncLoops,
1406
1570
  bootstrap: async (bootstrapOpts = {}) => {
1407
1571
  await pool.bootstrap({ adminUserId, ...bootstrapOpts });
1408
1572
  await Promise.allSettled(
@@ -1484,7 +1648,7 @@ async function publishWorkforce(opts) {
1484
1648
  await opts.client.sendStateEvent({
1485
1649
  roomId: opts.spaceRoomId,
1486
1650
  asUserId: opts.asUserId,
1487
- eventType: "eco.zoon.workforce",
1651
+ eventType: "dev.zooid.workforce",
1488
1652
  stateKey: "",
1489
1653
  content: buildWorkforceRoster(opts.agents)
1490
1654
  });
@@ -1500,6 +1664,7 @@ async function startWorkforcePublisher(opts) {
1500
1664
  };
1501
1665
  }
1502
1666
  export {
1667
+ AGENT_KEY_RE,
1503
1668
  BotPool,
1504
1669
  INLINE_IMAGE_MIMES,
1505
1670
  MAX_DOWNLOAD_BYTES,
@@ -1510,18 +1675,25 @@ export {
1510
1675
  MatrixContextProvider,
1511
1676
  MediaClient,
1512
1677
  PendingMediaStore,
1678
+ SLUG_RE,
1679
+ SyncLoop,
1680
+ agentMxid,
1513
1681
  buildWorkforceRoster,
1514
1682
  createMatrixTransport,
1515
1683
  ensureDefaultChannel,
1516
1684
  ensureWorkforceSpace,
1517
1685
  extractMentions,
1518
1686
  isMediaMsgtype,
1687
+ isValidAgentKey,
1688
+ isValidWorkstation,
1519
1689
  parseMxcUri,
1520
1690
  publishWorkforce,
1521
1691
  renderRegistration,
1522
1692
  route,
1523
1693
  serverNameFromMxid,
1694
+ splitAgentLocalpart,
1524
1695
  startWorkforcePublisher,
1696
+ workstationUserNamespace,
1525
1697
  writeAttachment
1526
1698
  };
1527
1699
  //# sourceMappingURL=index.js.map