@zooid/transport-matrix 0.7.4 → 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/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, {
@@ -359,14 +371,29 @@ var MatrixContextProvider = class {
359
371
  }
360
372
  toMessage(ev) {
361
373
  if (ev.type !== "m.room.message") return null;
362
- if (ev.content?.msgtype !== "m.text" || typeof ev.content.body !== "string") return null;
374
+ const msgtype = ev.content?.msgtype;
375
+ const body = ev.content?.body;
363
376
  const agent = this.opts.agentBots.get(ev.sender);
364
- const relatesTo = ev.content["m.relates_to"];
377
+ const relatesTo = ev.content?.["m.relates_to"];
365
378
  const threadId = relatesTo?.rel_type === "m.thread" && relatesTo.event_id ? relatesTo.event_id : void 0;
379
+ if (msgtype === "m.image" || msgtype === "m.file" || msgtype === "m.video" || msgtype === "m.audio") {
380
+ const kind = msgtype.slice(2);
381
+ const name = typeof body === "string" && body ? body : "untitled";
382
+ return {
383
+ id: ev.event_id,
384
+ sender: ev.sender,
385
+ text: `[${kind}: ${name}]`,
386
+ timestamp: new Date(ev.origin_server_ts).toISOString(),
387
+ is_agent: agent !== void 0,
388
+ ...agent !== void 0 ? { agent_name: agent } : {},
389
+ ...threadId !== void 0 ? { thread_id: threadId } : {}
390
+ };
391
+ }
392
+ if (msgtype !== "m.text" || typeof body !== "string") return null;
366
393
  return {
367
394
  id: ev.event_id,
368
395
  sender: ev.sender,
369
- text: ev.content.body,
396
+ text: body,
370
397
  timestamp: new Date(ev.origin_server_ts).toISOString(),
371
398
  is_agent: agent !== void 0,
372
399
  ...agent !== void 0 ? { agent_name: agent } : {},
@@ -440,6 +467,10 @@ function stripMention(body, userId) {
440
467
  }
441
468
 
442
469
  // src/router.ts
470
+ var MEDIA_MSGTYPES = /* @__PURE__ */ new Set(["m.image", "m.file", "m.video", "m.audio"]);
471
+ function isMediaMsgtype(t) {
472
+ return t !== void 0 && MEDIA_MSGTYPES.has(t);
473
+ }
443
474
  function inboundThreadRoot(event) {
444
475
  const r = event.content?.["m.relates_to"];
445
476
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
@@ -447,6 +478,7 @@ function inboundThreadRoot(event) {
447
478
  function route(event, agents, threadStates) {
448
479
  if (event.type !== "m.room.message") return [];
449
480
  if (!event.content?.msgtype) return [];
481
+ if (isMediaMsgtype(event.content.msgtype)) return [];
450
482
  const mentions = new Set(extractMentions(event));
451
483
  const matches = [];
452
484
  const threadRoot = inboundThreadRoot(event);
@@ -485,10 +517,12 @@ async function ensureWorkforceSpace(opts) {
485
517
  name: display,
486
518
  preset: opts.preset,
487
519
  creation_content: { type: "m.space" },
488
- // A workspace is joined by invitation, not self-service. Pin the space's
489
- // join rule to invite regardless of preset so it can't be walked into
490
- // (which would otherwise satisfy every restricted child room's allow).
491
- initial_state: [{ type: "m.room.join_rules", state_key: "", content: { join_rule: "invite" } }]
520
+ // Pin the join rule regardless of preset. Defaults to invite so the space
521
+ // can't be walked into (which would otherwise satisfy every restricted
522
+ // child room's allow); `zooid dev` opts into `public` for local-only use.
523
+ initial_state: [
524
+ { type: "m.room.join_rules", state_key: "", content: { join_rule: opts.joinRule ?? "invite" } }
525
+ ]
492
526
  };
493
527
  if (opts.admins && opts.admins.length > 0) {
494
528
  body.invite = opts.admins;
@@ -707,6 +741,15 @@ function toPlanBody(evt) {
707
741
  entries: evt.entries
708
742
  };
709
743
  }
744
+ function toAvailableCommandsBody(evt) {
745
+ return {
746
+ session_id: evt.sessionId,
747
+ available_commands: evt.commands.map((c) => ({
748
+ name: c.name,
749
+ description: c.description
750
+ }))
751
+ };
752
+ }
710
753
  var RECOVERY_URLS = {
711
754
  auth_missing: "https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over",
712
755
  auth_invalid: "https://zooid.dev/docs/guides/run-in-container#authentication-that-carries-over",
@@ -804,8 +847,180 @@ function toMatrixHtml(markdown) {
804
847
  });
805
848
  }
806
849
 
850
+ // src/pending-media.ts
851
+ var MAX_MEDIA_PER_TURN = 8;
852
+ var PendingMediaStore = class {
853
+ queues = /* @__PURE__ */ new Map();
854
+ key(roomId, threadKey) {
855
+ return `${roomId} ${threadKey ?? "main"}`;
856
+ }
857
+ add(roomId, threadKey, item) {
858
+ const k = this.key(roomId, threadKey);
859
+ const q = this.queues.get(k) ?? [];
860
+ q.push(item);
861
+ while (q.length > MAX_MEDIA_PER_TURN) q.shift();
862
+ this.queues.set(k, q);
863
+ }
864
+ drain(roomId, threadKey, sender) {
865
+ const k = this.key(roomId, threadKey);
866
+ const q = this.queues.get(k) ?? [];
867
+ const mine = q.filter((i) => i.sender === sender);
868
+ const rest = q.filter((i) => i.sender !== sender);
869
+ if (rest.length) this.queues.set(k, rest);
870
+ else this.queues.delete(k);
871
+ return mine;
872
+ }
873
+ };
874
+
875
+ // src/media-client.ts
876
+ var MAX_INLINE_IMAGE_BYTES = 524288;
877
+ var INLINE_IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
878
+ var MAX_DOWNLOAD_BYTES = 33554432;
879
+ function parseMxcUri(uri) {
880
+ const m = /^mxc:\/\/([^/]+)\/(.+)$/.exec(uri);
881
+ return m ? { serverName: m[1], mediaId: m[2] } : null;
882
+ }
883
+ var MediaClient = class {
884
+ homeserver;
885
+ asToken;
886
+ fetch;
887
+ constructor(opts) {
888
+ this.homeserver = opts.homeserver.replace(/\/$/, "");
889
+ this.asToken = opts.asToken;
890
+ this.fetch = opts.fetch ?? globalThis.fetch;
891
+ }
892
+ async download(input) {
893
+ const parsed = parseMxcUri(input.mxcUri);
894
+ if (!parsed) throw new Error(`not an mxc uri: ${input.mxcUri}`);
895
+ const url = `${this.homeserver}/_matrix/client/v1/media/download/${encodeURIComponent(parsed.serverName)}/${encodeURIComponent(parsed.mediaId)}?user_id=${encodeURIComponent(input.asUserId)}`;
896
+ const r = await this.fetch(url, {
897
+ headers: { Authorization: `Bearer ${this.asToken}` }
898
+ });
899
+ if (!r.ok) throw new Error(`media download failed: ${r.status}`);
900
+ const buf = new Uint8Array(await r.arrayBuffer());
901
+ const max = input.maxBytes ?? MAX_DOWNLOAD_BYTES;
902
+ if (buf.byteLength > max) {
903
+ throw new Error(`media too large: ${buf.byteLength} > ${max}`);
904
+ }
905
+ return { data: buf, contentType: r.headers.get("content-type") ?? "application/octet-stream" };
906
+ }
907
+ async upload(input) {
908
+ const params = new URLSearchParams();
909
+ if (input.filename) params.set("filename", input.filename);
910
+ params.set("user_id", input.asUserId);
911
+ const r = await this.fetch(`${this.homeserver}/_matrix/media/v3/upload?${params}`, {
912
+ method: "POST",
913
+ headers: { Authorization: `Bearer ${this.asToken}`, "Content-Type": input.contentType },
914
+ body: input.data
915
+ });
916
+ if (!r.ok) throw new Error(`media upload failed: ${r.status}`);
917
+ return await r.json();
918
+ }
919
+ };
920
+
921
+ // src/attachments.ts
922
+ import { mkdirSync, writeFileSync } from "fs";
923
+ import { join } from "path";
924
+ import { posix } from "path";
925
+ function sanitize(s, fallback) {
926
+ const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "").replace(/^\.+/, "");
927
+ return cleaned || fallback;
928
+ }
929
+ function writeAttachment(input) {
930
+ const dir = sanitize(input.eventId, "event");
931
+ const name = sanitize(input.filename, "file");
932
+ const hostDir = join(input.workspaceDir, ".zooid", "attachments", dir);
933
+ mkdirSync(hostDir, { recursive: true });
934
+ const hostPath = join(hostDir, name);
935
+ writeFileSync(hostPath, input.data);
936
+ const agentPath = posix.join(input.agentWorkspacePath, ".zooid", "attachments", dir, name);
937
+ return { hostPath, agentPath };
938
+ }
939
+
807
940
  // src/transport.ts
808
941
  var STARTUP_GRACE_MS = 5e3;
942
+ async function buildMediaBlocks(items, opts) {
943
+ const blocks = [];
944
+ const pathLines = [];
945
+ if (!opts.media || items.length === 0) return { blocks, pathLines };
946
+ for (const item of items) {
947
+ try {
948
+ const isInlineCandidate = item.msgtype === "m.image" && INLINE_IMAGE_MIMES.includes(item.info?.mimetype ?? "") && (item.info?.size === void 0 || item.info.size <= MAX_INLINE_IMAGE_BYTES);
949
+ if (isInlineCandidate) {
950
+ const { data, contentType } = await opts.media.download({
951
+ mxcUri: item.url,
952
+ asUserId: opts.agent.userId
953
+ });
954
+ if (data.byteLength <= MAX_INLINE_IMAGE_BYTES) {
955
+ blocks.push({
956
+ type: "image",
957
+ data: Buffer.from(data).toString("base64"),
958
+ mimeType: contentType
959
+ });
960
+ continue;
961
+ }
962
+ if (opts.agent.workspaceDir) {
963
+ const paths = opts.writeAttachmentFn({
964
+ workspaceDir: opts.agent.workspaceDir,
965
+ agentWorkspacePath: opts.agent.agentWorkspacePath ?? opts.agent.workspaceDir,
966
+ eventId: item.eventId,
967
+ filename: item.filename ?? item.body,
968
+ data
969
+ });
970
+ blocks.push({
971
+ type: "resource_link",
972
+ uri: `file://${paths.agentPath}`,
973
+ name: item.filename ?? item.body
974
+ });
975
+ pathLines.push(`Attached file: ${paths.agentPath}`);
976
+ }
977
+ } else {
978
+ if (!opts.agent.workspaceDir) continue;
979
+ const { data } = await opts.media.download({
980
+ mxcUri: item.url,
981
+ asUserId: opts.agent.userId
982
+ });
983
+ const paths = opts.writeAttachmentFn({
984
+ workspaceDir: opts.agent.workspaceDir,
985
+ agentWorkspacePath: opts.agent.agentWorkspacePath ?? opts.agent.workspaceDir,
986
+ eventId: item.eventId,
987
+ filename: item.filename ?? item.body,
988
+ data
989
+ });
990
+ blocks.push({
991
+ type: "resource_link",
992
+ uri: `file://${paths.agentPath}`,
993
+ name: item.filename ?? item.body,
994
+ mimeType: item.info?.mimetype,
995
+ size: item.info?.size
996
+ });
997
+ pathLines.push(`Attached file: ${paths.agentPath}`);
998
+ }
999
+ } catch (err) {
1000
+ opts.onError(item, err);
1001
+ }
1002
+ }
1003
+ return { blocks, pathLines };
1004
+ }
1005
+ async function sendMediaError(ctx, _err, message, client) {
1006
+ await client.sendCustomEvent({
1007
+ roomId: ctx.roomId,
1008
+ asUserId: ctx.agent.userId,
1009
+ eventType: "dev.zooid.error",
1010
+ content: toErrorBody(
1011
+ {
1012
+ kind: "error",
1013
+ agentId: ctx.agent.name,
1014
+ sessionId: null,
1015
+ turnId: null,
1016
+ code: "media_failed",
1017
+ message: message.slice(0, 250),
1018
+ transient: false
1019
+ },
1020
+ ctx.threadRoot
1021
+ )
1022
+ }).catch((e) => console.warn(`[matrix:${ctx.agent.name}] dev.zooid.error send failed:`, e));
1023
+ }
809
1024
  var SEEN_EVENT_CAP = 5e3;
810
1025
  var DRAIN_QUIET_MS = 300;
811
1026
  var DRAIN_MAX_MS = 3e4;
@@ -815,10 +1030,18 @@ function inboundThreadRoot2(evt) {
815
1030
  return r?.rel_type === "m.thread" && r.event_id ? r.event_id : void 0;
816
1031
  }
817
1032
  function createMatrixTransport(opts) {
818
- const { agents, approvals, client, bindings, hsToken, adminUserId } = opts;
1033
+ const { agents, approvals, client, bindings, hsToken, adminUserId, botUserId } = opts;
819
1034
  const drainQuietMs = opts.drainQuietMs ?? DRAIN_QUIET_MS;
820
1035
  const drainMaxMs = opts.drainMaxMs ?? DRAIN_MAX_MS;
1036
+ const mediaClient = opts.media;
1037
+ const writeAttachmentFn = opts.writeAttachmentFn ?? writeAttachment;
1038
+ const pendingMedia = new PendingMediaStore();
821
1039
  const pool = new BotPool(client, bindings);
1040
+ const ourBotUserIds = /* @__PURE__ */ new Set([
1041
+ ...botUserId ? [botUserId] : [],
1042
+ ...bindings.map((b) => b.userId)
1043
+ ]);
1044
+ 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.";
822
1045
  const sessions = /* @__PURE__ */ new Map();
823
1046
  const buffers = /* @__PURE__ */ new Map();
824
1047
  const bufferMessageIds = /* @__PURE__ */ new Map();
@@ -826,30 +1049,99 @@ function createMatrixTransport(opts) {
826
1049
  const threadStates = /* @__PURE__ */ new Map();
827
1050
  const cutoffTs = Date.now() - STARTUP_GRACE_MS;
828
1051
  const seenEventIds = /* @__PURE__ */ new Set();
1052
+ const flushedCounts = /* @__PURE__ */ new Map();
1053
+ const pendingCommands = /* @__PURE__ */ new Map();
1054
+ const buildTextContent = (text) => {
1055
+ const content = {
1056
+ msgtype: "m.text",
1057
+ body: text
1058
+ };
1059
+ const html = toMatrixHtml(text);
1060
+ if (html) {
1061
+ const escapedPlain = "<p>" + text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") + "</p>";
1062
+ const norm = (s) => s.replace(/\s+/g, " ").trim();
1063
+ if (norm(html) !== norm(escapedPlain)) {
1064
+ content.format = "org.matrix.custom.html";
1065
+ content.formatted_body = html;
1066
+ }
1067
+ }
1068
+ return content;
1069
+ };
1070
+ const flushBuffer = (sessionId) => {
1071
+ const ctx = sessions.get(sessionId);
1072
+ const text = buffers.get(sessionId) ?? "";
1073
+ if (!ctx || text.length === 0) return false;
1074
+ buffers.set(sessionId, "");
1075
+ flushedCounts.set(sessionId, (flushedCounts.get(sessionId) ?? 0) + 1);
1076
+ const content = buildTextContent(text);
1077
+ const tail = (sendQueue.get(sessionId) ?? Promise.resolve()).then(async () => {
1078
+ try {
1079
+ await client.sendMessage({
1080
+ roomId: ctx.roomId,
1081
+ asUserId: ctx.agent.userId,
1082
+ content,
1083
+ threadRoot: ctx.threadRoot
1084
+ });
1085
+ } catch (err) {
1086
+ console.warn(`[matrix:${ctx.agent.name}] sendMessage flush failed:`, err);
1087
+ }
1088
+ });
1089
+ sendQueue.set(sessionId, tail);
1090
+ return true;
1091
+ };
829
1092
  agents.onEvent = async (name, event) => {
830
1093
  const ctx = sessions.get(event.sessionId);
831
1094
  if (!ctx) {
832
- console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`);
1095
+ if (event.type === "available_commands") {
1096
+ pendingCommands.set(event.sessionId, event);
1097
+ } else {
1098
+ console.warn(`[matrix:${name}] no session ctx for ${event.sessionId}`);
1099
+ }
833
1100
  return;
834
1101
  }
835
1102
  if (event.type === "agent_message_chunk") {
836
1103
  const block = event.content;
837
1104
  if (block.type === "text" && typeof block.text === "string") {
838
- const current = buffers.get(event.sessionId) ?? "";
839
1105
  const prevMessageId = bufferMessageIds.get(event.sessionId);
840
1106
  const messageChanged = event.messageId !== void 0 && prevMessageId !== void 0 && event.messageId !== prevMessageId;
841
- const needsBreak = current.length > 0 && (block.text === "" || messageChanged);
842
- const prefix = needsBreak ? "\n\n" : "";
843
- buffers.set(event.sessionId, current + prefix + block.text);
844
1107
  if (event.messageId !== void 0)
845
1108
  bufferMessageIds.set(event.sessionId, event.messageId);
1109
+ if (messageChanged) flushBuffer(event.sessionId);
1110
+ const current = buffers.get(event.sessionId) ?? "";
1111
+ const needsBreak = current.length > 0 && block.text === "";
1112
+ const prefix = needsBreak ? "\n\n" : "";
1113
+ buffers.set(event.sessionId, current + prefix + block.text);
1114
+ } else if (block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string" && mediaClient) {
1115
+ const ctx2 = sessions.get(event.sessionId);
1116
+ if (ctx2) {
1117
+ const bytes = Buffer.from(block.data, "base64");
1118
+ const ext = (block.mimeType.split("/")[1] ?? "png").replace(/[^a-z0-9]/gi, "");
1119
+ const filename = `image.${ext}`;
1120
+ void mediaClient.upload({ data: bytes, contentType: block.mimeType, filename, asUserId: ctx2.agent.userId }).then(
1121
+ ({ content_uri }) => client.sendMessage({
1122
+ roomId: ctx2.roomId,
1123
+ asUserId: ctx2.agent.userId,
1124
+ threadRoot: ctx2.threadRoot,
1125
+ content: {
1126
+ msgtype: "m.image",
1127
+ body: filename,
1128
+ url: content_uri,
1129
+ info: { mimetype: block.mimeType, size: bytes.length }
1130
+ }
1131
+ })
1132
+ ).catch((err) => {
1133
+ console.warn(`[matrix:${name}] outbound image upload failed:`, err);
1134
+ void sendMediaError(ctx2, err, "agent image upload failed", client);
1135
+ });
1136
+ }
846
1137
  } else {
847
1138
  console.warn(`[matrix:${name}] dropped chunk block type=${block.type}`, block);
848
1139
  }
849
1140
  return;
850
1141
  }
851
- const eventType = event.type === "tool_call" ? "eco.zoon.tool_call" : event.type === "tool_call_update" ? "eco.zoon.tool_call_update" : "eco.zoon.plan";
852
- const body = event.type === "tool_call" ? toToolCallBody(event) : event.type === "tool_call_update" ? toUpdateBody(event) : toPlanBody(event);
1142
+ flushBuffer(event.sessionId);
1143
+ 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";
1144
+ const body = event.type === "tool_call" ? toToolCallBody(event) : event.type === "tool_call_update" ? toUpdateBody(event) : event.type === "available_commands" ? toAvailableCommandsBody(event) : toPlanBody(event);
853
1145
  body["m.relates_to"] = { rel_type: "m.thread", event_id: ctx.threadRoot };
854
1146
  const tail = (sendQueue.get(event.sessionId) ?? Promise.resolve()).then(async () => {
855
1147
  try {
@@ -888,7 +1180,7 @@ function createMatrixTransport(opts) {
888
1180
  void client.sendCustomEvent({
889
1181
  roomId: ctx.roomId,
890
1182
  asUserId: ctx.agent.userId,
891
- eventType: "eco.zoon.approval_request",
1183
+ eventType: "dev.zooid.approval_request",
892
1184
  content
893
1185
  });
894
1186
  });
@@ -922,20 +1214,33 @@ function createMatrixTransport(opts) {
922
1214
  );
923
1215
  continue;
924
1216
  }
925
- if (evt.type === "eco.zoon.session_reset") {
1217
+ if (evt.type === "m.room.member" && evt.content?.membership === "invite") {
1218
+ const target = evt.state_key;
1219
+ const inviter = evt.sender;
1220
+ if (target && evt.room_id && ourBotUserIds.has(target) && (!inviter || !ourBotUserIds.has(inviter))) {
1221
+ console.log(
1222
+ `[matrix] declining ad-hoc invite for ${target} in ${evt.room_id} from ${inviter ?? "unknown"}`
1223
+ );
1224
+ await client.leaveRoom(evt.room_id, target, { reason: DECLINE_REASON }).catch(
1225
+ (err) => console.warn(`[matrix] leaveRoom(${evt.room_id}, ${target}) failed:`, err)
1226
+ );
1227
+ }
1228
+ continue;
1229
+ }
1230
+ if (evt.type === "dev.zooid.session_reset") {
926
1231
  const relates = evt.content?.["m.relates_to"];
927
1232
  const threadRoot = relates?.rel_type === "m.thread" && relates.event_id ? relates.event_id : void 0;
928
1233
  if (!threadRoot) {
929
- console.log("[matrix] dropping eco.zoon.session_reset without thread relation");
1234
+ console.log("[matrix] dropping dev.zooid.session_reset without thread relation");
930
1235
  continue;
931
1236
  }
932
- console.log(`[matrix] inbound eco.zoon.session_reset in ${evt.room_id} thread=${threadRoot}`);
1237
+ console.log(`[matrix] inbound dev.zooid.session_reset in ${evt.room_id} thread=${threadRoot}`);
933
1238
  for (const a of bindings) {
934
1239
  agents.endSession(a.name, threadRoot);
935
1240
  }
936
1241
  continue;
937
1242
  }
938
- if (evt.type === "eco.zoon.interrupt") {
1243
+ if (evt.type === "dev.zooid.interrupt") {
939
1244
  const content = evt.content ?? {};
940
1245
  const relates = evt.content?.["m.relates_to"];
941
1246
  const threadRoot = relates?.rel_type === "m.thread" && relates.event_id ? relates.event_id : void 0;
@@ -957,7 +1262,7 @@ function createMatrixTransport(opts) {
957
1262
  continue;
958
1263
  }
959
1264
  if (!content.session_id) {
960
- console.warn(`[matrix] eco.zoon.interrupt missing session_id (event_id=${evt.event_id})`);
1265
+ console.warn(`[matrix] dev.zooid.interrupt missing session_id (event_id=${evt.event_id})`);
961
1266
  continue;
962
1267
  }
963
1268
  const ctx = sessions.get(content.session_id);
@@ -972,7 +1277,7 @@ function createMatrixTransport(opts) {
972
1277
  });
973
1278
  continue;
974
1279
  }
975
- if (evt.type === "eco.zoon.approval_response") {
1280
+ if (evt.type === "dev.zooid.approval_response") {
976
1281
  const content = evt.content ?? {};
977
1282
  if (!content.session_id || !content.approval_id || !content.decision) continue;
978
1283
  const decision = content.option_id ? { decision: content.decision, optionId: content.option_id } : { decision: content.decision };
@@ -985,6 +1290,18 @@ function createMatrixTransport(opts) {
985
1290
  continue;
986
1291
  }
987
1292
  logInbound(evt);
1293
+ 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)) {
1294
+ pendingMedia.add(evt.room_id, inboundThreadRoot2(evt), {
1295
+ eventId: evt.event_id,
1296
+ sender: evt.sender,
1297
+ msgtype: evt.content.msgtype,
1298
+ body: evt.content.body ?? "",
1299
+ filename: evt.content.filename,
1300
+ url: evt.content.url,
1301
+ info: evt.content.info
1302
+ });
1303
+ continue;
1304
+ }
988
1305
  const promotedRoot = inboundThreadRoot2(evt) ?? evt.event_id;
989
1306
  const inboundRel = inboundThreadRoot2(evt);
990
1307
  if (evt.type === "m.room.message" && inboundRel && !threadStates.has(inboundRel) && evt.room_id) {
@@ -1050,9 +1367,9 @@ function createMatrixTransport(opts) {
1050
1367
  void client.sendCustomEvent({
1051
1368
  roomId: evt.room_id,
1052
1369
  asUserId: a.userId,
1053
- eventType: "eco.zoon.error",
1370
+ eventType: "dev.zooid.error",
1054
1371
  content: body2
1055
- }).catch((e) => console.warn(`[matrix:${a.name}] eco.zoon.error send failed:`, e));
1372
+ }).catch((e) => console.warn(`[matrix:${a.name}] dev.zooid.error send failed:`, e));
1056
1373
  });
1057
1374
  }
1058
1375
  }
@@ -1086,6 +1403,12 @@ function createMatrixTransport(opts) {
1086
1403
  sessions.set(sessionId, { agent, roomId: evt.room_id, threadRoot });
1087
1404
  buffers.set(sessionId, "");
1088
1405
  bufferMessageIds.delete(sessionId);
1406
+ flushedCounts.set(sessionId, 0);
1407
+ const stashedCommands = pendingCommands.get(sessionId);
1408
+ if (stashedCommands) {
1409
+ pendingCommands.delete(sessionId);
1410
+ void agents.onEvent?.(agent.name, stashedCommands);
1411
+ }
1089
1412
  const roomId = evt.room_id;
1090
1413
  const TYPING_TTL_MS = 3e4;
1091
1414
  const TYPING_REFRESH_MS = 25e3;
@@ -1101,42 +1424,43 @@ function createMatrixTransport(opts) {
1101
1424
  try {
1102
1425
  const rawBody = evt.content?.body ?? "";
1103
1426
  const promptText = stripMention(rawBody, agent.userId);
1427
+ const pendingItems = pendingMedia.drain(
1428
+ evt.room_id,
1429
+ inboundThreadRoot2(evt),
1430
+ evt.sender ?? ""
1431
+ );
1432
+ const { blocks, pathLines } = await buildMediaBlocks(pendingItems, {
1433
+ agent,
1434
+ media: mediaClient,
1435
+ writeAttachmentFn,
1436
+ onError: (item, err) => {
1437
+ console.warn(`[matrix:${agent.name}] media_failed for ${item.body}:`, err);
1438
+ void sendMediaError(
1439
+ { agent, roomId: evt.room_id, threadRoot },
1440
+ err,
1441
+ `Could not process attachment: ${item.body}`,
1442
+ client
1443
+ );
1444
+ }
1445
+ });
1446
+ const fullPromptText = [promptText, ...pathLines].filter(Boolean).join("\n");
1104
1447
  await agents.prompt(agent.name, {
1105
1448
  threadId: sessionKey,
1106
1449
  channelId: evt.room_id,
1107
- content: [{ type: "text", text: promptText }]
1450
+ content: [...blocks, { type: "text", text: fullPromptText }]
1108
1451
  });
1109
1452
  const drainStart = Date.now();
1110
1453
  let drained = buffers.get(sessionId) ?? "";
1111
1454
  while (drainQuietMs > 0 && Date.now() - drainStart < drainMaxMs) {
1112
1455
  await delay(drainQuietMs);
1113
1456
  const next = buffers.get(sessionId) ?? "";
1114
- if (next === drained && next.length > 0) break;
1457
+ if (next === drained && (next.length > 0 || (flushedCounts.get(sessionId) ?? 0) > 0))
1458
+ break;
1115
1459
  drained = next;
1116
1460
  }
1117
- const text = buffers.get(sessionId) ?? "";
1118
- if (text.length > 0) {
1119
- const html = toMatrixHtml(text);
1120
- const content = {
1121
- msgtype: "m.text",
1122
- body: text
1123
- };
1124
- if (html) {
1125
- const escapedPlain = "<p>" + text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") + "</p>";
1126
- const norm = (s) => s.replace(/\s+/g, " ").trim();
1127
- if (norm(html) !== norm(escapedPlain)) {
1128
- content.format = "org.matrix.custom.html";
1129
- content.formatted_body = html;
1130
- }
1131
- }
1132
- await client.sendMessage({
1133
- roomId: evt.room_id,
1134
- asUserId: agent.userId,
1135
- content,
1136
- threadRoot
1137
- // every reply threads, full stop
1138
- });
1139
- } else {
1461
+ flushBuffer(sessionId);
1462
+ await (sendQueue.get(sessionId) ?? Promise.resolve());
1463
+ if ((flushedCounts.get(sessionId) ?? 0) === 0) {
1140
1464
  console.warn(
1141
1465
  `[matrix:${agent.name}] turn finished with empty buffer (session=${sessionId}); nothing sent to ${evt.room_id}`
1142
1466
  );
@@ -1147,6 +1471,8 @@ function createMatrixTransport(opts) {
1147
1471
  await safePresence("online");
1148
1472
  buffers.delete(sessionId);
1149
1473
  bufferMessageIds.delete(sessionId);
1474
+ flushedCounts.delete(sessionId);
1475
+ sendQueue.delete(sessionId);
1150
1476
  }
1151
1477
  }
1152
1478
  return {
@@ -1232,7 +1558,7 @@ async function publishWorkforce(opts) {
1232
1558
  await opts.client.sendStateEvent({
1233
1559
  roomId: opts.spaceRoomId,
1234
1560
  asUserId: opts.asUserId,
1235
- eventType: "eco.zoon.workforce",
1561
+ eventType: "dev.zooid.workforce",
1236
1562
  stateKey: "",
1237
1563
  content: buildWorkforceRoster(opts.agents)
1238
1564
  });
@@ -1249,17 +1575,27 @@ async function startWorkforcePublisher(opts) {
1249
1575
  }
1250
1576
  export {
1251
1577
  BotPool,
1578
+ INLINE_IMAGE_MIMES,
1579
+ MAX_DOWNLOAD_BYTES,
1580
+ MAX_INLINE_IMAGE_BYTES,
1581
+ MAX_MEDIA_PER_TURN,
1582
+ MEDIA_MSGTYPES,
1252
1583
  MatrixClient,
1253
1584
  MatrixContextProvider,
1585
+ MediaClient,
1586
+ PendingMediaStore,
1254
1587
  buildWorkforceRoster,
1255
1588
  createMatrixTransport,
1256
1589
  ensureDefaultChannel,
1257
1590
  ensureWorkforceSpace,
1258
1591
  extractMentions,
1592
+ isMediaMsgtype,
1593
+ parseMxcUri,
1259
1594
  publishWorkforce,
1260
1595
  renderRegistration,
1261
1596
  route,
1262
1597
  serverNameFromMxid,
1263
- startWorkforcePublisher
1598
+ startWorkforcePublisher,
1599
+ writeAttachment
1264
1600
  };
1265
1601
  //# sourceMappingURL=index.js.map