@agentvault/grok-bridge 0.4.11 → 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 +17 -0
- package/dist/index.js +516 -141
- package/package.json +1 -1
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.
|
|
64660
|
+
const v2 = true ? "0.23.40" : FALLBACK;
|
|
64609
64661
|
return `${PACKAGE}@${v2}`;
|
|
64610
64662
|
}
|
|
64611
64663
|
function buildSha() {
|
|
64612
|
-
const raw = true ? "
|
|
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();
|
|
@@ -65712,6 +65765,56 @@ var init_channel = __esm2({
|
|
|
65712
65765
|
* this process (GH #366). Cleared on (re)join so the next first message
|
|
65713
65766
|
* re-fetches the authoritative roster. Prevents per-message refetch storms. */
|
|
65714
65767
|
_roomRosterRefreshed = /* @__PURE__ */ new Set();
|
|
65768
|
+
/**
|
|
65769
|
+
* #1181: the last attachment saved per room, so the NEXT room turn can name
|
|
65770
|
+
* it. wren saved an un-mentioned file (no reply, #1107), was asked "@wren can
|
|
65771
|
+
* you read this file?" 14 s later, and answered "I don't see a file
|
|
65772
|
+
* attached" — the note lived only in the prior history line. Hermes defers
|
|
65773
|
+
* un-mentioned attachments and fetches on the next addressed turn (#1162);
|
|
65774
|
+
* this is the plugin's equivalent surface. In-memory: a restart forgets it,
|
|
65775
|
+
* which is the right default for "this file".
|
|
65776
|
+
*/
|
|
65777
|
+
_recentRoomAttachments = /* @__PURE__ */ new Map();
|
|
65778
|
+
static RECENT_ROOM_ATTACHMENT_MS = 10 * 60 * 1e3;
|
|
65779
|
+
/** #1181: prefix a room turn that carries no attachment with the one saved in this room recently. */
|
|
65780
|
+
_withRecentRoomAttachmentNote(roomId, text) {
|
|
65781
|
+
const recent = this._recentRoomAttachments.get(roomId);
|
|
65782
|
+
if (!recent) return text;
|
|
65783
|
+
const ageMs = Date.now() - recent.at;
|
|
65784
|
+
if (ageMs > _SecureChannel.RECENT_ROOM_ATTACHMENT_MS) return text;
|
|
65785
|
+
return `[Most recent file in this room: ${recent.filePath} (${recent.name}, received ${Math.round(ageMs / 1e3)}s ago). If this message refers to "the file" or "this file", it means that one \u2014 use your Read tool on it.]
|
|
65786
|
+
|
|
65787
|
+
${text}`;
|
|
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
|
+
}
|
|
65715
65818
|
/** In-flight roster-refresh promises (per room) — dedup concurrent refreshes. */
|
|
65716
65819
|
_roomRosterRefreshInFlight = /* @__PURE__ */ new Map();
|
|
65717
65820
|
/** Dedup buffer for A2A message IDs (prevents double-delivery via direct + Redis) */
|
|
@@ -66807,7 +66910,7 @@ var init_channel = __esm2({
|
|
|
66807
66910
|
*/
|
|
66808
66911
|
sendActivitySpan(spanData) {
|
|
66809
66912
|
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
|
|
66810
|
-
const pluginVersion = true ? "0.23.
|
|
66913
|
+
const pluginVersion = true ? "0.23.40" : "0.0.0-dev";
|
|
66811
66914
|
const agentName = this.config.agentName ?? "Agent";
|
|
66812
66915
|
const resource = {
|
|
66813
66916
|
"service.name": "agentvault-agent",
|
|
@@ -68745,7 +68848,7 @@ var init_channel = __esm2({
|
|
|
68745
68848
|
agentVersion: this.config.agentVersion ?? "0.0.0",
|
|
68746
68849
|
// __AV_VERSION__ is injected by esbuild from package.json at build time.
|
|
68747
68850
|
// Falls back to "0.0.0-dev" in non-bundled contexts (tests).
|
|
68748
|
-
pluginVersion: true ? "0.23.
|
|
68851
|
+
pluginVersion: true ? "0.23.40" : "0.0.0-dev"
|
|
68749
68852
|
});
|
|
68750
68853
|
this._telemetryReporter.startAutoFlush(3e4);
|
|
68751
68854
|
}
|
|
@@ -69069,7 +69172,7 @@ var init_channel = __esm2({
|
|
|
69069
69172
|
agentVersion: this.config.agentVersion ?? "0.0.0",
|
|
69070
69173
|
// __AV_VERSION__ is injected by esbuild from package.json at build time.
|
|
69071
69174
|
// Falls back to "0.0.0-dev" in non-bundled contexts (tests).
|
|
69072
|
-
pluginVersion: true ? "0.23.
|
|
69175
|
+
pluginVersion: true ? "0.23.40" : "0.0.0-dev"
|
|
69073
69176
|
});
|
|
69074
69177
|
this._telemetryReporter.startAutoFlush(3e4);
|
|
69075
69178
|
}
|
|
@@ -69383,6 +69486,7 @@ var init_channel = __esm2({
|
|
|
69383
69486
|
`[deliver] delivery_result=REJECTED group=${rejectedGroup || "unknown"} detail=${detail}`
|
|
69384
69487
|
);
|
|
69385
69488
|
console.error(`[SecureChannel] Server error: ${detail}`);
|
|
69489
|
+
this._noteCommitRefusal(data.data || data);
|
|
69386
69490
|
await this._handleServerError(data.data || data);
|
|
69387
69491
|
this.emit("error", new Error(`Server: ${detail}`));
|
|
69388
69492
|
}
|
|
@@ -69684,7 +69788,7 @@ var init_channel = __esm2({
|
|
|
69684
69788
|
try {
|
|
69685
69789
|
const parsed = JSON.parse(plaintext);
|
|
69686
69790
|
messageType = parsed.type || "message";
|
|
69687
|
-
messageText = parsed.text
|
|
69791
|
+
messageText = parsed.text ?? plaintext;
|
|
69688
69792
|
if (parsed.attachment) {
|
|
69689
69793
|
attachmentInfo = parsed.attachment;
|
|
69690
69794
|
}
|
|
@@ -69842,12 +69946,13 @@ ${messageText}`;
|
|
|
69842
69946
|
* Returns the text unchanged when there is no attachment or the fetch fails —
|
|
69843
69947
|
* but a failure is LOGGED and announced in the text, never silent.
|
|
69844
69948
|
*/
|
|
69845
|
-
async _augmentWithAttachment(attachmentInfo, baseText) {
|
|
69949
|
+
async _augmentWithAttachment(attachmentInfo, baseText, roomId) {
|
|
69846
69950
|
if (!attachmentInfo) return baseText;
|
|
69847
69951
|
try {
|
|
69848
69952
|
const { filePath, decrypted } = await this._downloadAndDecryptAttachment(attachmentInfo);
|
|
69849
69953
|
const mime = String(attachmentInfo.mime ?? "application/octet-stream");
|
|
69850
69954
|
const name = String(attachmentInfo.filename ?? "attachment");
|
|
69955
|
+
if (roomId) this._recentRoomAttachments.set(roomId, { filePath, name, at: Date.now() });
|
|
69851
69956
|
const textMimes = ["text/", "application/json", "application/xml", "application/csv"];
|
|
69852
69957
|
if (textMimes.some((m2) => mime.startsWith(m2))) {
|
|
69853
69958
|
const content = new TextDecoder().decode(decrypted);
|
|
@@ -70435,7 +70540,7 @@ ${baseText}`;
|
|
|
70435
70540
|
try {
|
|
70436
70541
|
const parsed = JSON.parse(rawPlaintext);
|
|
70437
70542
|
messageType = parsed.type || "message";
|
|
70438
|
-
messageText = parsed.text
|
|
70543
|
+
messageText = parsed.text ?? rawPlaintext;
|
|
70439
70544
|
if (parsed.attachment) roomAttachment = parsed.attachment;
|
|
70440
70545
|
} catch {
|
|
70441
70546
|
messageType = "message";
|
|
@@ -70476,8 +70581,19 @@ ${baseText}`;
|
|
|
70476
70581
|
await this._refreshRoomRoster(resolvedRoomId);
|
|
70477
70582
|
}
|
|
70478
70583
|
}
|
|
70479
|
-
|
|
70480
|
-
|
|
70584
|
+
const addressed = parseMentions(messageText).length > 0;
|
|
70585
|
+
if (roomAttachment && !addressed) {
|
|
70586
|
+
this._deferRoomAttachment(resolvedRoomId, roomAttachment);
|
|
70587
|
+
} else {
|
|
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
|
+
}
|
|
70481
70597
|
}
|
|
70482
70598
|
const roomMembers = this._persisted?.rooms?.[resolvedRoomId]?.members ?? [];
|
|
70483
70599
|
const senderMember = roomMembers.find((m2) => m2.deviceId === senderDeviceId);
|
|
@@ -70601,6 +70717,12 @@ ${baseText}`;
|
|
|
70601
70717
|
const groupId = data.group_id;
|
|
70602
70718
|
const epoch = typeof data.epoch === "number" ? data.epoch : 0;
|
|
70603
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
|
+
}
|
|
70604
70726
|
const target = this._resolveGroupFamily(groupId);
|
|
70605
70727
|
if (target) {
|
|
70606
70728
|
const { managerKey: groupKey, label } = target;
|
|
@@ -70843,73 +70965,146 @@ ${baseText}`;
|
|
|
70843
70965
|
return 0;
|
|
70844
70966
|
}
|
|
70845
70967
|
const snapshot = mlsGroup.exportState();
|
|
70846
|
-
|
|
70847
|
-
|
|
70848
|
-
let sentCommits = 0;
|
|
70849
|
-
for (const c2 of commits) {
|
|
70850
|
-
const ok = await this._sendFrameConfirmed(
|
|
70851
|
-
{
|
|
70852
|
-
event: "mls_commit",
|
|
70853
|
-
data: {
|
|
70854
|
-
group_id: mlsGroupId,
|
|
70855
|
-
// The epoch this commit PRODUCED. After two commits the manager
|
|
70856
|
-
// only knows the last one, which is why the result carries them.
|
|
70857
|
-
epoch: Number(c2.epoch),
|
|
70858
|
-
payload: Buffer.from(c2.commit).toString("hex"),
|
|
70859
|
-
...parent2 ? { parent_digest: parent2 } : {}
|
|
70860
|
-
}
|
|
70861
|
-
},
|
|
70862
|
-
`${label} commit ${sentCommits + 1}/${commits.length}`
|
|
70863
|
-
);
|
|
70864
|
-
if (!ok) break;
|
|
70865
|
-
parent2 = this._commitDigest(c2.commit);
|
|
70866
|
-
sentCommits++;
|
|
70867
|
-
}
|
|
70868
|
-
if (sentCommits === 0) {
|
|
70869
|
-
mlsGroup.importState(snapshot);
|
|
70968
|
+
const preHead = await this._readGroupHead(mlsGroupId);
|
|
70969
|
+
if (preHead.ok && preHead.epoch !== Number(mlsGroup.epoch)) {
|
|
70870
70970
|
console.warn(
|
|
70871
|
-
`[SecureChannel] ${label}:
|
|
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`
|
|
70872
70972
|
);
|
|
70873
70973
|
return 0;
|
|
70874
70974
|
}
|
|
70875
|
-
|
|
70876
|
-
|
|
70877
|
-
|
|
70878
|
-
|
|
70879
|
-
);
|
|
70880
|
-
|
|
70881
|
-
|
|
70882
|
-
|
|
70883
|
-
|
|
70884
|
-
|
|
70885
|
-
|
|
70886
|
-
|
|
70887
|
-
|
|
70888
|
-
|
|
70889
|
-
|
|
70890
|
-
|
|
70891
|
-
|
|
70892
|
-
|
|
70893
|
-
|
|
70894
|
-
|
|
70895
|
-
|
|
70896
|
-
|
|
70897
|
-
|
|
70898
|
-
|
|
70899
|
-
|
|
70900
|
-
|
|
70901
|
-
|
|
70902
|
-
|
|
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;
|
|
70903
71033
|
console.warn(
|
|
70904
|
-
`[SecureChannel] ${label}:
|
|
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`
|
|
70905
71035
|
);
|
|
70906
|
-
|
|
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;
|
|
71042
|
+
}
|
|
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
|
+
}
|
|
70907
71096
|
}
|
|
70908
|
-
|
|
70909
|
-
|
|
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
|
+
}
|
|
70910
71106
|
}
|
|
70911
71107
|
}
|
|
70912
|
-
return fulfilled;
|
|
70913
71108
|
}
|
|
70914
71109
|
/**
|
|
70915
71110
|
* Resolve an MLS group id to the family that owns it.
|
|
@@ -70956,24 +71151,237 @@ ${baseText}`;
|
|
|
70956
71151
|
/**
|
|
70957
71152
|
* #1177: the digest of the commit at the server's head, or undefined.
|
|
70958
71153
|
*
|
|
70959
|
-
* Undefined covers "no head yet"
|
|
70960
|
-
*
|
|
70961
|
-
*
|
|
70962
|
-
* so a read failure must degrade
|
|
70963
|
-
* rescue that cannot run is the
|
|
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`.
|
|
70964
71160
|
*/
|
|
70965
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) {
|
|
70966
71185
|
try {
|
|
70967
71186
|
const res = await fetch(`${this.config.apiUrl}/api/v1/mls/groups/${groupId}`, {
|
|
70968
|
-
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)
|
|
70969
71191
|
});
|
|
70970
|
-
if (!res.ok) return
|
|
71192
|
+
if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
|
|
70971
71193
|
const head = await res.json();
|
|
70972
|
-
|
|
70973
|
-
|
|
70974
|
-
|
|
70975
|
-
|
|
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;
|
|
70976
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";
|
|
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";
|
|
70977
71385
|
}
|
|
70978
71386
|
/**
|
|
70979
71387
|
* #1015: write one frame and report whether the socket took it.
|
|
@@ -71495,7 +71903,11 @@ ${baseText}`;
|
|
|
71495
71903
|
* Called on WS connect, on mls_delivery ping, and every 30s heartbeat.
|
|
71496
71904
|
*/
|
|
71497
71905
|
async _pullDeliveryQueue() {
|
|
71498
|
-
if (!this._deviceId || !this._deviceJwt
|
|
71906
|
+
if (!this._deviceId || !this._deviceJwt) return;
|
|
71907
|
+
if (this._deliveryPulling) {
|
|
71908
|
+
this._deliveryPullRequested = true;
|
|
71909
|
+
return;
|
|
71910
|
+
}
|
|
71499
71911
|
this._deliveryPulling = true;
|
|
71500
71912
|
try {
|
|
71501
71913
|
const res = await fetch(
|
|
@@ -72057,6 +72469,10 @@ ${baseText}`;
|
|
|
72057
72469
|
console.warn("[SecureChannel] Delivery pull error:", err);
|
|
72058
72470
|
} finally {
|
|
72059
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
|
+
}
|
|
72060
72476
|
}
|
|
72061
72477
|
}
|
|
72062
72478
|
/**
|
|
@@ -72140,7 +72556,7 @@ ${baseText}`;
|
|
|
72140
72556
|
try {
|
|
72141
72557
|
const parsed = JSON.parse(plaintext);
|
|
72142
72558
|
messageType = parsed.type || "message";
|
|
72143
|
-
messageText = parsed.text
|
|
72559
|
+
messageText = parsed.text ?? plaintext;
|
|
72144
72560
|
} catch {
|
|
72145
72561
|
messageType = "message";
|
|
72146
72562
|
messageText = plaintext;
|
|
@@ -72489,7 +72905,7 @@ ${baseText}`;
|
|
|
72489
72905
|
try {
|
|
72490
72906
|
const parsed = JSON.parse(plaintext);
|
|
72491
72907
|
messageType = parsed.type || "message";
|
|
72492
|
-
messageText = parsed.text
|
|
72908
|
+
messageText = parsed.text ?? plaintext;
|
|
72493
72909
|
} catch {
|
|
72494
72910
|
messageType = "message";
|
|
72495
72911
|
messageText = plaintext;
|
|
@@ -72774,7 +73190,7 @@ ${baseText}`;
|
|
|
72774
73190
|
try {
|
|
72775
73191
|
const parsed = JSON.parse(plaintext);
|
|
72776
73192
|
messageType = parsed.type || "message";
|
|
72777
|
-
messageText = parsed.text
|
|
73193
|
+
messageText = parsed.text ?? plaintext;
|
|
72778
73194
|
} catch {
|
|
72779
73195
|
messageType = "message";
|
|
72780
73196
|
messageText = plaintext;
|
|
@@ -73374,58 +73790,6 @@ var init_gateway_send = __esm2({
|
|
|
73374
73790
|
init_openclaw_compat();
|
|
73375
73791
|
}
|
|
73376
73792
|
});
|
|
73377
|
-
function parseMentions(text) {
|
|
73378
|
-
const mentions = [];
|
|
73379
|
-
const re2 = new RegExp(MENTION_RE.source, MENTION_RE.flags);
|
|
73380
|
-
let match;
|
|
73381
|
-
while ((match = re2.exec(text)) !== null) {
|
|
73382
|
-
mentions.push(match[1].toLowerCase());
|
|
73383
|
-
}
|
|
73384
|
-
return mentions;
|
|
73385
|
-
}
|
|
73386
|
-
function resolveOwn(mention, names) {
|
|
73387
|
-
if (names.has(mention)) return mention;
|
|
73388
|
-
const parts = mention.split("-");
|
|
73389
|
-
for (let i2 = parts.length - 1; i2 > 0; i2--) {
|
|
73390
|
-
const candidate = parts.slice(0, i2).join("-");
|
|
73391
|
-
if (names.has(candidate)) return candidate;
|
|
73392
|
-
}
|
|
73393
|
-
return null;
|
|
73394
|
-
}
|
|
73395
|
-
function owesRoomReply(plaintext, agentName, accountId, senderIsAgent, roomDisplayName, ownDisplayName, selfResolved = true) {
|
|
73396
|
-
const mentions = parseMentions(plaintext);
|
|
73397
|
-
if (mentions.length === 0) return false;
|
|
73398
|
-
const ownNames = ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName);
|
|
73399
|
-
if (mentions.some((m2) => resolveOwn(m2, ownNames) !== null)) return true;
|
|
73400
|
-
if (mentions.some((m2) => resolveOwn(m2, BROADCAST_NAMES) !== null)) {
|
|
73401
|
-
return senderIsAgent !== true;
|
|
73402
|
-
}
|
|
73403
|
-
if (!selfResolved) return true;
|
|
73404
|
-
return false;
|
|
73405
|
-
}
|
|
73406
|
-
function ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName) {
|
|
73407
|
-
const names = /* @__PURE__ */ new Set();
|
|
73408
|
-
const add4 = (n2) => {
|
|
73409
|
-
if (!n2) return;
|
|
73410
|
-
const lower = n2.toLowerCase();
|
|
73411
|
-
names.add(lower);
|
|
73412
|
-
names.add(lower.split(/[\s(]/)[0]);
|
|
73413
|
-
};
|
|
73414
|
-
add4(agentName);
|
|
73415
|
-
add4(accountId);
|
|
73416
|
-
add4(roomDisplayName);
|
|
73417
|
-
add4(ownDisplayName);
|
|
73418
|
-
return names;
|
|
73419
|
-
}
|
|
73420
|
-
var MENTION_RE;
|
|
73421
|
-
var BROADCAST_NAMES;
|
|
73422
|
-
var init_room_protocol = __esm2({
|
|
73423
|
-
"src/room-protocol.ts"() {
|
|
73424
|
-
"use strict";
|
|
73425
|
-
MENTION_RE = /@([\w-]+)/gi;
|
|
73426
|
-
BROADCAST_NAMES = /* @__PURE__ */ new Set(["all", "everyone"]);
|
|
73427
|
-
}
|
|
73428
|
-
});
|
|
73429
73793
|
var init_llm_response_parser = __esm2({
|
|
73430
73794
|
"src/llm-response-parser.ts"() {
|
|
73431
73795
|
"use strict";
|
|
@@ -99519,7 +99883,7 @@ var init_index = __esm2({
|
|
|
99519
99883
|
await init_skill_telemetry();
|
|
99520
99884
|
await init_policy_enforcer();
|
|
99521
99885
|
init_room_protocol();
|
|
99522
|
-
VERSION = true ? "0.23.
|
|
99886
|
+
VERSION = true ? "0.23.40" : "0.0.0-dev";
|
|
99523
99887
|
}
|
|
99524
99888
|
});
|
|
99525
99889
|
await init_index();
|
|
@@ -99570,7 +99934,12 @@ var GrokSession = class {
|
|
|
99570
99934
|
saidThisTurn = false;
|
|
99571
99935
|
drain = Promise.resolve();
|
|
99572
99936
|
push(text, reply, opts) {
|
|
99573
|
-
this.queue.push({
|
|
99937
|
+
this.queue.push({
|
|
99938
|
+
text,
|
|
99939
|
+
reply,
|
|
99940
|
+
autoReplyOnText: opts?.replyExpected ?? opts?.autoReplyOnText,
|
|
99941
|
+
sayAllowed: opts?.replyExpected ?? true
|
|
99942
|
+
});
|
|
99574
99943
|
if (!this.running) this.drain = this.run();
|
|
99575
99944
|
}
|
|
99576
99945
|
/** Hand this to startRoomMcpServer({ onSay }). The behaviour is stable for
|
|
@@ -99584,6 +99953,12 @@ var GrokSession = class {
|
|
|
99584
99953
|
this.log(`say arrived with no turn in flight; DROPPED: ${JSON.stringify(text.slice(0, 80))}`);
|
|
99585
99954
|
return;
|
|
99586
99955
|
}
|
|
99956
|
+
if (!this.current.sayAllowed) {
|
|
99957
|
+
this.log(
|
|
99958
|
+
`say during a turn this agent does not owe (no @mention) \u2014 SUPPRESSED: ${JSON.stringify(text.slice(0, 80))}`
|
|
99959
|
+
);
|
|
99960
|
+
return;
|
|
99961
|
+
}
|
|
99587
99962
|
this.saidThisTurn = true;
|
|
99588
99963
|
const sink = this.current.reply ?? this.opts.onSay;
|
|
99589
99964
|
if (!sink) {
|
|
@@ -100445,7 +100820,7 @@ async function main() {
|
|
|
100445
100820
|
const info = parseInfoFlags(process.argv.slice(2));
|
|
100446
100821
|
if (info.showVersion || info.showHelp) {
|
|
100447
100822
|
const version2 = buildClientVersion2(
|
|
100448
|
-
true ? "0.4.
|
|
100823
|
+
true ? "0.4.13" : void 0
|
|
100449
100824
|
);
|
|
100450
100825
|
if (info.showVersion) printLine(version2);
|
|
100451
100826
|
if (info.showHelp) {
|
|
@@ -100498,7 +100873,7 @@ async function main() {
|
|
|
100498
100873
|
agentName: cfg.agentName,
|
|
100499
100874
|
platform: "node",
|
|
100500
100875
|
clientVersion: buildClientVersion2(
|
|
100501
|
-
true ? "0.4.
|
|
100876
|
+
true ? "0.4.13" : void 0
|
|
100502
100877
|
)
|
|
100503
100878
|
});
|
|
100504
100879
|
channelRef = channel;
|
package/package.json
CHANGED