@securecoms/agent-bridge 0.26.1 → 0.26.2

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/bin.js CHANGED
@@ -28383,9 +28383,9 @@ async function recordInboundMemory(sink, channelId, senderId, text, messageId, m
28383
28383
  } catch {
28384
28384
  }
28385
28385
  }
28386
- async function recordOutboundMemory(sink, channelId, text, messageId) {
28386
+ async function recordOutboundMemory(sink, channelId, text, messageId, modality = "text") {
28387
28387
  try {
28388
- await sink.record({ conversationId: channelId, role: "agent", text, modality: "text", messageId });
28388
+ await sink.record({ conversationId: channelId, role: "agent", text, modality, messageId });
28389
28389
  } catch {
28390
28390
  }
28391
28391
  }
@@ -29110,6 +29110,15 @@ function logPendingCommitResolved(outcome) {
29110
29110
  writeServiceLog("info", "mls.pending_commit.resolved.unknown");
29111
29111
  }
29112
29112
  }
29113
+ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
29114
+ Object.freeze({ tool: "current_time", mode: "read" }),
29115
+ Object.freeze({ tool: "web_search", mode: "read" })
29116
+ ]);
29117
+ var NO_TOOL_GRANTS = Object.freeze([]);
29118
+ function ownerDmDefaultGrantsEnabled(env = process.env) {
29119
+ const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
29120
+ return v === void 0 || v === "on";
29121
+ }
29113
29122
  var VAULT_REF_BARE_TEXT = "[Files link]";
29114
29123
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
29115
29124
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
@@ -29121,6 +29130,20 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
29121
29130
  }
29122
29131
  return VOICE_MODE_GUIDANCE;
29123
29132
  }
29133
+ var VOICE_COALESCE_WINDOW_MS = 0;
29134
+ var VOICE_COALESCE_WINDOW_MAX_MS = 5e3;
29135
+ function voiceCoalesceWindowFromEnv(env = process.env) {
29136
+ const raw = env.SECURECOMS_VOICE_COALESCE_MS;
29137
+ if (raw === void 0 || !/^\d{1,6}$/.test(raw.trim())) return VOICE_COALESCE_WINDOW_MS;
29138
+ return Math.min(Number(raw.trim()), VOICE_COALESCE_WINDOW_MAX_MS);
29139
+ }
29140
+ function waitMs(ms) {
29141
+ if (ms <= 0) return Promise.resolve();
29142
+ return new Promise((resolve3) => {
29143
+ const t = setTimeout(resolve3, ms);
29144
+ t.unref?.();
29145
+ });
29146
+ }
29124
29147
  var DEFAULT_MAX_BUFFER = 1e3;
29125
29148
  var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
29126
29149
  var REDELIVERY_BACKOFF_BASE_MS = 5e3;
@@ -29405,6 +29428,7 @@ var BridgeAgent = class _BridgeAgent {
29405
29428
  this.fastLlm = deps.fastLlm ?? (cfg.voice?.fastPath.enabled ? new FastLlmClient(cfg.voice.fastPath, this.fetchImpl) : void 0);
29406
29429
  this.voiceSettingsSource = cfg.voice ? "file" : "none";
29407
29430
  this.latency = deps.latency ?? new LatencyRecorder();
29431
+ this.voiceCoalesceWindowMs = Math.max(0, deps.voiceCoalesceWindowMs ?? voiceCoalesceWindowFromEnv());
29408
29432
  this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
29409
29433
  this.maxDeliveryAttempts = deps.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS;
29410
29434
  this.maxAgentHops = deps.maxAgentHops ?? DEFAULT_MAX_AGENT_HOPS;
@@ -29653,6 +29677,19 @@ var BridgeAgent = class _BridgeAgent {
29653
29677
  /** Room send-floor tokens reserved by queued fast replies, per channel (F3). */
29654
29678
  fastReplyReserved = /* @__PURE__ */ new Map();
29655
29679
  static FAST_REPLY_MAX_JOBS = 32;
29680
+ /** Voice repeats (a): see {@link VOICE_COALESCE_WINDOW_MS}. */
29681
+ voiceCoalesceWindowMs;
29682
+ /** H1: fast-path reply ids being published right now (recorded as voice memory). */
29683
+ spokenReplyIds = /* @__PURE__ */ new Set();
29684
+ /** Open coalescing windows, keyed by channel + speaker. */
29685
+ voiceCoalescing = /* @__PURE__ */ new Map();
29686
+ /** Ids of every coalesced fragment (capped FIFO): a redelivered row is acked, never a second turn. */
29687
+ voiceCoalescedIds = /* @__PURE__ */ new Set();
29688
+ /** Merged turn id -> the absorbed fragments' held frames, retired with it. */
29689
+ voiceCoalescedTails = /* @__PURE__ */ new Map();
29690
+ /** (4) recv.lag: application queue ids already measured (capped FIFO), so a re-pulled row is not re-counted. */
29691
+ recvLagSeen = /* @__PURE__ */ new Set();
29692
+ static RECV_LAG_SEEN_CAP = 2e3;
29656
29693
  /** Fast-path turns handed to the runtime behind a spoken ack (LLM [[DEFER]], pre-check, or ack_only). */
29657
29694
  fastPathDeferred = 0;
29658
29695
  /** Of those, `full`-mode turns the deterministic pre-check deferred without calling the fast model. */
@@ -29746,6 +29783,8 @@ var BridgeAgent = class _BridgeAgent {
29746
29783
  * back to the delivery-row-driven Task 11 behaviour only.
29747
29784
  */
29748
29785
  selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
29786
+ /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
29787
+ ownerDmDefaultGrants = ownerDmDefaultGrantsEnabled();
29749
29788
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
29750
29789
  static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
29751
29790
  /**
@@ -31110,7 +31149,8 @@ var BridgeAgent = class _BridgeAgent {
31110
31149
  /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
31111
31150
  afterPublish(channelId, body, id, opts) {
31112
31151
  if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
31113
- if (body.kind === "text") void recordOutboundMemory(this.memory, channelId, body.text, id);
31152
+ const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
31153
+ if (body.kind === "text") void recordOutboundMemory(this.memory, channelId, body.text, id, modality);
31114
31154
  }
31115
31155
  isIdempotentSend(messageId, opts) {
31116
31156
  if (opts.inReplyTo === void 0 && opts.idempotencyKey === void 0) return false;
@@ -31288,26 +31328,26 @@ var BridgeAgent = class _BridgeAgent {
31288
31328
  } catch (e) {
31289
31329
  if (e instanceof ReplyInFlightError) {
31290
31330
  if (inFlight >= REPLY_IN_FLIGHT_RETRIES) throw e;
31291
- const waitMs2 = Math.min(
31331
+ const waitMs3 = Math.min(
31292
31332
  REPLY_RATE_LIMIT_MAX_WAIT_MS,
31293
31333
  Math.max(REPLY_IN_FLIGHT_BASE_WAIT_MS * 2 ** inFlight, e.retryAfterSec * 1e3)
31294
31334
  );
31295
31335
  inFlight++;
31296
- writeServiceLog("info", "reply.in_flight_retry", { attempt: inFlight, wait_ms: waitMs2 });
31297
- await this.replySleep(waitMs2);
31336
+ writeServiceLog("info", "reply.in_flight_retry", { attempt: inFlight, wait_ms: waitMs3 });
31337
+ await this.replySleep(waitMs3);
31298
31338
  continue;
31299
31339
  }
31300
31340
  const backendSec = backendRetryAfterSec(e);
31301
31341
  const wait = e instanceof RateLimitedError ? e.retryAfterMs : backendSec !== void 0 ? backendSec * 1e3 : null;
31302
31342
  if (wait === null) throw e;
31303
- const waitMs = Math.min(REPLY_RATE_LIMIT_MAX_WAIT_MS, Math.max(REPLY_RATE_LIMIT_MIN_WAIT_MS, wait));
31343
+ const waitMs2 = Math.min(REPLY_RATE_LIMIT_MAX_WAIT_MS, Math.max(REPLY_RATE_LIMIT_MIN_WAIT_MS, wait));
31304
31344
  if (rateLimited >= REPLY_RATE_LIMIT_RETRIES) {
31305
- throw e instanceof RateLimitedError ? e : new RateLimitedError(channelId, waitMs);
31345
+ throw e instanceof RateLimitedError ? e : new RateLimitedError(channelId, waitMs2);
31306
31346
  }
31307
31347
  rateLimited++;
31308
31348
  this.replyRateLimitRetries++;
31309
- writeServiceLog("info", "reply.rate_limited_retry", { attempt: rateLimited, wait_ms: waitMs });
31310
- await this.replySleep(waitMs);
31349
+ writeServiceLog("info", "reply.rate_limited_retry", { attempt: rateLimited, wait_ms: waitMs2 });
31350
+ await this.replySleep(waitMs2);
31311
31351
  }
31312
31352
  }
31313
31353
  }
@@ -31796,6 +31836,13 @@ var BridgeAgent = class _BridgeAgent {
31796
31836
  if (needsRuntime(text)) return { kind: "ack", line: deferAck(messageId), intent: true };
31797
31837
  return { kind: "fast" };
31798
31838
  }
31839
+ noteVoiceCoalesced(messageId) {
31840
+ this.voiceCoalescedIds.add(messageId);
31841
+ if (this.voiceCoalescedIds.size > _BridgeAgent.FAST_PATH_SEEN_CAP) {
31842
+ const oldest = this.voiceCoalescedIds.values().next().value;
31843
+ if (oldest !== void 0) this.voiceCoalescedIds.delete(oldest);
31844
+ }
31845
+ }
31799
31846
  markFastPathSeen(messageId) {
31800
31847
  this.fastPathSeen.add(messageId);
31801
31848
  if (this.fastPathSeen.size > _BridgeAgent.FAST_PATH_SEEN_CAP) {
@@ -31812,22 +31859,36 @@ var BridgeAgent = class _BridgeAgent {
31812
31859
  * reserve a room send-floor token until they finish. A job never throws.
31813
31860
  */
31814
31861
  enqueueFastReply(channelId, job, spends = true) {
31815
- if (spends) {
31816
- this.fastReplyJobs++;
31817
- this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
31818
- }
31862
+ const release = spends ? this.reserveFastReply(channelId) : void 0;
31819
31863
  const prev = this.fastReplyChains.get(channelId) ?? Promise.resolve();
31820
31864
  const next = prev.then(() => this.outsideIngestCycle(job)).catch((e) => writeServiceLog("warn", "voice.fast_reply_job_failed", {}, e)).finally(() => {
31821
- if (spends) {
31822
- this.fastReplyJobs--;
31823
- const left3 = (this.fastReplyReserved.get(channelId) ?? 1) - 1;
31824
- if (left3 > 0) this.fastReplyReserved.set(channelId, left3);
31825
- else this.fastReplyReserved.delete(channelId);
31826
- }
31865
+ release?.();
31827
31866
  if (this.fastReplyChains.get(channelId) === next) this.fastReplyChains.delete(channelId);
31828
31867
  });
31829
31868
  this.fastReplyChains.set(channelId, next);
31830
31869
  }
31870
+ /** Count a fast reply against FAST_REPLY_MAX_JOBS and reserve its room send-floor token; returns the release. */
31871
+ reserveFastReply(channelId) {
31872
+ this.fastReplyJobs++;
31873
+ this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
31874
+ return () => {
31875
+ this.fastReplyJobs--;
31876
+ const left3 = (this.fastReplyReserved.get(channelId) ?? 1) - 1;
31877
+ if (left3 > 0) this.fastReplyReserved.set(channelId, left3);
31878
+ else this.fastReplyReserved.delete(channelId);
31879
+ };
31880
+ }
31881
+ /**
31882
+ * Speak a planned fast turn ("ack" or "fast") and settle its held frame:
31883
+ * retired when the fast model answered alone, otherwise released to the
31884
+ * runtime (with the "ack already spoken" guidance only when an ack or a
31885
+ * deferring fast reply was actually published — F3).
31886
+ */
31887
+ async speakAndSettle(turn, plan, held, heldAfterAck, fastText, guidance, fastSpeaker, greeting) {
31888
+ const outcome = plan.kind === "ack" ? await this.sendFastLine(turn.channelId, turn.messageId, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
31889
+ if (outcome === "answered") await this.settleHeldRuntimeMessage(held, false);
31890
+ else await this.settleHeldRuntimeMessage(outcome === "deferred" ? heldAfterAck : held, true);
31891
+ }
31831
31892
  /**
31832
31893
  * Stage a runtime frame without overtaking a turn of the same channel that
31833
31894
  * is still queued behind the fast path (F2): while the channel has a queue,
@@ -31843,10 +31904,19 @@ var BridgeAgent = class _BridgeAgent {
31843
31904
  async settleFastReplies() {
31844
31905
  while (this.fastReplyChains.size) await Promise.all([...this.fastReplyChains.values()]);
31845
31906
  }
31907
+ /** Send a line the voice fast path speaks: its memory record is "voice" (H1). */
31908
+ async sendSpoken(channelId, text, messageId) {
31909
+ this.spokenReplyIds.add(messageId);
31910
+ try {
31911
+ await this.send(channelId, text, messageId);
31912
+ } finally {
31913
+ this.spokenReplyIds.delete(messageId);
31914
+ }
31915
+ }
31846
31916
  /** Speak a fixed acknowledgement line; failures are counted, never thrown. */
31847
31917
  async sendFastLine(channelId, messageId, line) {
31848
31918
  try {
31849
- await this.send(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
31919
+ await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
31850
31920
  this.fastPathSent++;
31851
31921
  return true;
31852
31922
  } catch (e) {
@@ -31903,7 +31973,7 @@ var BridgeAgent = class _BridgeAgent {
31903
31973
  const raw = await this.latency.time("voice.fast_llm", () => fastLlm.complete(messages));
31904
31974
  const { reply, defer } = splitFastReply(raw, 200, maxSentences);
31905
31975
  if (!reply) return "failed";
31906
- await this.send(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
31976
+ await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
31907
31977
  this.fastPathSent++;
31908
31978
  if (defer) this.fastPathDeferred++;
31909
31979
  return defer ? "deferred" : "answered";
@@ -31936,15 +32006,48 @@ var BridgeAgent = class _BridgeAgent {
31936
32006
  }
31937
32007
  }
31938
32008
  /** Settle a held frame: the fast path answered alone (retire it) or the runtime answers next (release it, in channel order). */
31939
- settleHeldRuntimeMessage(message, release) {
32009
+ async settleHeldRuntimeMessage(message, release) {
31940
32010
  if (!this.runtimePendingIds.has(message.message_id)) return;
31941
32011
  if (!release) {
31942
32012
  this.ackRuntimeMessages([message.message_id]);
31943
32013
  return;
31944
32014
  }
31945
- this.buffer.push(message);
32015
+ const memory = await this.memoryAtRelease(message);
32016
+ if (this.closingDown || !this.runtimePendingIds.has(message.message_id)) return;
32017
+ this.buffer.push(memory === message.memory ? message : { ...message, memory });
31946
32018
  this.wakeListeners();
31947
32019
  }
32020
+ /**
32021
+ * Voice repeats (b): a held frame's memory was read when its turn ARRIVED,
32022
+ * before the replies queued ahead of it (an earlier turn's fast reply, this
32023
+ * turn's own ack) were spoken. The runtime replaces its history with the
32024
+ * frame's memory, so with the arrival snapshot it answered an
32025
+ * already-answered turn again. At release the context is re-read and the
32026
+ * AGENT turns it holds beyond the snapshot (afterPublish ->
32027
+ * recordOutboundMemory) are appended to the snapshot. Peer turns recorded
32028
+ * since (this turn itself, later turns) are not, so the frame stays
32029
+ * prior-turn-only for what the runtime is about to answer. New turns are
32030
+ * found by recorded time, never by `seq` (the OpenClaw provider renumbers
32031
+ * seq per read). A failed re-read keeps the arrival snapshot.
32032
+ */
32033
+ async memoryAtRelease(message) {
32034
+ const prior = message.memory;
32035
+ if (!prior || !this.mayReadOpenClawMemoryForTurn(message.authorization)) return prior;
32036
+ let fresh;
32037
+ try {
32038
+ fresh = await this.memory.context(message.channel_id, _BridgeAgent.TURN_MEMORY_LIMIT);
32039
+ } catch {
32040
+ return prior;
32041
+ }
32042
+ const after = prior.recent.reduce((max, turn) => Math.max(max, turn.ts ?? 0), message.ts);
32043
+ const spoken = fresh.recent.filter((turn) => turn.role === "agent" && turn.ts !== void 0 && turn.ts > after);
32044
+ if (spoken.length === 0) return prior;
32045
+ return {
32046
+ recent: [...prior.recent, ...spoken].slice(-_BridgeAgent.TURN_MEMORY_LIMIT),
32047
+ facts: prior.facts
32048
+ };
32049
+ }
32050
+ static TURN_MEMORY_LIMIT = 50;
31948
32051
  /**
31949
32052
  * List channels in one enclave (default: the daemon's own home enclave).
31950
32053
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
@@ -32206,10 +32309,22 @@ var BridgeAgent = class _BridgeAgent {
32206
32309
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
32207
32310
  * guess. The returned object and grant array are frozen before they cross the
32208
32311
  * daemon boundary so one consumer cannot widen a later execution check.
32312
+ *
32313
+ * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32314
+ * the kill switch is on, the channel is a DM, and EITHER
32315
+ * (a) audience is owner_dm AND the sender is the pinned structural owner
32316
+ * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32317
+ * alone gets nothing; OR
32318
+ * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32319
+ * relayed `speaker_identity_id` (passed by the caller only for relay
32320
+ * turns) is that same pinned owner, whose roster row is the agent's
32321
+ * owner. A speaker claim from any non-relay sender gets nothing.
32322
+ * Everything else gets the frozen empty list.
32209
32323
  */
32210
- authorizationFor(channelId, senderId) {
32324
+ authorizationFor(channelId, senderId, relaySpeakerId) {
32211
32325
  const meta = this.freshMeta(channelId);
32212
- const sender = this.freshRoster(channelId)?.get(senderId);
32326
+ const roster = this.freshRoster(channelId);
32327
+ const sender = roster?.get(senderId);
32213
32328
  let audience = "unknown";
32214
32329
  if (meta && sender) {
32215
32330
  if (sender.is_agent) audience = "agent_to_agent";
@@ -32218,11 +32333,22 @@ var BridgeAgent = class _BridgeAgent {
32218
32333
  else if (sender.is_partner) audience = "partner_room";
32219
32334
  else audience = "team_room";
32220
32335
  }
32336
+ let tool_grants = NO_TOOL_GRANTS;
32337
+ if (this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
32338
+ const pinned = this.channelOwner.get(channelId);
32339
+ const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
32340
+ if (ownerId !== void 0) {
32341
+ const ownerDirect = audience === "owner_dm" && senderId === ownerId;
32342
+ const owner = roster?.get(ownerId);
32343
+ const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
32344
+ if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
32345
+ }
32346
+ }
32221
32347
  return Object.freeze({
32222
32348
  version: 1,
32223
32349
  audience,
32224
32350
  sender_membership: sender ? "active" : "unknown",
32225
- tool_grants: Object.freeze([])
32351
+ tool_grants
32226
32352
  });
32227
32353
  }
32228
32354
  /**
@@ -32730,11 +32856,36 @@ var BridgeAgent = class _BridgeAgent {
32730
32856
  this.ingestInFlight = entry;
32731
32857
  return promise;
32732
32858
  }
32859
+ /**
32860
+ * (4) recv.lag = server message created_at -> this pull's completion, per
32861
+ * application row, counted once per queue id. It makes the daemon-side
32862
+ * receive delay measurable (recv.pull alone times only the HTTP call).
32863
+ * Cross-clock: daemon-vs-server skew lands in the sample; a negative lag
32864
+ * is clamped to 0. The server serialises a naive UTC datetime (no offset),
32865
+ * which Date.parse would read as LOCAL time, so a bare timestamp is UTC.
32866
+ */
32867
+ recordRecvLag(rows) {
32868
+ const now = Date.now();
32869
+ for (const row of rows) {
32870
+ if (row.message_type !== "application" || typeof row.queue_id !== "string") continue;
32871
+ if (typeof row.created_at !== "string" || this.recvLagSeen.has(row.queue_id)) continue;
32872
+ const iso = /(Z|[+-]\d{2}:?\d{2})$/i.test(row.created_at) ? row.created_at : `${row.created_at}Z`;
32873
+ const createdAt = Date.parse(iso);
32874
+ if (!Number.isFinite(createdAt)) continue;
32875
+ this.recvLagSeen.add(row.queue_id);
32876
+ if (this.recvLagSeen.size > _BridgeAgent.RECV_LAG_SEEN_CAP) {
32877
+ const oldest = this.recvLagSeen.values().next().value;
32878
+ if (oldest !== void 0) this.recvLagSeen.delete(oldest);
32879
+ }
32880
+ this.latency.record("recv.lag", Math.max(0, now - createdAt));
32881
+ }
32882
+ }
32733
32883
  async ingestCycle() {
32734
32884
  if (this.closingDown) return;
32735
32885
  const cycleStart = performance.now();
32736
32886
  try {
32737
32887
  const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
32888
+ this.recordRecvLag(rows);
32738
32889
  const channelIds = /* @__PURE__ */ new Set();
32739
32890
  for (const row of rows) {
32740
32891
  if (row.connect_channel_id) channelIds.add(row.connect_channel_id);
@@ -32991,21 +33142,23 @@ var BridgeAgent = class _BridgeAgent {
32991
33142
  )?.name;
32992
33143
  const turnAuthorization = this.authorizationFor(
32993
33144
  m.channelId,
32994
- m.envelope.sender_id
33145
+ m.envelope.sender_id,
33146
+ speaker?.identityId
32995
33147
  );
32996
33148
  const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
32997
33149
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
32998
33150
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
32999
33151
  const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
33152
+ const turnText = parsed.text;
33153
+ const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
33154
+ const coalescible = this.voiceCoalesceWindowMs > 0 && m.voiceMode === true && speaker !== void 0 && parsed.transcribed === true && voiceGuidance !== void 0 && !isCallStart;
33000
33155
  let fastPathRoomBlocked = false;
33001
- if (fastPathEligible && meta && meta.is_dm === false) {
33156
+ if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
33002
33157
  const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
33003
33158
  if (this.rateFloor.available(`room:${m.channelId}`) - reserved < 2) fastPathRoomBlocked = true;
33004
33159
  }
33005
33160
  if (fastPathRoomBlocked) this.fastPathSkipped++;
33006
33161
  const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
33007
- const turnText = parsed.text;
33008
- const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
33009
33162
  const fastGreeting = isCallStart && fastPathFiring;
33010
33163
  const runtimeVoice = (ackSent) => m.voiceMode ? {
33011
33164
  voice: true,
@@ -33026,6 +33179,95 @@ var BridgeAgent = class _BridgeAgent {
33026
33179
  ...speaker ? { speaker } : {},
33027
33180
  ...runtimeVoice(ackSent)
33028
33181
  });
33182
+ if (coalescible) {
33183
+ if (this.voiceCoalescedIds.has(m.messageId) || this.runtimePendingIds.has(m.messageId)) {
33184
+ acked.push(m.queueId);
33185
+ continue;
33186
+ }
33187
+ const windowKey = `${m.channelId}\0${speaker.identityId}`;
33188
+ const open = this.voiceCoalescing.get(windowKey);
33189
+ if (open && !open.closed) {
33190
+ open.ids.push(m.messageId);
33191
+ open.parts.push(turnText);
33192
+ const staging = (async () => {
33193
+ const ok = await this.stageHeldRuntimeMessage(runtimeEntry(false, await runtimeMemory));
33194
+ if (!ok) {
33195
+ const i = open.ids.indexOf(m.messageId);
33196
+ if (i > 0) {
33197
+ open.ids.splice(i, 1);
33198
+ open.parts.splice(i, 1);
33199
+ }
33200
+ }
33201
+ return ok;
33202
+ })();
33203
+ open.pending = Promise.all([open.pending, staging]);
33204
+ if (!await staging) continue;
33205
+ this.noteVoiceCoalesced(m.messageId);
33206
+ acked.push(m.queueId);
33207
+ continue;
33208
+ }
33209
+ const held = runtimeEntry(false, await runtimeMemory);
33210
+ if (!await this.stageHeldRuntimeMessage(held)) continue;
33211
+ this.noteVoiceCoalesced(m.messageId);
33212
+ const win = {
33213
+ speakerId: speaker.identityId,
33214
+ ids: [m.messageId],
33215
+ parts: [turnText],
33216
+ closed: false,
33217
+ pending: Promise.resolve()
33218
+ };
33219
+ this.voiceCoalescing.set(windowKey, win);
33220
+ const openedAt = Date.now();
33221
+ const channelId = m.channelId;
33222
+ const senderId = m.envelope.sender_id;
33223
+ const isRoom = meta?.is_dm === false;
33224
+ this.enqueueFastReply(channelId, async () => {
33225
+ await waitMs(openedAt + this.voiceCoalesceWindowMs - Date.now());
33226
+ win.closed = true;
33227
+ if (this.voiceCoalescing.get(windowKey) === win) this.voiceCoalescing.delete(windowKey);
33228
+ await win.pending;
33229
+ if (this.closingDown) return;
33230
+ const [firstId, ...tailIds] = win.ids;
33231
+ if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
33232
+ const text = win.parts.join(" ");
33233
+ const merged = { ...held, text };
33234
+ void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice");
33235
+ let roomBlocked = false;
33236
+ if (fastPathEligible && isRoom) {
33237
+ const reserved = this.fastReplyReserved.get(channelId) ?? 0;
33238
+ if (this.rateFloor.available(`room:${channelId}`) - reserved < 2) roomBlocked = true;
33239
+ }
33240
+ if (roomBlocked) this.fastPathSkipped++;
33241
+ const plan = fastPathEligible && !roomBlocked && !this.pausedChannels.has(channelId) ? this.planFastPath(firstId, text, false) : void 0;
33242
+ if (!plan || plan.kind === "dup" || plan.kind === "skip") {
33243
+ if (plan?.kind === "skip" && plan.full) this.fastPathSkipped++;
33244
+ await this.settleHeldRuntimeMessage(merged, true);
33245
+ return;
33246
+ }
33247
+ this.markFastPathSeen(firstId);
33248
+ if (plan.kind === "ack") {
33249
+ this.fastPathDeferred++;
33250
+ if (plan.intent) this.fastPathIntentDeferred++;
33251
+ }
33252
+ const release = this.reserveFastReply(channelId);
33253
+ try {
33254
+ await this.speakAndSettle(
33255
+ { channelId, messageId: firstId, senderId, authorization: turnAuthorization },
33256
+ plan,
33257
+ merged,
33258
+ { ...merged, ...runtimeVoice(true) },
33259
+ text,
33260
+ voiceGuidance,
33261
+ speaker,
33262
+ false
33263
+ );
33264
+ } finally {
33265
+ release();
33266
+ }
33267
+ }, false);
33268
+ acked.push(m.queueId);
33269
+ continue;
33270
+ }
33029
33271
  if (fastPathFiring) {
33030
33272
  const fastSpeaker = speaker ?? (senderName ? { identityId: m.envelope.sender_id, displayName: senderName } : void 0);
33031
33273
  const fastText = fastGreeting ? greetingTurnFor(this.voiceSettings ?? DEFAULT_VOICE_SETTINGS, fastSpeaker?.displayName) : parsed.text;
@@ -33051,11 +33293,11 @@ var BridgeAgent = class _BridgeAgent {
33051
33293
  this.fastPathDeferred++;
33052
33294
  if (ack.intent) this.fastPathIntentDeferred++;
33053
33295
  }
33054
- this.enqueueFastReply(m.channelId, async () => {
33055
- const outcome = ack ? await this.sendFastLine(turn.channelId, turn.messageId, ack.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidanceForFast, fastSpeaker, greeting);
33056
- if (outcome === "answered") this.settleHeldRuntimeMessage(held, false);
33057
- else this.settleHeldRuntimeMessage(outcome === "deferred" ? { ...held, ...runtimeVoice(true) } : held, true);
33058
- });
33296
+ const heldAfterAck = { ...held, ...runtimeVoice(true) };
33297
+ this.enqueueFastReply(
33298
+ m.channelId,
33299
+ () => this.speakAndSettle(turn, ack ?? plan, held, heldAfterAck, fastText, guidanceForFast, fastSpeaker, greeting)
33300
+ );
33059
33301
  }
33060
33302
  } else {
33061
33303
  if (!await this.stageRuntimeMessageInOrder(runtimeEntry(false, await runtimeMemory))) continue;
@@ -33476,6 +33718,11 @@ var BridgeAgent = class _BridgeAgent {
33476
33718
  this.deliveryAttempts.delete(messageId);
33477
33719
  this.redeliverNotBefore.delete(messageId);
33478
33720
  this.supersedeRefundable.delete(messageId);
33721
+ const tails = this.voiceCoalescedTails.get(messageId);
33722
+ if (tails) {
33723
+ this.voiceCoalescedTails.delete(messageId);
33724
+ this.ackRuntimeMessages(tails);
33725
+ }
33479
33726
  }
33480
33727
  }
33481
33728
  async restoreRuntimeInbox() {
@@ -51,10 +51,32 @@ export type BridgeToolGrant = {
51
51
  };
52
52
  /**
53
53
  * Immutable authorization context derived from authenticated backend roster
54
- * data. The E2E plaintext never contributes to these claims. Tool grants are
55
- * intentionally empty until the channel-grant persistence/API lands; this
56
- * makes the new contract fail closed during staged deployment.
54
+ * data. The E2E plaintext never contributes to these claims, with ONE bounded
55
+ * exception: a relayed voice turn's `speaker_identity_id`, honoured only when
56
+ * the envelope's authenticated sender is the channel's server-marked relay.
57
+ *
58
+ * Tool grants (architect ruling, 2026-10-02): until channel-grant persistence
59
+ * lands, the ONLY non-empty grant list is OWNER_DM_DEFAULT_READ_GRANTS
60
+ * (`current_time` + `web_search`, read mode), and only on a DM whose sender is
61
+ * the pinned structural owner (or a relayed voice turn whose speaker is). Every
62
+ * other audience, an unpinned or anomalous owner, and the kill switch
63
+ * `SECURECOMS_OWNER_DM_DEFAULT_GRANTS` (anything but unset/`on`) get `[]`.
64
+ * SEC-06 is LIVE for these two tools: docs/security/sec-06-runtime-bounds-audit.md.
65
+ */
66
+ /**
67
+ * Architect ruling 2026-10-02: the default read grants an owner DM carries.
68
+ * A frozen constant in the daemon, NOT runtime config: a runtime cannot widen
69
+ * it, and the runtime's own tool allow-list still intersects it (a tool the
70
+ * runtime does not load is never offered). `fetch_url`, `echo`, `sql_query`
71
+ * and `http_request` are deliberately absent.
72
+ */
73
+ export declare const OWNER_DM_DEFAULT_READ_GRANTS: readonly BridgeToolGrant[];
74
+ /**
75
+ * Kill switch for OWNER_DM_DEFAULT_READ_GRANTS, read once at BridgeAgent
76
+ * construction (daemon startup). Unset or exactly `on` keeps the grants;
77
+ * any other value (`off`, `0`, `false`, empty) sends `[]` on every turn.
57
78
  */
79
+ export declare function ownerDmDefaultGrantsEnabled(env?: NodeJS.ProcessEnv): boolean;
58
80
  export interface BridgeAuthorization {
59
81
  readonly version: 1;
60
82
  readonly audience: AgentAudience;
@@ -272,6 +294,13 @@ export interface BridgeAgentDeps {
272
294
  fastLlm?: {
273
295
  complete(messages: ChatMessage[]): Promise<string>;
274
296
  };
297
+ /**
298
+ * Voice repeats (a): how long a relayed transcribed voice turn waits for
299
+ * the rest of a VAD-split utterance, measured from its first fragment
300
+ * (default: env `SECURECOMS_VOICE_COALESCE_MS`, else 0 = disabled).
301
+ * Injected so tests do not wait the full window.
302
+ */
303
+ voiceCoalesceWindowMs?: number;
275
304
  /**
276
305
  * B7 (FileSafe vault): persistence for the vault kekset's owner-pin TOFU
277
306
  * state. Defaults to a {@link FileOwnerAuthorityStore} at
@@ -280,6 +309,22 @@ export interface BridgeAgentDeps {
280
309
  */
281
310
  vaultOwnerStore?: OwnerAuthorityStore;
282
311
  }
312
+ /**
313
+ * Voice repeats (a), owner report 2026-10-02: one spoken question reached the
314
+ * bridge as two relayed turns ~2 s apart (the relay's VAD split it), which
315
+ * produced a fast reply to the fragment, an ack, and a runtime answer.
316
+ * Relayed transcribed voice turns from the same speaker on the same channel
317
+ * that arrive within this window of the FIRST fragment are joined into one
318
+ * turn before the fast path plans it. The cost is that every relayed voice
319
+ * turn waits this long before it is answered, so it is OFF by default (0):
320
+ * the observed ~2 s split would not fit a window short enough for live
321
+ * voice. Enable with env `SECURECOMS_VOICE_COALESCE_MS` (read at startup).
322
+ */
323
+ export declare const VOICE_COALESCE_WINDOW_MS = 0;
324
+ /** Upper bound for the window: a typo must not park every voice turn for minutes. */
325
+ export declare const VOICE_COALESCE_WINDOW_MAX_MS = 5000;
326
+ /** `SECURECOMS_VOICE_COALESCE_MS` as integer ms, clamped to VOICE_COALESCE_WINDOW_MAX_MS; anything else is 0 (disabled). */
327
+ export declare function voiceCoalesceWindowFromEnv(env?: NodeJS.ProcessEnv): number;
283
328
  /** Default inbound-buffer cap (messages) when deps.maxBuffer is unset. */
284
329
  export declare const DEFAULT_MAX_BUFFER = 1000;
285
330
  /**
@@ -738,6 +783,19 @@ export declare class BridgeAgent {
738
783
  /** Room send-floor tokens reserved by queued fast replies, per channel (F3). */
739
784
  private readonly fastReplyReserved;
740
785
  private static readonly FAST_REPLY_MAX_JOBS;
786
+ /** Voice repeats (a): see {@link VOICE_COALESCE_WINDOW_MS}. */
787
+ private readonly voiceCoalesceWindowMs;
788
+ /** H1: fast-path reply ids being published right now (recorded as voice memory). */
789
+ private readonly spokenReplyIds;
790
+ /** Open coalescing windows, keyed by channel + speaker. */
791
+ private readonly voiceCoalescing;
792
+ /** Ids of every coalesced fragment (capped FIFO): a redelivered row is acked, never a second turn. */
793
+ private readonly voiceCoalescedIds;
794
+ /** Merged turn id -> the absorbed fragments' held frames, retired with it. */
795
+ private readonly voiceCoalescedTails;
796
+ /** (4) recv.lag: application queue ids already measured (capped FIFO), so a re-pulled row is not re-counted. */
797
+ private readonly recvLagSeen;
798
+ private static readonly RECV_LAG_SEEN_CAP;
741
799
  /** Fast-path turns handed to the runtime behind a spoken ack (LLM [[DEFER]], pre-check, or ack_only). */
742
800
  private fastPathDeferred;
743
801
  /** Of those, `full`-mode turns the deterministic pre-check deferred without calling the fast model. */
@@ -830,6 +888,8 @@ export declare class BridgeAgent {
830
888
  * back to the delivery-row-driven Task 11 behaviour only.
831
889
  */
832
890
  private readonly selfHealSweepEnabled;
891
+ /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
892
+ private readonly ownerDmDefaultGrants;
833
893
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
834
894
  private static readonly SELF_HEAL_SWEEP_INTERVAL_MS;
835
895
  /**
@@ -1658,6 +1718,7 @@ export declare class BridgeAgent {
1658
1718
  * Owner report 2026-10-02 (calls never used tools) motivates "ack".
1659
1719
  */
1660
1720
  private planFastPath;
1721
+ private noteVoiceCoalesced;
1661
1722
  private markFastPathSeen;
1662
1723
  /**
1663
1724
  * Off-cycle per-channel queue: one promise chain per channel, so within a
@@ -1668,6 +1729,15 @@ export declare class BridgeAgent {
1668
1729
  * reserve a room send-floor token until they finish. A job never throws.
1669
1730
  */
1670
1731
  private enqueueFastReply;
1732
+ /** Count a fast reply against FAST_REPLY_MAX_JOBS and reserve its room send-floor token; returns the release. */
1733
+ private reserveFastReply;
1734
+ /**
1735
+ * Speak a planned fast turn ("ack" or "fast") and settle its held frame:
1736
+ * retired when the fast model answered alone, otherwise released to the
1737
+ * runtime (with the "ack already spoken" guidance only when an ack or a
1738
+ * deferring fast reply was actually published — F3).
1739
+ */
1740
+ private speakAndSettle;
1671
1741
  /**
1672
1742
  * Stage a runtime frame without overtaking a turn of the same channel that
1673
1743
  * is still queued behind the fast path (F2): while the channel has a queue,
@@ -1676,6 +1746,8 @@ export declare class BridgeAgent {
1676
1746
  private stageRuntimeMessageInOrder;
1677
1747
  /** Test seam: resolves once every queued fast reply has settled. */
1678
1748
  settleFastReplies(): Promise<void>;
1749
+ /** Send a line the voice fast path speaks: its memory record is "voice" (H1). */
1750
+ private sendSpoken;
1679
1751
  /** Speak a fixed acknowledgement line; failures are counted, never thrown. */
1680
1752
  private sendFastLine;
1681
1753
  /**
@@ -1705,6 +1777,21 @@ export declare class BridgeAgent {
1705
1777
  private stageHeldRuntimeMessage;
1706
1778
  /** Settle a held frame: the fast path answered alone (retire it) or the runtime answers next (release it, in channel order). */
1707
1779
  private settleHeldRuntimeMessage;
1780
+ /**
1781
+ * Voice repeats (b): a held frame's memory was read when its turn ARRIVED,
1782
+ * before the replies queued ahead of it (an earlier turn's fast reply, this
1783
+ * turn's own ack) were spoken. The runtime replaces its history with the
1784
+ * frame's memory, so with the arrival snapshot it answered an
1785
+ * already-answered turn again. At release the context is re-read and the
1786
+ * AGENT turns it holds beyond the snapshot (afterPublish ->
1787
+ * recordOutboundMemory) are appended to the snapshot. Peer turns recorded
1788
+ * since (this turn itself, later turns) are not, so the frame stays
1789
+ * prior-turn-only for what the runtime is about to answer. New turns are
1790
+ * found by recorded time, never by `seq` (the OpenClaw provider renumbers
1791
+ * seq per read). A failed re-read keeps the arrival snapshot.
1792
+ */
1793
+ private memoryAtRelease;
1794
+ private static readonly TURN_MEMORY_LIMIT;
1708
1795
  /**
1709
1796
  * List channels in one enclave (default: the daemon's own home enclave).
1710
1797
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
@@ -1867,6 +1954,17 @@ export declare class BridgeAgent {
1867
1954
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
1868
1955
  * guess. The returned object and grant array are frozen before they cross the
1869
1956
  * daemon boundary so one consumer cannot widen a later execution check.
1957
+ *
1958
+ * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
1959
+ * the kill switch is on, the channel is a DM, and EITHER
1960
+ * (a) audience is owner_dm AND the sender is the pinned structural owner
1961
+ * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
1962
+ * alone gets nothing; OR
1963
+ * (b) the sender is this channel's server-marked relay (`is_relay`) and the
1964
+ * relayed `speaker_identity_id` (passed by the caller only for relay
1965
+ * turns) is that same pinned owner, whose roster row is the agent's
1966
+ * owner. A speaker claim from any non-relay sender gets nothing.
1967
+ * Everything else gets the frozen empty list.
1870
1968
  */
1871
1969
  private authorizationFor;
1872
1970
  /**
@@ -2015,6 +2113,15 @@ export declare class BridgeAgent {
2015
2113
  */
2016
2114
  private ingestOnce;
2017
2115
  private startIngestCycle;
2116
+ /**
2117
+ * (4) recv.lag = server message created_at -> this pull's completion, per
2118
+ * application row, counted once per queue id. It makes the daemon-side
2119
+ * receive delay measurable (recv.pull alone times only the HTTP call).
2120
+ * Cross-clock: daemon-vs-server skew lands in the sample; a negative lag
2121
+ * is clamped to 0. The server serialises a naive UTC datetime (no offset),
2122
+ * which Date.parse would read as LOCAL time, so a bare timestamp is UTC.
2123
+ */
2124
+ private recordRecvLag;
2018
2125
  private ingestCycle;
2019
2126
  drain(channelId: string | undefined, waitSeconds: number): Promise<BridgeMessage[]>;
2020
2127
  /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
package/dist/index.js CHANGED
@@ -27971,9 +27971,9 @@ async function recordInboundMemory(sink, channelId, senderId, text, messageId, m
27971
27971
  } catch {
27972
27972
  }
27973
27973
  }
27974
- async function recordOutboundMemory(sink, channelId, text, messageId) {
27974
+ async function recordOutboundMemory(sink, channelId, text, messageId, modality = "text") {
27975
27975
  try {
27976
- await sink.record({ conversationId: channelId, role: "agent", text, modality: "text", messageId });
27976
+ await sink.record({ conversationId: channelId, role: "agent", text, modality, messageId });
27977
27977
  } catch {
27978
27978
  }
27979
27979
  }
@@ -28958,6 +28958,15 @@ function logPendingCommitResolved(outcome) {
28958
28958
  writeServiceLog2("info", "mls.pending_commit.resolved.unknown");
28959
28959
  }
28960
28960
  }
28961
+ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
28962
+ Object.freeze({ tool: "current_time", mode: "read" }),
28963
+ Object.freeze({ tool: "web_search", mode: "read" })
28964
+ ]);
28965
+ var NO_TOOL_GRANTS = Object.freeze([]);
28966
+ function ownerDmDefaultGrantsEnabled(env = process.env) {
28967
+ const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
28968
+ return v === void 0 || v === "on";
28969
+ }
28961
28970
  var VAULT_REF_BARE_TEXT = "[Files link]";
28962
28971
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
28963
28972
  var FAST_PATH_SPOKEN_GUIDANCE = "A one-sentence spoken acknowledgement has already been sent for this turn. Reply only with information it did not contain, in one or two short sentences; if there is nothing to add, send nothing.";
@@ -28969,6 +28978,20 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
28969
28978
  }
28970
28979
  return VOICE_MODE_GUIDANCE;
28971
28980
  }
28981
+ var VOICE_COALESCE_WINDOW_MS = 0;
28982
+ var VOICE_COALESCE_WINDOW_MAX_MS = 5e3;
28983
+ function voiceCoalesceWindowFromEnv(env = process.env) {
28984
+ const raw = env.SECURECOMS_VOICE_COALESCE_MS;
28985
+ if (raw === void 0 || !/^\d{1,6}$/.test(raw.trim())) return VOICE_COALESCE_WINDOW_MS;
28986
+ return Math.min(Number(raw.trim()), VOICE_COALESCE_WINDOW_MAX_MS);
28987
+ }
28988
+ function waitMs(ms) {
28989
+ if (ms <= 0) return Promise.resolve();
28990
+ return new Promise((resolve) => {
28991
+ const t = setTimeout(resolve, ms);
28992
+ t.unref?.();
28993
+ });
28994
+ }
28972
28995
  var DEFAULT_MAX_BUFFER = 1e3;
28973
28996
  var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
28974
28997
  var REDELIVERY_BACKOFF_BASE_MS = 5e3;
@@ -29253,6 +29276,7 @@ var BridgeAgent = class _BridgeAgent {
29253
29276
  this.fastLlm = deps.fastLlm ?? (cfg.voice?.fastPath.enabled ? new FastLlmClient(cfg.voice.fastPath, this.fetchImpl) : void 0);
29254
29277
  this.voiceSettingsSource = cfg.voice ? "file" : "none";
29255
29278
  this.latency = deps.latency ?? new LatencyRecorder();
29279
+ this.voiceCoalesceWindowMs = Math.max(0, deps.voiceCoalesceWindowMs ?? voiceCoalesceWindowFromEnv());
29256
29280
  this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
29257
29281
  this.maxDeliveryAttempts = deps.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS;
29258
29282
  this.maxAgentHops = deps.maxAgentHops ?? DEFAULT_MAX_AGENT_HOPS;
@@ -29501,6 +29525,19 @@ var BridgeAgent = class _BridgeAgent {
29501
29525
  /** Room send-floor tokens reserved by queued fast replies, per channel (F3). */
29502
29526
  fastReplyReserved = /* @__PURE__ */ new Map();
29503
29527
  static FAST_REPLY_MAX_JOBS = 32;
29528
+ /** Voice repeats (a): see {@link VOICE_COALESCE_WINDOW_MS}. */
29529
+ voiceCoalesceWindowMs;
29530
+ /** H1: fast-path reply ids being published right now (recorded as voice memory). */
29531
+ spokenReplyIds = /* @__PURE__ */ new Set();
29532
+ /** Open coalescing windows, keyed by channel + speaker. */
29533
+ voiceCoalescing = /* @__PURE__ */ new Map();
29534
+ /** Ids of every coalesced fragment (capped FIFO): a redelivered row is acked, never a second turn. */
29535
+ voiceCoalescedIds = /* @__PURE__ */ new Set();
29536
+ /** Merged turn id -> the absorbed fragments' held frames, retired with it. */
29537
+ voiceCoalescedTails = /* @__PURE__ */ new Map();
29538
+ /** (4) recv.lag: application queue ids already measured (capped FIFO), so a re-pulled row is not re-counted. */
29539
+ recvLagSeen = /* @__PURE__ */ new Set();
29540
+ static RECV_LAG_SEEN_CAP = 2e3;
29504
29541
  /** Fast-path turns handed to the runtime behind a spoken ack (LLM [[DEFER]], pre-check, or ack_only). */
29505
29542
  fastPathDeferred = 0;
29506
29543
  /** Of those, `full`-mode turns the deterministic pre-check deferred without calling the fast model. */
@@ -29594,6 +29631,8 @@ var BridgeAgent = class _BridgeAgent {
29594
29631
  * back to the delivery-row-driven Task 11 behaviour only.
29595
29632
  */
29596
29633
  selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
29634
+ /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
29635
+ ownerDmDefaultGrants = ownerDmDefaultGrantsEnabled();
29597
29636
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
29598
29637
  static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
29599
29638
  /**
@@ -30958,7 +30997,8 @@ var BridgeAgent = class _BridgeAgent {
30958
30997
  /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
30959
30998
  afterPublish(channelId, body, id, opts) {
30960
30999
  if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
30961
- if (body.kind === "text") void recordOutboundMemory(this.memory, channelId, body.text, id);
31000
+ const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
31001
+ if (body.kind === "text") void recordOutboundMemory(this.memory, channelId, body.text, id, modality);
30962
31002
  }
30963
31003
  isIdempotentSend(messageId, opts) {
30964
31004
  if (opts.inReplyTo === void 0 && opts.idempotencyKey === void 0) return false;
@@ -31136,26 +31176,26 @@ var BridgeAgent = class _BridgeAgent {
31136
31176
  } catch (e) {
31137
31177
  if (e instanceof ReplyInFlightError) {
31138
31178
  if (inFlight >= REPLY_IN_FLIGHT_RETRIES) throw e;
31139
- const waitMs2 = Math.min(
31179
+ const waitMs3 = Math.min(
31140
31180
  REPLY_RATE_LIMIT_MAX_WAIT_MS,
31141
31181
  Math.max(REPLY_IN_FLIGHT_BASE_WAIT_MS * 2 ** inFlight, e.retryAfterSec * 1e3)
31142
31182
  );
31143
31183
  inFlight++;
31144
- writeServiceLog2("info", "reply.in_flight_retry", { attempt: inFlight, wait_ms: waitMs2 });
31145
- await this.replySleep(waitMs2);
31184
+ writeServiceLog2("info", "reply.in_flight_retry", { attempt: inFlight, wait_ms: waitMs3 });
31185
+ await this.replySleep(waitMs3);
31146
31186
  continue;
31147
31187
  }
31148
31188
  const backendSec = backendRetryAfterSec(e);
31149
31189
  const wait = e instanceof RateLimitedError ? e.retryAfterMs : backendSec !== void 0 ? backendSec * 1e3 : null;
31150
31190
  if (wait === null) throw e;
31151
- const waitMs = Math.min(REPLY_RATE_LIMIT_MAX_WAIT_MS, Math.max(REPLY_RATE_LIMIT_MIN_WAIT_MS, wait));
31191
+ const waitMs2 = Math.min(REPLY_RATE_LIMIT_MAX_WAIT_MS, Math.max(REPLY_RATE_LIMIT_MIN_WAIT_MS, wait));
31152
31192
  if (rateLimited >= REPLY_RATE_LIMIT_RETRIES) {
31153
- throw e instanceof RateLimitedError ? e : new RateLimitedError(channelId, waitMs);
31193
+ throw e instanceof RateLimitedError ? e : new RateLimitedError(channelId, waitMs2);
31154
31194
  }
31155
31195
  rateLimited++;
31156
31196
  this.replyRateLimitRetries++;
31157
- writeServiceLog2("info", "reply.rate_limited_retry", { attempt: rateLimited, wait_ms: waitMs });
31158
- await this.replySleep(waitMs);
31197
+ writeServiceLog2("info", "reply.rate_limited_retry", { attempt: rateLimited, wait_ms: waitMs2 });
31198
+ await this.replySleep(waitMs2);
31159
31199
  }
31160
31200
  }
31161
31201
  }
@@ -31644,6 +31684,13 @@ var BridgeAgent = class _BridgeAgent {
31644
31684
  if (needsRuntime(text)) return { kind: "ack", line: deferAck(messageId), intent: true };
31645
31685
  return { kind: "fast" };
31646
31686
  }
31687
+ noteVoiceCoalesced(messageId) {
31688
+ this.voiceCoalescedIds.add(messageId);
31689
+ if (this.voiceCoalescedIds.size > _BridgeAgent.FAST_PATH_SEEN_CAP) {
31690
+ const oldest = this.voiceCoalescedIds.values().next().value;
31691
+ if (oldest !== void 0) this.voiceCoalescedIds.delete(oldest);
31692
+ }
31693
+ }
31647
31694
  markFastPathSeen(messageId) {
31648
31695
  this.fastPathSeen.add(messageId);
31649
31696
  if (this.fastPathSeen.size > _BridgeAgent.FAST_PATH_SEEN_CAP) {
@@ -31660,22 +31707,36 @@ var BridgeAgent = class _BridgeAgent {
31660
31707
  * reserve a room send-floor token until they finish. A job never throws.
31661
31708
  */
31662
31709
  enqueueFastReply(channelId, job, spends = true) {
31663
- if (spends) {
31664
- this.fastReplyJobs++;
31665
- this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
31666
- }
31710
+ const release = spends ? this.reserveFastReply(channelId) : void 0;
31667
31711
  const prev = this.fastReplyChains.get(channelId) ?? Promise.resolve();
31668
31712
  const next = prev.then(() => this.outsideIngestCycle(job)).catch((e) => writeServiceLog2("warn", "voice.fast_reply_job_failed", {}, e)).finally(() => {
31669
- if (spends) {
31670
- this.fastReplyJobs--;
31671
- const left3 = (this.fastReplyReserved.get(channelId) ?? 1) - 1;
31672
- if (left3 > 0) this.fastReplyReserved.set(channelId, left3);
31673
- else this.fastReplyReserved.delete(channelId);
31674
- }
31713
+ release?.();
31675
31714
  if (this.fastReplyChains.get(channelId) === next) this.fastReplyChains.delete(channelId);
31676
31715
  });
31677
31716
  this.fastReplyChains.set(channelId, next);
31678
31717
  }
31718
+ /** Count a fast reply against FAST_REPLY_MAX_JOBS and reserve its room send-floor token; returns the release. */
31719
+ reserveFastReply(channelId) {
31720
+ this.fastReplyJobs++;
31721
+ this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
31722
+ return () => {
31723
+ this.fastReplyJobs--;
31724
+ const left3 = (this.fastReplyReserved.get(channelId) ?? 1) - 1;
31725
+ if (left3 > 0) this.fastReplyReserved.set(channelId, left3);
31726
+ else this.fastReplyReserved.delete(channelId);
31727
+ };
31728
+ }
31729
+ /**
31730
+ * Speak a planned fast turn ("ack" or "fast") and settle its held frame:
31731
+ * retired when the fast model answered alone, otherwise released to the
31732
+ * runtime (with the "ack already spoken" guidance only when an ack or a
31733
+ * deferring fast reply was actually published — F3).
31734
+ */
31735
+ async speakAndSettle(turn, plan, held, heldAfterAck, fastText, guidance, fastSpeaker, greeting) {
31736
+ const outcome = plan.kind === "ack" ? await this.sendFastLine(turn.channelId, turn.messageId, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
31737
+ if (outcome === "answered") await this.settleHeldRuntimeMessage(held, false);
31738
+ else await this.settleHeldRuntimeMessage(outcome === "deferred" ? heldAfterAck : held, true);
31739
+ }
31679
31740
  /**
31680
31741
  * Stage a runtime frame without overtaking a turn of the same channel that
31681
31742
  * is still queued behind the fast path (F2): while the channel has a queue,
@@ -31691,10 +31752,19 @@ var BridgeAgent = class _BridgeAgent {
31691
31752
  async settleFastReplies() {
31692
31753
  while (this.fastReplyChains.size) await Promise.all([...this.fastReplyChains.values()]);
31693
31754
  }
31755
+ /** Send a line the voice fast path speaks: its memory record is "voice" (H1). */
31756
+ async sendSpoken(channelId, text, messageId) {
31757
+ this.spokenReplyIds.add(messageId);
31758
+ try {
31759
+ await this.send(channelId, text, messageId);
31760
+ } finally {
31761
+ this.spokenReplyIds.delete(messageId);
31762
+ }
31763
+ }
31694
31764
  /** Speak a fixed acknowledgement line; failures are counted, never thrown. */
31695
31765
  async sendFastLine(channelId, messageId, line) {
31696
31766
  try {
31697
- await this.send(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
31767
+ await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
31698
31768
  this.fastPathSent++;
31699
31769
  return true;
31700
31770
  } catch (e) {
@@ -31751,7 +31821,7 @@ var BridgeAgent = class _BridgeAgent {
31751
31821
  const raw = await this.latency.time("voice.fast_llm", () => fastLlm.complete(messages));
31752
31822
  const { reply, defer } = splitFastReply(raw, 200, maxSentences);
31753
31823
  if (!reply) return "failed";
31754
- await this.send(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
31824
+ await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
31755
31825
  this.fastPathSent++;
31756
31826
  if (defer) this.fastPathDeferred++;
31757
31827
  return defer ? "deferred" : "answered";
@@ -31784,15 +31854,48 @@ var BridgeAgent = class _BridgeAgent {
31784
31854
  }
31785
31855
  }
31786
31856
  /** Settle a held frame: the fast path answered alone (retire it) or the runtime answers next (release it, in channel order). */
31787
- settleHeldRuntimeMessage(message, release) {
31857
+ async settleHeldRuntimeMessage(message, release) {
31788
31858
  if (!this.runtimePendingIds.has(message.message_id)) return;
31789
31859
  if (!release) {
31790
31860
  this.ackRuntimeMessages([message.message_id]);
31791
31861
  return;
31792
31862
  }
31793
- this.buffer.push(message);
31863
+ const memory = await this.memoryAtRelease(message);
31864
+ if (this.closingDown || !this.runtimePendingIds.has(message.message_id)) return;
31865
+ this.buffer.push(memory === message.memory ? message : { ...message, memory });
31794
31866
  this.wakeListeners();
31795
31867
  }
31868
+ /**
31869
+ * Voice repeats (b): a held frame's memory was read when its turn ARRIVED,
31870
+ * before the replies queued ahead of it (an earlier turn's fast reply, this
31871
+ * turn's own ack) were spoken. The runtime replaces its history with the
31872
+ * frame's memory, so with the arrival snapshot it answered an
31873
+ * already-answered turn again. At release the context is re-read and the
31874
+ * AGENT turns it holds beyond the snapshot (afterPublish ->
31875
+ * recordOutboundMemory) are appended to the snapshot. Peer turns recorded
31876
+ * since (this turn itself, later turns) are not, so the frame stays
31877
+ * prior-turn-only for what the runtime is about to answer. New turns are
31878
+ * found by recorded time, never by `seq` (the OpenClaw provider renumbers
31879
+ * seq per read). A failed re-read keeps the arrival snapshot.
31880
+ */
31881
+ async memoryAtRelease(message) {
31882
+ const prior = message.memory;
31883
+ if (!prior || !this.mayReadOpenClawMemoryForTurn(message.authorization)) return prior;
31884
+ let fresh;
31885
+ try {
31886
+ fresh = await this.memory.context(message.channel_id, _BridgeAgent.TURN_MEMORY_LIMIT);
31887
+ } catch {
31888
+ return prior;
31889
+ }
31890
+ const after = prior.recent.reduce((max, turn) => Math.max(max, turn.ts ?? 0), message.ts);
31891
+ const spoken = fresh.recent.filter((turn) => turn.role === "agent" && turn.ts !== void 0 && turn.ts > after);
31892
+ if (spoken.length === 0) return prior;
31893
+ return {
31894
+ recent: [...prior.recent, ...spoken].slice(-_BridgeAgent.TURN_MEMORY_LIMIT),
31895
+ facts: prior.facts
31896
+ };
31897
+ }
31898
+ static TURN_MEMORY_LIMIT = 50;
31796
31899
  /**
31797
31900
  * List channels in one enclave (default: the daemon's own home enclave).
31798
31901
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
@@ -32054,10 +32157,22 @@ var BridgeAgent = class _BridgeAgent {
32054
32157
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
32055
32158
  * guess. The returned object and grant array are frozen before they cross the
32056
32159
  * daemon boundary so one consumer cannot widen a later execution check.
32160
+ *
32161
+ * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32162
+ * the kill switch is on, the channel is a DM, and EITHER
32163
+ * (a) audience is owner_dm AND the sender is the pinned structural owner
32164
+ * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32165
+ * alone gets nothing; OR
32166
+ * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32167
+ * relayed `speaker_identity_id` (passed by the caller only for relay
32168
+ * turns) is that same pinned owner, whose roster row is the agent's
32169
+ * owner. A speaker claim from any non-relay sender gets nothing.
32170
+ * Everything else gets the frozen empty list.
32057
32171
  */
32058
- authorizationFor(channelId, senderId) {
32172
+ authorizationFor(channelId, senderId, relaySpeakerId) {
32059
32173
  const meta = this.freshMeta(channelId);
32060
- const sender = this.freshRoster(channelId)?.get(senderId);
32174
+ const roster = this.freshRoster(channelId);
32175
+ const sender = roster?.get(senderId);
32061
32176
  let audience = "unknown";
32062
32177
  if (meta && sender) {
32063
32178
  if (sender.is_agent) audience = "agent_to_agent";
@@ -32066,11 +32181,22 @@ var BridgeAgent = class _BridgeAgent {
32066
32181
  else if (sender.is_partner) audience = "partner_room";
32067
32182
  else audience = "team_room";
32068
32183
  }
32184
+ let tool_grants = NO_TOOL_GRANTS;
32185
+ if (this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
32186
+ const pinned = this.channelOwner.get(channelId);
32187
+ const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
32188
+ if (ownerId !== void 0) {
32189
+ const ownerDirect = audience === "owner_dm" && senderId === ownerId;
32190
+ const owner = roster?.get(ownerId);
32191
+ const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
32192
+ if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
32193
+ }
32194
+ }
32069
32195
  return Object.freeze({
32070
32196
  version: 1,
32071
32197
  audience,
32072
32198
  sender_membership: sender ? "active" : "unknown",
32073
- tool_grants: Object.freeze([])
32199
+ tool_grants
32074
32200
  });
32075
32201
  }
32076
32202
  /**
@@ -32578,11 +32704,36 @@ var BridgeAgent = class _BridgeAgent {
32578
32704
  this.ingestInFlight = entry;
32579
32705
  return promise;
32580
32706
  }
32707
+ /**
32708
+ * (4) recv.lag = server message created_at -> this pull's completion, per
32709
+ * application row, counted once per queue id. It makes the daemon-side
32710
+ * receive delay measurable (recv.pull alone times only the HTTP call).
32711
+ * Cross-clock: daemon-vs-server skew lands in the sample; a negative lag
32712
+ * is clamped to 0. The server serialises a naive UTC datetime (no offset),
32713
+ * which Date.parse would read as LOCAL time, so a bare timestamp is UTC.
32714
+ */
32715
+ recordRecvLag(rows) {
32716
+ const now = Date.now();
32717
+ for (const row of rows) {
32718
+ if (row.message_type !== "application" || typeof row.queue_id !== "string") continue;
32719
+ if (typeof row.created_at !== "string" || this.recvLagSeen.has(row.queue_id)) continue;
32720
+ const iso = /(Z|[+-]\d{2}:?\d{2})$/i.test(row.created_at) ? row.created_at : `${row.created_at}Z`;
32721
+ const createdAt = Date.parse(iso);
32722
+ if (!Number.isFinite(createdAt)) continue;
32723
+ this.recvLagSeen.add(row.queue_id);
32724
+ if (this.recvLagSeen.size > _BridgeAgent.RECV_LAG_SEEN_CAP) {
32725
+ const oldest = this.recvLagSeen.values().next().value;
32726
+ if (oldest !== void 0) this.recvLagSeen.delete(oldest);
32727
+ }
32728
+ this.latency.record("recv.lag", Math.max(0, now - createdAt));
32729
+ }
32730
+ }
32581
32731
  async ingestCycle() {
32582
32732
  if (this.closingDown) return;
32583
32733
  const cycleStart = performance.now();
32584
32734
  try {
32585
32735
  const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
32736
+ this.recordRecvLag(rows);
32586
32737
  const channelIds = /* @__PURE__ */ new Set();
32587
32738
  for (const row of rows) {
32588
32739
  if (row.connect_channel_id) channelIds.add(row.connect_channel_id);
@@ -32839,21 +32990,23 @@ var BridgeAgent = class _BridgeAgent {
32839
32990
  )?.name;
32840
32991
  const turnAuthorization = this.authorizationFor(
32841
32992
  m.channelId,
32842
- m.envelope.sender_id
32993
+ m.envelope.sender_id,
32994
+ speaker?.identityId
32843
32995
  );
32844
32996
  const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
32845
32997
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
32846
32998
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
32847
32999
  const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
33000
+ const turnText = parsed.text;
33001
+ const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
33002
+ const coalescible = this.voiceCoalesceWindowMs > 0 && m.voiceMode === true && speaker !== void 0 && parsed.transcribed === true && voiceGuidance !== void 0 && !isCallStart;
32848
33003
  let fastPathRoomBlocked = false;
32849
- if (fastPathEligible && meta && meta.is_dm === false) {
33004
+ if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
32850
33005
  const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
32851
33006
  if (this.rateFloor.available(`room:${m.channelId}`) - reserved < 2) fastPathRoomBlocked = true;
32852
33007
  }
32853
33008
  if (fastPathRoomBlocked) this.fastPathSkipped++;
32854
33009
  const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
32855
- const turnText = parsed.text;
32856
- const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
32857
33010
  const fastGreeting = isCallStart && fastPathFiring;
32858
33011
  const runtimeVoice = (ackSent) => m.voiceMode ? {
32859
33012
  voice: true,
@@ -32874,6 +33027,95 @@ var BridgeAgent = class _BridgeAgent {
32874
33027
  ...speaker ? { speaker } : {},
32875
33028
  ...runtimeVoice(ackSent)
32876
33029
  });
33030
+ if (coalescible) {
33031
+ if (this.voiceCoalescedIds.has(m.messageId) || this.runtimePendingIds.has(m.messageId)) {
33032
+ acked.push(m.queueId);
33033
+ continue;
33034
+ }
33035
+ const windowKey = `${m.channelId}\0${speaker.identityId}`;
33036
+ const open = this.voiceCoalescing.get(windowKey);
33037
+ if (open && !open.closed) {
33038
+ open.ids.push(m.messageId);
33039
+ open.parts.push(turnText);
33040
+ const staging = (async () => {
33041
+ const ok = await this.stageHeldRuntimeMessage(runtimeEntry(false, await runtimeMemory));
33042
+ if (!ok) {
33043
+ const i = open.ids.indexOf(m.messageId);
33044
+ if (i > 0) {
33045
+ open.ids.splice(i, 1);
33046
+ open.parts.splice(i, 1);
33047
+ }
33048
+ }
33049
+ return ok;
33050
+ })();
33051
+ open.pending = Promise.all([open.pending, staging]);
33052
+ if (!await staging) continue;
33053
+ this.noteVoiceCoalesced(m.messageId);
33054
+ acked.push(m.queueId);
33055
+ continue;
33056
+ }
33057
+ const held = runtimeEntry(false, await runtimeMemory);
33058
+ if (!await this.stageHeldRuntimeMessage(held)) continue;
33059
+ this.noteVoiceCoalesced(m.messageId);
33060
+ const win = {
33061
+ speakerId: speaker.identityId,
33062
+ ids: [m.messageId],
33063
+ parts: [turnText],
33064
+ closed: false,
33065
+ pending: Promise.resolve()
33066
+ };
33067
+ this.voiceCoalescing.set(windowKey, win);
33068
+ const openedAt = Date.now();
33069
+ const channelId = m.channelId;
33070
+ const senderId = m.envelope.sender_id;
33071
+ const isRoom = meta?.is_dm === false;
33072
+ this.enqueueFastReply(channelId, async () => {
33073
+ await waitMs(openedAt + this.voiceCoalesceWindowMs - Date.now());
33074
+ win.closed = true;
33075
+ if (this.voiceCoalescing.get(windowKey) === win) this.voiceCoalescing.delete(windowKey);
33076
+ await win.pending;
33077
+ if (this.closingDown) return;
33078
+ const [firstId, ...tailIds] = win.ids;
33079
+ if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
33080
+ const text = win.parts.join(" ");
33081
+ const merged = { ...held, text };
33082
+ void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice");
33083
+ let roomBlocked = false;
33084
+ if (fastPathEligible && isRoom) {
33085
+ const reserved = this.fastReplyReserved.get(channelId) ?? 0;
33086
+ if (this.rateFloor.available(`room:${channelId}`) - reserved < 2) roomBlocked = true;
33087
+ }
33088
+ if (roomBlocked) this.fastPathSkipped++;
33089
+ const plan = fastPathEligible && !roomBlocked && !this.pausedChannels.has(channelId) ? this.planFastPath(firstId, text, false) : void 0;
33090
+ if (!plan || plan.kind === "dup" || plan.kind === "skip") {
33091
+ if (plan?.kind === "skip" && plan.full) this.fastPathSkipped++;
33092
+ await this.settleHeldRuntimeMessage(merged, true);
33093
+ return;
33094
+ }
33095
+ this.markFastPathSeen(firstId);
33096
+ if (plan.kind === "ack") {
33097
+ this.fastPathDeferred++;
33098
+ if (plan.intent) this.fastPathIntentDeferred++;
33099
+ }
33100
+ const release = this.reserveFastReply(channelId);
33101
+ try {
33102
+ await this.speakAndSettle(
33103
+ { channelId, messageId: firstId, senderId, authorization: turnAuthorization },
33104
+ plan,
33105
+ merged,
33106
+ { ...merged, ...runtimeVoice(true) },
33107
+ text,
33108
+ voiceGuidance,
33109
+ speaker,
33110
+ false
33111
+ );
33112
+ } finally {
33113
+ release();
33114
+ }
33115
+ }, false);
33116
+ acked.push(m.queueId);
33117
+ continue;
33118
+ }
32877
33119
  if (fastPathFiring) {
32878
33120
  const fastSpeaker = speaker ?? (senderName ? { identityId: m.envelope.sender_id, displayName: senderName } : void 0);
32879
33121
  const fastText = fastGreeting ? greetingTurnFor(this.voiceSettings ?? DEFAULT_VOICE_SETTINGS, fastSpeaker?.displayName) : parsed.text;
@@ -32899,11 +33141,11 @@ var BridgeAgent = class _BridgeAgent {
32899
33141
  this.fastPathDeferred++;
32900
33142
  if (ack.intent) this.fastPathIntentDeferred++;
32901
33143
  }
32902
- this.enqueueFastReply(m.channelId, async () => {
32903
- const outcome = ack ? await this.sendFastLine(turn.channelId, turn.messageId, ack.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidanceForFast, fastSpeaker, greeting);
32904
- if (outcome === "answered") this.settleHeldRuntimeMessage(held, false);
32905
- else this.settleHeldRuntimeMessage(outcome === "deferred" ? { ...held, ...runtimeVoice(true) } : held, true);
32906
- });
33144
+ const heldAfterAck = { ...held, ...runtimeVoice(true) };
33145
+ this.enqueueFastReply(
33146
+ m.channelId,
33147
+ () => this.speakAndSettle(turn, ack ?? plan, held, heldAfterAck, fastText, guidanceForFast, fastSpeaker, greeting)
33148
+ );
32907
33149
  }
32908
33150
  } else {
32909
33151
  if (!await this.stageRuntimeMessageInOrder(runtimeEntry(false, await runtimeMemory))) continue;
@@ -33324,6 +33566,11 @@ var BridgeAgent = class _BridgeAgent {
33324
33566
  this.deliveryAttempts.delete(messageId);
33325
33567
  this.redeliverNotBefore.delete(messageId);
33326
33568
  this.supersedeRefundable.delete(messageId);
33569
+ const tails = this.voiceCoalescedTails.get(messageId);
33570
+ if (tails) {
33571
+ this.voiceCoalescedTails.delete(messageId);
33572
+ this.ackRuntimeMessages(tails);
33573
+ }
33327
33574
  }
33328
33575
  }
33329
33576
  async restoreRuntimeInbox() {
@@ -12,6 +12,7 @@ interface MemorySink {
12
12
  * for a turn whose InboxEntry had the server-set `voiceMode` flag, so memory
13
13
  * continuity reflects how the turn actually arrived. */
14
14
  export declare function recordInboundMemory(sink: MemorySink, channelId: string, senderId: string, text: string, messageId: string, modality?: "text" | "voice"): Promise<void>;
15
- /** Record the agent's own outbound text reply. Best-effort. */
16
- export declare function recordOutboundMemory(sink: MemorySink, channelId: string, text: string, messageId: string): Promise<void>;
15
+ /** Record the agent's own outbound reply. Best-effort. `modality` is "voice"
16
+ * for a line the voice fast path spoke (H1), else "text". */
17
+ export declare function recordOutboundMemory(sink: MemorySink, channelId: string, text: string, messageId: string, modality?: "text" | "voice"): Promise<void>;
17
18
  export {};
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const BRIDGE_VERSION = "0.26.1";
1
+ export declare const BRIDGE_VERSION = "0.26.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@securecoms/agent-bridge",
3
- "version": "0.26.1",
3
+ "version": "0.26.2",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.5.0"