@agentvault/grok-bridge 0.4.12 → 0.4.13

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/bridge.d.ts CHANGED
@@ -163,6 +163,23 @@ export declare function attachLifecycle(channel: LifecycleChannel, opts?: {
163
163
  * unmarked turn already reads as a DM, which is what the model assumes.
164
164
  */
165
165
  export declare function roomTurnPrefix(roomId: string, senderName: string, roomName?: string): string;
166
+ /**
167
+ * Does this agent owe a reply to this room message? (#1107)
168
+ *
169
+ * This bridge used to answer with `e.senderIsAgent === false` — "reply to any
170
+ * human". That was a THIRD implementation of the room reply rule, alongside the
171
+ * OpenClaw plugin and hermes, and #1107 shipped to the other two only. Under the
172
+ * old rule this agent would still have answered every un-mentioned message while
173
+ * wren, cortina, smartcode and smartcoder2 stayed quiet: the cascade, half-fixed,
174
+ * in the half most likely to be read as "the protocol doesn't work".
175
+ *
176
+ * Now it defers to the ONE shared implementation exported by
177
+ * `@agentvault/agentvault`. The message is still pushed into the session either
178
+ * way — the agent reads the whole room and only the reply obligation changes.
179
+ */
180
+ export declare function _makeOwesReply(agentName: string | undefined, channel: {
181
+ ownDisplayName?: string;
182
+ }, log: (msg: string) => void): (e: RoomMessage) => boolean;
166
183
  export declare function wireBridge(channel: RoomChannel, session: RoomSession, target: ActiveTarget, opts?: {
167
184
  /**
168
185
  * #1107: this agent's own name, used to recognise its own @mention. Threaded
package/dist/index.js CHANGED
@@ -1115,6 +1115,58 @@ var init_mls_commit_classify = __esm2({
1115
1115
  "use strict";
1116
1116
  }
1117
1117
  });
1118
+ function parseMentions(text) {
1119
+ const mentions = [];
1120
+ const re2 = new RegExp(MENTION_RE.source, MENTION_RE.flags);
1121
+ let match;
1122
+ while ((match = re2.exec(text)) !== null) {
1123
+ mentions.push(match[1].toLowerCase());
1124
+ }
1125
+ return mentions;
1126
+ }
1127
+ function resolveOwn(mention, names) {
1128
+ if (names.has(mention)) return mention;
1129
+ const parts = mention.split("-");
1130
+ for (let i2 = parts.length - 1; i2 > 0; i2--) {
1131
+ const candidate = parts.slice(0, i2).join("-");
1132
+ if (names.has(candidate)) return candidate;
1133
+ }
1134
+ return null;
1135
+ }
1136
+ function owesRoomReply(plaintext, agentName, accountId, senderIsAgent, roomDisplayName, ownDisplayName, selfResolved = true) {
1137
+ const mentions = parseMentions(plaintext);
1138
+ if (mentions.length === 0) return false;
1139
+ const ownNames = ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName);
1140
+ if (mentions.some((m2) => resolveOwn(m2, ownNames) !== null)) return true;
1141
+ if (mentions.some((m2) => resolveOwn(m2, BROADCAST_NAMES) !== null)) {
1142
+ return senderIsAgent !== true;
1143
+ }
1144
+ if (!selfResolved) return true;
1145
+ return false;
1146
+ }
1147
+ function ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName) {
1148
+ const names = /* @__PURE__ */ new Set();
1149
+ const add4 = (n2) => {
1150
+ if (!n2) return;
1151
+ const lower = n2.toLowerCase();
1152
+ names.add(lower);
1153
+ names.add(lower.split(/[\s(]/)[0]);
1154
+ };
1155
+ add4(agentName);
1156
+ add4(accountId);
1157
+ add4(roomDisplayName);
1158
+ add4(ownDisplayName);
1159
+ return names;
1160
+ }
1161
+ var MENTION_RE;
1162
+ var BROADCAST_NAMES;
1163
+ var init_room_protocol = __esm2({
1164
+ "src/room-protocol.ts"() {
1165
+ "use strict";
1166
+ MENTION_RE = /@([\w-]+)/gi;
1167
+ BROADCAST_NAMES = /* @__PURE__ */ new Set(["all", "everyone"]);
1168
+ }
1169
+ });
1118
1170
  var __filename;
1119
1171
  var __dirname;
1120
1172
  var url2;
@@ -64017,10 +64069,10 @@ var init_mls_group = __esm2({
64017
64069
  const commits = [];
64018
64070
  if (leavesToRemove.length > 0) {
64019
64071
  const { commit } = await this.removeMembers(leavesToRemove);
64020
- commits.push({ commit, epoch: this.epoch });
64072
+ commits.push({ commit, epoch: this.epoch, state: this.exportState() });
64021
64073
  }
64022
64074
  const added = await this.addMembers(toAdd);
64023
- commits.push({ commit: added.commit, epoch: this.epoch });
64075
+ commits.push({ commit: added.commit, epoch: this.epoch, state: this.exportState() });
64024
64076
  return { commits, welcome: added.welcome };
64025
64077
  }
64026
64078
  async removeMembers(memberIndices) {
@@ -64605,11 +64657,11 @@ var init_mls_kp_pool = __esm2({
64605
64657
  }
64606
64658
  });
64607
64659
  function ownIdentity() {
64608
- const v2 = true ? "0.23.39" : FALLBACK;
64660
+ const v2 = true ? "0.23.40" : FALLBACK;
64609
64661
  return `${PACKAGE}@${v2}`;
64610
64662
  }
64611
64663
  function buildSha() {
64612
- const raw = true ? "9982fc8-dirty" : "";
64664
+ const raw = true ? "dbac15d-dirty" : "";
64613
64665
  const cleaned = (raw ?? "").trim();
64614
64666
  return !cleaned || cleaned === "unknown" ? null : cleaned;
64615
64667
  }
@@ -65531,6 +65583,7 @@ var init_channel = __esm2({
65531
65583
  "use strict";
65532
65584
  init_abstention();
65533
65585
  init_mls_commit_classify();
65586
+ init_room_protocol();
65534
65587
  await init_libsodium_wrappers();
65535
65588
  await init_dist();
65536
65589
  await init_dist();
@@ -65733,6 +65786,35 @@ var init_channel = __esm2({
65733
65786
 
65734
65787
  ${text}`;
65735
65788
  }
65789
+ /**
65790
+ * #1161: room attachments whose caption addressed nobody, NOT yet fetched.
65791
+ * The reply gate lives in every consumer of `room_message` (openclaw-entry
65792
+ * and the three native bridges), but its first clause is shared and needs no
65793
+ * identity: a caption with no mention token owes nobody a reply (#1107), so
65794
+ * nobody needs the file yet. Kept per room, newest last, and fetched on the
65795
+ * room's next ADDRESSED turn — the owner attaches, then asks (#1159/#1181).
65796
+ * Hermes does the same (`_defer_room_attachment`).
65797
+ */
65798
+ _deferredRoomAttachments = /* @__PURE__ */ new Map();
65799
+ static DEFERRED_ROOM_ATTACHMENT_MAX = 5;
65800
+ static DEFERRED_ROOM_ATTACHMENT_MS = 60 * 60 * 1e3;
65801
+ _deferRoomAttachment(roomId, info) {
65802
+ const list = this._deferredRoomAttachments.get(roomId) ?? [];
65803
+ list.push({ info, at: Date.now() });
65804
+ while (list.length > _SecureChannel.DEFERRED_ROOM_ATTACHMENT_MAX) list.shift();
65805
+ this._deferredRoomAttachments.set(roomId, list);
65806
+ console.log(
65807
+ `[SecureChannel] room ${roomId.slice(0, 8)}: attachment ${String(info?.filename ?? "file")} deferred \u2014 caption addresses nobody, fetch on the next @-addressed turn (#1161)`
65808
+ );
65809
+ }
65810
+ /** Drain the room's deferred attachments that are still fresh (oldest first). */
65811
+ _takeDeferredRoomAttachments(roomId) {
65812
+ const list = this._deferredRoomAttachments.get(roomId);
65813
+ if (!list?.length) return [];
65814
+ this._deferredRoomAttachments.delete(roomId);
65815
+ const cutoff = Date.now() - _SecureChannel.DEFERRED_ROOM_ATTACHMENT_MS;
65816
+ return list.filter((d2) => d2.at >= cutoff).map((d2) => d2.info);
65817
+ }
65736
65818
  /** In-flight roster-refresh promises (per room) — dedup concurrent refreshes. */
65737
65819
  _roomRosterRefreshInFlight = /* @__PURE__ */ new Map();
65738
65820
  /** Dedup buffer for A2A message IDs (prevents double-delivery via direct + Redis) */
@@ -66828,7 +66910,7 @@ ${text}`;
66828
66910
  */
66829
66911
  sendActivitySpan(spanData) {
66830
66912
  if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
66831
- const pluginVersion = true ? "0.23.39" : "0.0.0-dev";
66913
+ const pluginVersion = true ? "0.23.40" : "0.0.0-dev";
66832
66914
  const agentName = this.config.agentName ?? "Agent";
66833
66915
  const resource = {
66834
66916
  "service.name": "agentvault-agent",
@@ -68766,7 +68848,7 @@ ${text}`;
68766
68848
  agentVersion: this.config.agentVersion ?? "0.0.0",
68767
68849
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68768
68850
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68769
- pluginVersion: true ? "0.23.39" : "0.0.0-dev"
68851
+ pluginVersion: true ? "0.23.40" : "0.0.0-dev"
68770
68852
  });
68771
68853
  this._telemetryReporter.startAutoFlush(3e4);
68772
68854
  }
@@ -69090,7 +69172,7 @@ ${text}`;
69090
69172
  agentVersion: this.config.agentVersion ?? "0.0.0",
69091
69173
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
69092
69174
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
69093
- pluginVersion: true ? "0.23.39" : "0.0.0-dev"
69175
+ pluginVersion: true ? "0.23.40" : "0.0.0-dev"
69094
69176
  });
69095
69177
  this._telemetryReporter.startAutoFlush(3e4);
69096
69178
  }
@@ -69404,6 +69486,7 @@ ${text}`;
69404
69486
  `[deliver] delivery_result=REJECTED group=${rejectedGroup || "unknown"} detail=${detail}`
69405
69487
  );
69406
69488
  console.error(`[SecureChannel] Server error: ${detail}`);
69489
+ this._noteCommitRefusal(data.data || data);
69407
69490
  await this._handleServerError(data.data || data);
69408
69491
  this.emit("error", new Error(`Server: ${detail}`));
69409
69492
  }
@@ -69705,7 +69788,7 @@ ${text}`;
69705
69788
  try {
69706
69789
  const parsed = JSON.parse(plaintext);
69707
69790
  messageType = parsed.type || "message";
69708
- messageText = parsed.text || plaintext;
69791
+ messageText = parsed.text ?? plaintext;
69709
69792
  if (parsed.attachment) {
69710
69793
  attachmentInfo = parsed.attachment;
69711
69794
  }
@@ -70457,7 +70540,7 @@ ${baseText}`;
70457
70540
  try {
70458
70541
  const parsed = JSON.parse(rawPlaintext);
70459
70542
  messageType = parsed.type || "message";
70460
- messageText = parsed.text || rawPlaintext;
70543
+ messageText = parsed.text ?? rawPlaintext;
70461
70544
  if (parsed.attachment) roomAttachment = parsed.attachment;
70462
70545
  } catch {
70463
70546
  messageType = "message";
@@ -70498,10 +70581,19 @@ ${baseText}`;
70498
70581
  await this._refreshRoomRoster(resolvedRoomId);
70499
70582
  }
70500
70583
  }
70501
- if (roomAttachment) {
70502
- messageText = await this._augmentWithAttachment(roomAttachment, messageText, resolvedRoomId);
70584
+ const addressed = parseMentions(messageText).length > 0;
70585
+ if (roomAttachment && !addressed) {
70586
+ this._deferRoomAttachment(resolvedRoomId, roomAttachment);
70503
70587
  } else {
70504
- messageText = this._withRecentRoomAttachmentNote(resolvedRoomId, messageText);
70588
+ const deferred = addressed ? this._takeDeferredRoomAttachments(resolvedRoomId) : [];
70589
+ for (const info of deferred) {
70590
+ messageText = await this._augmentWithAttachment(info, messageText, resolvedRoomId);
70591
+ }
70592
+ if (roomAttachment) {
70593
+ messageText = await this._augmentWithAttachment(roomAttachment, messageText, resolvedRoomId);
70594
+ } else if (deferred.length === 0) {
70595
+ messageText = this._withRecentRoomAttachmentNote(resolvedRoomId, messageText);
70596
+ }
70505
70597
  }
70506
70598
  const roomMembers = this._persisted?.rooms?.[resolvedRoomId]?.members ?? [];
70507
70599
  const senderMember = roomMembers.find((m2) => m2.deviceId === senderDeviceId);
@@ -70625,6 +70717,12 @@ ${baseText}`;
70625
70717
  const groupId = data.group_id;
70626
70718
  const epoch = typeof data.epoch === "number" ? data.epoch : 0;
70627
70719
  this._noteGroupEpoch(groupId, data.epoch);
70720
+ const held = this._commitInFlight.get(groupId);
70721
+ if (held) {
70722
+ held.frames.push(data);
70723
+ console.log(`[SecureChannel] MLS commit for ${groupId.slice(0, 8)} held (epoch=${epoch}) \u2014 our own commit is awaiting confirmation (#1184)`);
70724
+ return true;
70725
+ }
70628
70726
  const target = this._resolveGroupFamily(groupId);
70629
70727
  if (target) {
70630
70728
  const { managerKey: groupKey, label } = target;
@@ -70867,73 +70965,146 @@ ${baseText}`;
70867
70965
  return 0;
70868
70966
  }
70869
70967
  const snapshot = mlsGroup.exportState();
70870
- let parent2 = await this._readGroupHeadDigest(mlsGroupId);
70871
- const { commits, welcome } = await mlsGroup.reAdmitMembers(targets.map((t2) => t2.keyPackage));
70872
- let sentCommits = 0;
70873
- for (const c2 of commits) {
70874
- const ok = await this._sendFrameConfirmed(
70875
- {
70876
- event: "mls_commit",
70877
- data: {
70878
- group_id: mlsGroupId,
70879
- // The epoch this commit PRODUCED. After two commits the manager
70880
- // only knows the last one, which is why the result carries them.
70881
- epoch: Number(c2.epoch),
70882
- payload: Buffer.from(c2.commit).toString("hex"),
70883
- ...parent2 ? { parent_digest: parent2 } : {}
70884
- }
70885
- },
70886
- `${label} commit ${sentCommits + 1}/${commits.length}`
70887
- );
70888
- if (!ok) break;
70889
- parent2 = this._commitDigest(c2.commit);
70890
- sentCommits++;
70891
- }
70892
- if (sentCommits === 0) {
70893
- mlsGroup.importState(snapshot);
70894
- console.warn(
70895
- `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
70896
- );
70897
- return 0;
70898
- }
70899
- await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70900
- if (sentCommits < commits.length) {
70901
- console.warn(
70902
- `[SecureChannel] ${label}: only ${sentCommits}/${commits.length} commits reached the wire \u2014 requests stay PENDING for the next pull`
70903
- );
70904
- return 0;
70905
- }
70906
- if (!welcome) {
70968
+ const preHead = await this._readGroupHead(mlsGroupId);
70969
+ if (preHead.ok && preHead.epoch !== Number(mlsGroup.epoch)) {
70907
70970
  console.warn(
70908
- `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
70971
+ `[SecureChannel] ${label}: server head is at epoch ${preHead.epoch}, we are at ${Number(mlsGroup.epoch)} \u2014 NOT committing (it would be refused, #1184); ${targets.length} request(s) stay pending`
70909
70972
  );
70910
70973
  return 0;
70911
70974
  }
70912
- let fulfilled = 0;
70913
- for (const t2 of targets) {
70914
- const sent = await this._sendFrameConfirmed(
70915
- {
70916
- event: "mls_welcome",
70917
- data: {
70918
- target_device_id: t2.deviceId,
70919
- group_id: mlsGroupId,
70920
- ...welcomeFields,
70921
- payload: Buffer.from(welcome).toString("hex")
70922
- }
70923
- },
70924
- `${label} welcome\u2192${t2.deviceId.slice(0, 8)}`
70925
- );
70926
- if (!sent) {
70975
+ let parent2 = preHead.ok ? preHead.digest : void 0;
70976
+ const inFlight = { frames: [], sent: [] };
70977
+ this._commitInFlight.set(mlsGroupId, inFlight);
70978
+ try {
70979
+ const { commits, welcome } = await mlsGroup.reAdmitMembers(targets.map((t2) => t2.keyPackage));
70980
+ let sentCommits = 0;
70981
+ let refusedAt = -1;
70982
+ let verdict = "confirmed";
70983
+ for (const c2 of commits) {
70984
+ const ours = this._commitDigest(c2.commit);
70985
+ inFlight.sent.push({
70986
+ epoch: Number(c2.epoch),
70987
+ digest: ours,
70988
+ verified: false,
70989
+ stateBefore: sentCommits === 0 ? snapshot : commits[sentCommits - 1].state
70990
+ });
70991
+ const ok = await this._sendFrameConfirmed(
70992
+ {
70993
+ event: "mls_commit",
70994
+ data: {
70995
+ group_id: mlsGroupId,
70996
+ // The epoch this commit PRODUCED. After two commits the manager
70997
+ // only knows the last one, which is why the result carries them.
70998
+ epoch: Number(c2.epoch),
70999
+ payload: Buffer.from(c2.commit).toString("hex"),
71000
+ ...parent2 ? { parent_digest: parent2 } : {}
71001
+ }
71002
+ },
71003
+ `${label} commit ${sentCommits + 1}/${commits.length}`
71004
+ );
71005
+ if (!ok) {
71006
+ verdict = "unsent";
71007
+ inFlight.sent.pop();
71008
+ break;
71009
+ }
71010
+ verdict = await this._confirmCommitAtHead(mlsGroupId, Number(c2.epoch), ours, inFlight, label);
71011
+ if (verdict === "confirmed") {
71012
+ inFlight.sent[sentCommits].verified = true;
71013
+ } else if (verdict === "unverifiable") {
71014
+ console.warn(
71015
+ `[SecureChannel] ${label}: commit ${sentCommits + 1}/${commits.length} at epoch ${Number(c2.epoch)} could not be verified and was not refused \u2014 proceeding UNCHECKED (#1184)`
71016
+ );
71017
+ } else {
71018
+ refusedAt = sentCommits;
71019
+ break;
71020
+ }
71021
+ parent2 = ours;
71022
+ sentCommits++;
71023
+ }
71024
+ const cutFromRefusal = () => {
71025
+ const r2 = inFlight.refusal;
71026
+ if (r2 === void 0) return refusedAt;
71027
+ return refusedAt === -1 ? r2.from : Math.min(refusedAt, r2.from);
71028
+ };
71029
+ const rollBackTo = async (cut2, stage, persisted) => {
71030
+ const why = inFlight.refusal?.detail ?? verdict.toUpperCase();
71031
+ mlsGroup.importState(inFlight.sent[cut2].stateBefore);
71032
+ sentCommits = cut2;
70927
71033
  console.warn(
70928
- `[SecureChannel] ${label}: Welcome for ${t2.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
71034
+ `[SecureChannel] ${label}: commit ${cut2 + 1}/${commits.length} ${stage} \u2014 "${why}" \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}` + (cut2 === 0 ? ", nothing" : ` (after commit ${cut2}), only that`) + ` persisted, ${targets.length} request(s) stay pending`
70929
71035
  );
70930
- continue;
71036
+ if (persisted) await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
71037
+ };
71038
+ let cut = cutFromRefusal();
71039
+ if (cut !== -1) {
71040
+ await rollBackTo(cut, "was refused by the server", false);
71041
+ if (sentCommits === 0) return 0;
70931
71042
  }
70932
- if (await this._postFulfilWelcome(mlsGroupId, t2.requestId, `${label} ${t2.deviceId.slice(0, 8)}`)) {
70933
- fulfilled++;
71043
+ if (sentCommits === 0) {
71044
+ mlsGroup.importState(snapshot);
71045
+ console.warn(
71046
+ `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
71047
+ );
71048
+ return 0;
71049
+ }
71050
+ await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
71051
+ cut = cutFromRefusal();
71052
+ if (cut !== -1 && cut < sentCommits) {
71053
+ await rollBackTo(cut, "was refused by the server after the window (during persist)", true);
71054
+ return 0;
71055
+ }
71056
+ if (sentCommits < commits.length) {
71057
+ console.warn(
71058
+ `[SecureChannel] ${label}: only ${sentCommits}/${commits.length} commits were confirmed \u2014 requests stay PENDING for the next pull`
71059
+ );
71060
+ return 0;
71061
+ }
71062
+ if (!welcome) {
71063
+ console.warn(
71064
+ `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
71065
+ );
71066
+ return 0;
71067
+ }
71068
+ let fulfilled = 0;
71069
+ for (const t2 of targets) {
71070
+ const sent = await this._sendFrameConfirmed(
71071
+ {
71072
+ event: "mls_welcome",
71073
+ data: {
71074
+ target_device_id: t2.deviceId,
71075
+ group_id: mlsGroupId,
71076
+ ...welcomeFields,
71077
+ payload: Buffer.from(welcome).toString("hex")
71078
+ }
71079
+ },
71080
+ `${label} welcome\u2192${t2.deviceId.slice(0, 8)}`
71081
+ );
71082
+ if (!sent) {
71083
+ console.warn(
71084
+ `[SecureChannel] ${label}: Welcome for ${t2.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
71085
+ );
71086
+ continue;
71087
+ }
71088
+ cut = cutFromRefusal();
71089
+ if (cut !== -1 && cut < sentCommits) {
71090
+ await rollBackTo(cut, "was refused by the server after the window (during the Welcome)", true);
71091
+ return fulfilled;
71092
+ }
71093
+ if (await this._postFulfilWelcome(mlsGroupId, t2.requestId, `${label} ${t2.deviceId.slice(0, 8)}`)) {
71094
+ fulfilled++;
71095
+ }
71096
+ }
71097
+ return fulfilled;
71098
+ } finally {
71099
+ this._commitInFlight.delete(mlsGroupId);
71100
+ for (const frame of inFlight.frames) {
71101
+ try {
71102
+ await this._handleMlsCommit(frame);
71103
+ } catch (err) {
71104
+ console.error(`[SecureChannel] ${label}: replaying a held MLS commit failed:`, err);
71105
+ }
70934
71106
  }
70935
71107
  }
70936
- return fulfilled;
70937
71108
  }
70938
71109
  /**
70939
71110
  * Resolve an MLS group id to the family that owns it.
@@ -70980,24 +71151,237 @@ ${baseText}`;
70980
71151
  /**
70981
71152
  * #1177: the digest of the commit at the server's head, or undefined.
70982
71153
  *
70983
- * Undefined covers "no head yet", "older backend", AND "could not read" —
70984
- * deliberately the same value. The commit is sent either way: the server
70985
- * skips the parent check for an ABSENT field and refuses a MALFORMED one,
70986
- * so a read failure must degrade to "unchecked", never to "blocked" (a
70987
- * rescue that cannot run is the 2026-09-04 lockout) and never to `null`.
71154
+ * Undefined covers "no head yet" (a pruned group: epoch known, digest NULL —
71155
+ * 29 in production) AND "could not read" — deliberately the same value. The
71156
+ * commit is sent either way: the server skips the parent check for an
71157
+ * ABSENT field and refuses a MALFORMED one, so a read failure must degrade
71158
+ * to "unchecked", never to "blocked" (a rescue that cannot run is the
71159
+ * 2026-09-04 lockout) and never to `null`.
70988
71160
  */
70989
71161
  async _readGroupHeadDigest(groupId) {
71162
+ const head = await this._readGroupHead(groupId);
71163
+ return head.ok ? head.digest : void 0;
71164
+ }
71165
+ /**
71166
+ * #1184: the server's head — its `current_epoch` and the digest of the commit
71167
+ * that produced it.
71168
+ *
71169
+ * - `{ ok: true, epoch, digest? }` the server described its head. `digest`
71170
+ * is absent on a pruned group (NULL
71171
+ * `last_commit_digest`).
71172
+ * - `{ ok: false, why }` the read failed: a non-2xx (the 403
71173
+ * `assert_group_member` returns on an
71174
+ * EMPTY roster — 79 of 80 production
71175
+ * rooms — or a 5xx), a timeout, or a body
71176
+ * with no `current_epoch`. Callers may
71177
+ * retry; a failure is never a verdict.
71178
+ *
71179
+ * `current_epoch` is a required field of `MLSGroupResponse`, so "200 with
71180
+ * no head" is not a shape any deployed backend produces; it is folded into
71181
+ * `ok: false` rather than given a branch of its own (the first cut of #1184
71182
+ * had one, and it was the only branch the suite exercised).
71183
+ */
71184
+ async _readGroupHead(groupId) {
70990
71185
  try {
70991
71186
  const res = await fetch(`${this.config.apiUrl}/api/v1/mls/groups/${groupId}`, {
70992
- headers: { Authorization: `Bearer ${this._deviceJwt}` }
71187
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
71188
+ // The confirm loop holds `_deliveryPulling`; a black-holed read must
71189
+ // not hold it for undici's 300 s default. A timeout is a failed read.
71190
+ signal: AbortSignal.timeout(_SecureChannel.HEAD_READ_TIMEOUT_MS)
70993
71191
  });
70994
- if (!res.ok) return void 0;
71192
+ if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
70995
71193
  const head = await res.json();
70996
- const d2 = head?.last_commit_digest;
70997
- return typeof d2 === "string" && /^[0-9a-f]{64}$/.test(d2) ? d2 : void 0;
70998
- } catch {
70999
- return void 0;
71194
+ if (typeof head?.current_epoch !== "number") return { ok: false, why: "no current_epoch in body" };
71195
+ const d2 = head.last_commit_digest;
71196
+ return { ok: true, epoch: head.current_epoch, digest: typeof d2 === "string" && /^[0-9a-f]{64}$/.test(d2) ? d2 : void 0 };
71197
+ } catch (err) {
71198
+ return { ok: false, why: err instanceof Error ? err.name : String(err) };
71199
+ }
71200
+ }
71201
+ /**
71202
+ * #1184: after each read of the head, how long to wait before the next.
71203
+ * The web's schedule (`lib/mls-commit-confirm.ts`) plus one immediate read:
71204
+ * five reads, ~3.75 s, no resend — an MLS commit cannot be regenerated. The
71205
+ * immediate read catches the common case where the server stored the frame
71206
+ * before the GET lands. Instance-level so tests can run the decision logic
71207
+ * without the clock; an empty schedule still reads once (`_confirmCommitAtHead`).
71208
+ */
71209
+ _commitConfirmDelaysMs = [0, 250, 500, 1e3, 2e3];
71210
+ static HEAD_READ_TIMEOUT_MS = 5e3;
71211
+ /**
71212
+ * #1184: groups with a commit of OURS between "built" and "confirmed or rolled
71213
+ * back". Each record holds the `mls_commit` frames the socket delivered for
71214
+ * that group meanwhile — before the confirm loop that window was
71215
+ * microseconds; now it is up to ~3.75 s, and a commit applied to our
71216
+ * unconfirmed local state would be lost by the rollback (in memory) after
71217
+ * being persisted (on disk). Held frames are replayed, in order, once the
71218
+ * group is back on a real state. The record also carries the server's
71219
+ * REFUSAL, when one arrives on the socket (`_noteCommitRefusal`), and a
71220
+ * `wake` the confirm loop installs so a refusal ends its wait at once.
71221
+ */
71222
+ _commitInFlight = /* @__PURE__ */ new Map();
71223
+ /** #1184: a pull was requested while one was running — run once more after. */
71224
+ _deliveryPullRequested = false;
71225
+ /**
71226
+ * #1184: an `error` frame naming a group we have a commit in flight for, with
71227
+ * a `… commit rejected` detail, is the server's refusal of one of those
71228
+ * commits — every rejection the `mls_commit` WS handler sends (malformed
71229
+ * parent, PARENT_MISMATCH, EPOCH_CONFLICT, not-a-member, no-participants)
71230
+ * ends with those words and carries `group_id` (pinned by
71231
+ * `tests/test_1184_commit_reject_frame_contract.py`). It is sent on OUR
71232
+ * socket after the server processed OUR frame, so it is deterministic in a
71233
+ * way the head poll is not, and it is the only refusal signal on a group
71234
+ * whose head we cannot read (403).
71235
+ *
71236
+ * WHICH commit, and what else the frame proves (re-review of #1227):
71237
+ * - EPOCH_CONFLICT carries `expected_epoch` (the epoch we claimed to build
71238
+ * on) and `current_epoch` (the server's); PARENT_MISMATCH carries
71239
+ * `current_epoch` (the head, which equalled our claim or the epoch check
71240
+ * would have fired first) and `head_digest`. The refused commit is the
71241
+ * one built on that epoch. A frame naming an epoch none of our in-flight
71242
+ * commits claimed is a refusal of some EARLIER attempt — ignored.
71243
+ * - the server's `current_epoch` bounds our earlier commits too: any of
71244
+ * ours ABOVE it did not land.
71245
+ * - `head_digest` at `current_epoch` is our commit at that epoch → that one
71246
+ * positively LANDED; someone else's → ours at that epoch did not.
71247
+ * - the un-numbered shapes refuse all of ours alike: the cut is the first
71248
+ * commit the head poll has not positively confirmed.
71249
+ * Recorded as the index of the first commit shown not to hold; the fulfil
71250
+ * path honours it whenever it arrives. A frame for a group with nothing in
71251
+ * flight is somebody else's business.
71252
+ */
71253
+ _noteCommitRefusal(payload) {
71254
+ const groupId = typeof payload?.group_id === "string" ? payload.group_id : void 0;
71255
+ const detail = typeof payload?.detail === "string" ? payload.detail : "";
71256
+ if (!groupId || !detail.endsWith("commit rejected")) return;
71257
+ const inFlight = this._commitInFlight.get(groupId);
71258
+ if (!inFlight) return;
71259
+ const num = (v2) => typeof v2 === "number" && Number.isFinite(v2) ? v2 : void 0;
71260
+ const current = num(payload.current_epoch);
71261
+ const claimed = num(payload.expected_epoch) ?? current;
71262
+ const headDigest = typeof payload.head_digest === "string" ? payload.head_digest : void 0;
71263
+ const sent = inFlight.sent;
71264
+ const tag = `[SecureChannel] refusal for ${groupId.slice(0, 8)} ("${detail}")`;
71265
+ let from = -1;
71266
+ if (claimed !== void 0) {
71267
+ from = sent.findIndex((c2) => c2.epoch === claimed + 1);
71268
+ if (from === -1) {
71269
+ console.warn(`${tag} names epoch ${claimed + 1}, which no commit in flight claimed \u2014 an earlier attempt's; ignored`);
71270
+ return;
71271
+ }
71272
+ }
71273
+ if (current !== void 0) {
71274
+ const above = sent.findIndex((c2) => c2.epoch > current && !c2.verified);
71275
+ if (above !== -1 && (from === -1 || above < from)) from = above;
71276
+ }
71277
+ if (headDigest !== void 0 && current !== void 0) {
71278
+ const at = sent.findIndex((c2) => c2.epoch === current);
71279
+ if (at !== -1) {
71280
+ if (sent[at].digest === headDigest) {
71281
+ sent[at].verified = true;
71282
+ } else if (from === -1 || at < from) {
71283
+ from = at;
71284
+ }
71285
+ }
71286
+ }
71287
+ if (from === -1) from = sent.findIndex((c2) => !c2.verified);
71288
+ if (from === -1 || sent[from].verified) {
71289
+ console.warn(`${tag} contradicts a head read that showed our commit \u2014 stale; ignored`);
71290
+ return;
71291
+ }
71292
+ if (inFlight.refusal !== void 0 && inFlight.refusal.from <= from) return;
71293
+ inFlight.refusal = { detail, from };
71294
+ inFlight.wake?.();
71295
+ }
71296
+ /**
71297
+ * #1184: a commit is CONFIRMED when the server's head shows OUR digest at OUR
71298
+ * epoch — never when the socket took the bytes (#1015 covers only that). The
71299
+ * server sends no ack for a stored commit, so, like the web, we read the
71300
+ * head back; but it DOES send a refusal on the socket (`_noteCommitRefusal`),
71301
+ * and that is checked first, before every read and during every wait.
71302
+ *
71303
+ * confirmed head.epoch === ours && head.digest === ours
71304
+ * conflict the server refused ours on the socket, OR the head is at
71305
+ * our epoch with ANOTHER digest, OR already past us
71306
+ * unconfirmed the head was READ (at least once) but never reached our
71307
+ * epoch within the window — the server described its state
71308
+ * and ours is not in it
71309
+ * unverifiable the head could not be read on ANY try (403 on an empty
71310
+ * roster, an outage) and no refusal arrived — or it is at
71311
+ * our epoch but keeps no digest (a pruned group). Nothing
71312
+ * can be decided on; the caller proceeds UNCHECKED (#1177's
71313
+ * rule: a rescue that cannot run is the 2026-09-04 lockout)
71314
+ *
71315
+ * The first cut returned `unverifiable` only for a body with no
71316
+ * `current_epoch`, which no deployed backend produces, and rolled back on
71317
+ * every real read failure — the opposite of the rule it stated.
71318
+ */
71319
+ async _confirmCommitAtHead(groupId, epoch, ours, inFlight, label) {
71320
+ const refused = () => {
71321
+ if (inFlight.refusal === void 0) return void 0;
71322
+ console.warn(`[SecureChannel] ${label}: server refused our commit at epoch ${epoch} \u2014 "${inFlight.refusal.detail}"`);
71323
+ return "conflict";
71324
+ };
71325
+ let lastSeen;
71326
+ let described = false;
71327
+ let lastFailure = "";
71328
+ const delays = this._commitConfirmDelaysMs.length > 0 ? this._commitConfirmDelaysMs : [0];
71329
+ for (const delay of delays) {
71330
+ if (delay > 0) {
71331
+ await new Promise((resolve3) => {
71332
+ const timer = setTimeout(() => {
71333
+ inFlight.wake = void 0;
71334
+ resolve3();
71335
+ }, delay);
71336
+ inFlight.wake = () => {
71337
+ clearTimeout(timer);
71338
+ inFlight.wake = void 0;
71339
+ resolve3();
71340
+ };
71341
+ });
71342
+ }
71343
+ const early = refused();
71344
+ if (early) return early;
71345
+ const head = await this._readGroupHead(groupId);
71346
+ const duringRead = refused();
71347
+ if (duringRead) return duringRead;
71348
+ if (!head.ok) {
71349
+ lastFailure = head.why;
71350
+ continue;
71351
+ }
71352
+ described = true;
71353
+ lastSeen = head.epoch;
71354
+ if (head.epoch === epoch) {
71355
+ if (head.digest === ours) return "confirmed";
71356
+ if (head.digest === void 0) return "unverifiable";
71357
+ console.warn(
71358
+ `[SecureChannel] ${label}: head at epoch ${epoch} is NOT our commit (${head.digest.slice(0, 12)}\u2026 vs ours ${ours.slice(0, 12)}\u2026) \u2014 ours was refused`
71359
+ );
71360
+ return "conflict";
71361
+ }
71362
+ if (head.epoch > epoch) {
71363
+ console.warn(`[SecureChannel] ${label}: head already at epoch ${head.epoch}, past our ${epoch} \u2014 ours was refused`);
71364
+ return "conflict";
71365
+ }
71366
+ }
71367
+ const late2 = refused();
71368
+ if (late2) return late2;
71369
+ if (!described && !this._wsIsOpen()) {
71370
+ console.warn(
71371
+ `[SecureChannel] ${label}: the socket closed during the confirm window and the head could not be read \u2014 a refusal of commit at epoch ${epoch} may have been lost; treating as refused`
71372
+ );
71373
+ return "unconfirmed";
71374
+ }
71375
+ if (!described) {
71376
+ console.warn(
71377
+ `[SecureChannel] ${label}: could not read the head on any of ${delays.length} tries (last: ${lastFailure}) and the server sent no refusal \u2014 cannot verify commit at epoch ${epoch}`
71378
+ );
71379
+ return "unverifiable";
71000
71380
  }
71381
+ console.warn(
71382
+ `[SecureChannel] ${label}: commit at epoch ${epoch} never appeared at the head (last seen ${lastSeen}) \u2014 treating as refused`
71383
+ );
71384
+ return "unconfirmed";
71001
71385
  }
71002
71386
  /**
71003
71387
  * #1015: write one frame and report whether the socket took it.
@@ -71519,7 +71903,11 @@ ${baseText}`;
71519
71903
  * Called on WS connect, on mls_delivery ping, and every 30s heartbeat.
71520
71904
  */
71521
71905
  async _pullDeliveryQueue() {
71522
- if (!this._deviceId || !this._deviceJwt || this._deliveryPulling) return;
71906
+ if (!this._deviceId || !this._deviceJwt) return;
71907
+ if (this._deliveryPulling) {
71908
+ this._deliveryPullRequested = true;
71909
+ return;
71910
+ }
71523
71911
  this._deliveryPulling = true;
71524
71912
  try {
71525
71913
  const res = await fetch(
@@ -72081,6 +72469,10 @@ ${baseText}`;
72081
72469
  console.warn("[SecureChannel] Delivery pull error:", err);
72082
72470
  } finally {
72083
72471
  this._deliveryPulling = false;
72472
+ if (this._deliveryPullRequested) {
72473
+ this._deliveryPullRequested = false;
72474
+ void this._pullDeliveryQueue().catch((err) => console.warn("[SecureChannel] Coalesced delivery pull error:", err));
72475
+ }
72084
72476
  }
72085
72477
  }
72086
72478
  /**
@@ -72164,7 +72556,7 @@ ${baseText}`;
72164
72556
  try {
72165
72557
  const parsed = JSON.parse(plaintext);
72166
72558
  messageType = parsed.type || "message";
72167
- messageText = parsed.text || plaintext;
72559
+ messageText = parsed.text ?? plaintext;
72168
72560
  } catch {
72169
72561
  messageType = "message";
72170
72562
  messageText = plaintext;
@@ -72513,7 +72905,7 @@ ${baseText}`;
72513
72905
  try {
72514
72906
  const parsed = JSON.parse(plaintext);
72515
72907
  messageType = parsed.type || "message";
72516
- messageText = parsed.text || plaintext;
72908
+ messageText = parsed.text ?? plaintext;
72517
72909
  } catch {
72518
72910
  messageType = "message";
72519
72911
  messageText = plaintext;
@@ -72798,7 +73190,7 @@ ${baseText}`;
72798
73190
  try {
72799
73191
  const parsed = JSON.parse(plaintext);
72800
73192
  messageType = parsed.type || "message";
72801
- messageText = parsed.text || plaintext;
73193
+ messageText = parsed.text ?? plaintext;
72802
73194
  } catch {
72803
73195
  messageType = "message";
72804
73196
  messageText = plaintext;
@@ -73398,58 +73790,6 @@ var init_gateway_send = __esm2({
73398
73790
  init_openclaw_compat();
73399
73791
  }
73400
73792
  });
73401
- function parseMentions(text) {
73402
- const mentions = [];
73403
- const re2 = new RegExp(MENTION_RE.source, MENTION_RE.flags);
73404
- let match;
73405
- while ((match = re2.exec(text)) !== null) {
73406
- mentions.push(match[1].toLowerCase());
73407
- }
73408
- return mentions;
73409
- }
73410
- function resolveOwn(mention, names) {
73411
- if (names.has(mention)) return mention;
73412
- const parts = mention.split("-");
73413
- for (let i2 = parts.length - 1; i2 > 0; i2--) {
73414
- const candidate = parts.slice(0, i2).join("-");
73415
- if (names.has(candidate)) return candidate;
73416
- }
73417
- return null;
73418
- }
73419
- function owesRoomReply(plaintext, agentName, accountId, senderIsAgent, roomDisplayName, ownDisplayName, selfResolved = true) {
73420
- const mentions = parseMentions(plaintext);
73421
- if (mentions.length === 0) return false;
73422
- const ownNames = ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName);
73423
- if (mentions.some((m2) => resolveOwn(m2, ownNames) !== null)) return true;
73424
- if (mentions.some((m2) => resolveOwn(m2, BROADCAST_NAMES) !== null)) {
73425
- return senderIsAgent !== true;
73426
- }
73427
- if (!selfResolved) return true;
73428
- return false;
73429
- }
73430
- function ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName) {
73431
- const names = /* @__PURE__ */ new Set();
73432
- const add4 = (n2) => {
73433
- if (!n2) return;
73434
- const lower = n2.toLowerCase();
73435
- names.add(lower);
73436
- names.add(lower.split(/[\s(]/)[0]);
73437
- };
73438
- add4(agentName);
73439
- add4(accountId);
73440
- add4(roomDisplayName);
73441
- add4(ownDisplayName);
73442
- return names;
73443
- }
73444
- var MENTION_RE;
73445
- var BROADCAST_NAMES;
73446
- var init_room_protocol = __esm2({
73447
- "src/room-protocol.ts"() {
73448
- "use strict";
73449
- MENTION_RE = /@([\w-]+)/gi;
73450
- BROADCAST_NAMES = /* @__PURE__ */ new Set(["all", "everyone"]);
73451
- }
73452
- });
73453
73793
  var init_llm_response_parser = __esm2({
73454
73794
  "src/llm-response-parser.ts"() {
73455
73795
  "use strict";
@@ -99543,7 +99883,7 @@ var init_index = __esm2({
99543
99883
  await init_skill_telemetry();
99544
99884
  await init_policy_enforcer();
99545
99885
  init_room_protocol();
99546
- VERSION = true ? "0.23.39" : "0.0.0-dev";
99886
+ VERSION = true ? "0.23.40" : "0.0.0-dev";
99547
99887
  }
99548
99888
  });
99549
99889
  await init_index();
@@ -100480,7 +100820,7 @@ async function main() {
100480
100820
  const info = parseInfoFlags(process.argv.slice(2));
100481
100821
  if (info.showVersion || info.showHelp) {
100482
100822
  const version2 = buildClientVersion2(
100483
- true ? "0.4.12" : void 0
100823
+ true ? "0.4.13" : void 0
100484
100824
  );
100485
100825
  if (info.showVersion) printLine(version2);
100486
100826
  if (info.showHelp) {
@@ -100533,7 +100873,7 @@ async function main() {
100533
100873
  agentName: cfg.agentName,
100534
100874
  platform: "node",
100535
100875
  clientVersion: buildClientVersion2(
100536
- true ? "0.4.12" : void 0
100876
+ true ? "0.4.13" : void 0
100537
100877
  )
100538
100878
  });
100539
100879
  channelRef = channel;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/grok-bridge",
3
- "version": "0.4.12",
3
+ "version": "0.4.13",
4
4
  "type": "module",
5
5
  "description": "AgentVault Grok Bridge — daemon bridging a Grok agent into E2E-encrypted AgentVault direct messages and rooms.",
6
6
  "main": "dist/index.js",