@securecoms/agent-bridge 0.26.1 → 0.26.3

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
@@ -7597,6 +7597,15 @@ function systemPrompt(p, settings, agentName) {
7597
7597
  }
7598
7598
 
7599
7599
  // src/service-log.ts
7600
+ var ENUM_VALUE = /^[a-z][a-z_]{0,31}$/;
7601
+ var ServiceLogEnum = class _ServiceLogEnum {
7602
+ constructor(value) {
7603
+ this.value = value;
7604
+ }
7605
+ static of(value, allowed) {
7606
+ return new _ServiceLogEnum(allowed.includes(value) && ENUM_VALUE.test(value) ? value : "invalid");
7607
+ }
7608
+ };
7600
7609
  var TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/;
7601
7610
  var CLASS = /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
7602
7611
  function errorClass(error) {
@@ -7626,6 +7635,7 @@ function formatServiceLog(level3, event, fields = {}, error) {
7626
7635
  if (!TOKEN.test(key)) continue;
7627
7636
  if (typeof value === "boolean") parts.push(`${key}=${value}`);
7628
7637
  else if (typeof value === "number" && Number.isFinite(value)) parts.push(`${key}=${value}`);
7638
+ else if (value instanceof ServiceLogEnum && ENUM_VALUE.test(value.value)) parts.push(`${key}=${value.value}`);
7629
7639
  }
7630
7640
  if (error !== void 0) parts.push(`error_class=${errorClass(error)}`);
7631
7641
  return parts.join(" ");
@@ -28383,9 +28393,9 @@ async function recordInboundMemory(sink, channelId, senderId, text, messageId, m
28383
28393
  } catch {
28384
28394
  }
28385
28395
  }
28386
- async function recordOutboundMemory(sink, channelId, text, messageId) {
28396
+ async function recordOutboundMemory(sink, channelId, text, messageId, modality = "text") {
28387
28397
  try {
28388
- await sink.record({ conversationId: channelId, role: "agent", text, modality: "text", messageId });
28398
+ await sink.record({ conversationId: channelId, role: "agent", text, modality, messageId });
28389
28399
  } catch {
28390
28400
  }
28391
28401
  }
@@ -29110,6 +29120,15 @@ function logPendingCommitResolved(outcome) {
29110
29120
  writeServiceLog("info", "mls.pending_commit.resolved.unknown");
29111
29121
  }
29112
29122
  }
29123
+ var OWNER_DM_DEFAULT_READ_GRANTS = Object.freeze([
29124
+ Object.freeze({ tool: "current_time", mode: "read" }),
29125
+ Object.freeze({ tool: "web_search", mode: "read" })
29126
+ ]);
29127
+ var NO_TOOL_GRANTS = Object.freeze([]);
29128
+ function ownerDmDefaultGrantsEnabled(env = process.env) {
29129
+ const v = env.SECURECOMS_OWNER_DM_DEFAULT_GRANTS;
29130
+ return v === void 0 || v === "on";
29131
+ }
29113
29132
  var VAULT_REF_BARE_TEXT = "[Files link]";
29114
29133
  var VOICE_MODE_GUIDANCE = "Reply immediately and conversationally, one short sentence per message; never bundle multiple points into one message.";
29115
29134
  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 +29140,20 @@ function selectVoiceGuidance(isDm, voiceDefaultResponder) {
29121
29140
  }
29122
29141
  return VOICE_MODE_GUIDANCE;
29123
29142
  }
29143
+ var VOICE_COALESCE_WINDOW_MS = 0;
29144
+ var VOICE_COALESCE_WINDOW_MAX_MS = 5e3;
29145
+ function voiceCoalesceWindowFromEnv(env = process.env) {
29146
+ const raw = env.SECURECOMS_VOICE_COALESCE_MS;
29147
+ if (raw === void 0 || !/^\d{1,6}$/.test(raw.trim())) return VOICE_COALESCE_WINDOW_MS;
29148
+ return Math.min(Number(raw.trim()), VOICE_COALESCE_WINDOW_MAX_MS);
29149
+ }
29150
+ function waitMs(ms) {
29151
+ if (ms <= 0) return Promise.resolve();
29152
+ return new Promise((resolve3) => {
29153
+ const t = setTimeout(resolve3, ms);
29154
+ t.unref?.();
29155
+ });
29156
+ }
29124
29157
  var DEFAULT_MAX_BUFFER = 1e3;
29125
29158
  var DEFAULT_MAX_DELIVERY_ATTEMPTS = 5;
29126
29159
  var REDELIVERY_BACKOFF_BASE_MS = 5e3;
@@ -29405,6 +29438,7 @@ var BridgeAgent = class _BridgeAgent {
29405
29438
  this.fastLlm = deps.fastLlm ?? (cfg.voice?.fastPath.enabled ? new FastLlmClient(cfg.voice.fastPath, this.fetchImpl) : void 0);
29406
29439
  this.voiceSettingsSource = cfg.voice ? "file" : "none";
29407
29440
  this.latency = deps.latency ?? new LatencyRecorder();
29441
+ this.voiceCoalesceWindowMs = Math.max(0, deps.voiceCoalesceWindowMs ?? voiceCoalesceWindowFromEnv());
29408
29442
  this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
29409
29443
  this.maxDeliveryAttempts = deps.maxDeliveryAttempts ?? DEFAULT_MAX_DELIVERY_ATTEMPTS;
29410
29444
  this.maxAgentHops = deps.maxAgentHops ?? DEFAULT_MAX_AGENT_HOPS;
@@ -29653,6 +29687,19 @@ var BridgeAgent = class _BridgeAgent {
29653
29687
  /** Room send-floor tokens reserved by queued fast replies, per channel (F3). */
29654
29688
  fastReplyReserved = /* @__PURE__ */ new Map();
29655
29689
  static FAST_REPLY_MAX_JOBS = 32;
29690
+ /** Voice repeats (a): see {@link VOICE_COALESCE_WINDOW_MS}. */
29691
+ voiceCoalesceWindowMs;
29692
+ /** H1: fast-path reply ids being published right now (recorded as voice memory). */
29693
+ spokenReplyIds = /* @__PURE__ */ new Set();
29694
+ /** Open coalescing windows, keyed by channel + speaker. */
29695
+ voiceCoalescing = /* @__PURE__ */ new Map();
29696
+ /** Ids of every coalesced fragment (capped FIFO): a redelivered row is acked, never a second turn. */
29697
+ voiceCoalescedIds = /* @__PURE__ */ new Set();
29698
+ /** Merged turn id -> the absorbed fragments' held frames, retired with it. */
29699
+ voiceCoalescedTails = /* @__PURE__ */ new Map();
29700
+ /** (4) recv.lag: application queue ids already measured (capped FIFO), so a re-pulled row is not re-counted. */
29701
+ recvLagSeen = /* @__PURE__ */ new Set();
29702
+ static RECV_LAG_SEEN_CAP = 2e3;
29656
29703
  /** Fast-path turns handed to the runtime behind a spoken ack (LLM [[DEFER]], pre-check, or ack_only). */
29657
29704
  fastPathDeferred = 0;
29658
29705
  /** Of those, `full`-mode turns the deterministic pre-check deferred without calling the fast model. */
@@ -29746,6 +29793,8 @@ var BridgeAgent = class _BridgeAgent {
29746
29793
  * back to the delivery-row-driven Task 11 behaviour only.
29747
29794
  */
29748
29795
  selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
29796
+ /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
29797
+ ownerDmDefaultGrants = ownerDmDefaultGrantsEnabled();
29749
29798
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
29750
29799
  static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
29751
29800
  /**
@@ -31110,7 +31159,8 @@ var BridgeAgent = class _BridgeAgent {
31110
31159
  /** Bookkeeping after a CONFIRMED publish of `body` under `id`. */
31111
31160
  afterPublish(channelId, body, id, opts) {
31112
31161
  if (opts.agentTurn !== false) this.agentHops.noteOwnSend(channelId);
31113
- if (body.kind === "text") void recordOutboundMemory(this.memory, channelId, body.text, id);
31162
+ const modality = this.spokenReplyIds.has(id) ? "voice" : "text";
31163
+ if (body.kind === "text") void recordOutboundMemory(this.memory, channelId, body.text, id, modality);
31114
31164
  }
31115
31165
  isIdempotentSend(messageId, opts) {
31116
31166
  if (opts.inReplyTo === void 0 && opts.idempotencyKey === void 0) return false;
@@ -31288,26 +31338,26 @@ var BridgeAgent = class _BridgeAgent {
31288
31338
  } catch (e) {
31289
31339
  if (e instanceof ReplyInFlightError) {
31290
31340
  if (inFlight >= REPLY_IN_FLIGHT_RETRIES) throw e;
31291
- const waitMs2 = Math.min(
31341
+ const waitMs3 = Math.min(
31292
31342
  REPLY_RATE_LIMIT_MAX_WAIT_MS,
31293
31343
  Math.max(REPLY_IN_FLIGHT_BASE_WAIT_MS * 2 ** inFlight, e.retryAfterSec * 1e3)
31294
31344
  );
31295
31345
  inFlight++;
31296
- writeServiceLog("info", "reply.in_flight_retry", { attempt: inFlight, wait_ms: waitMs2 });
31297
- await this.replySleep(waitMs2);
31346
+ writeServiceLog("info", "reply.in_flight_retry", { attempt: inFlight, wait_ms: waitMs3 });
31347
+ await this.replySleep(waitMs3);
31298
31348
  continue;
31299
31349
  }
31300
31350
  const backendSec = backendRetryAfterSec(e);
31301
31351
  const wait = e instanceof RateLimitedError ? e.retryAfterMs : backendSec !== void 0 ? backendSec * 1e3 : null;
31302
31352
  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));
31353
+ const waitMs2 = Math.min(REPLY_RATE_LIMIT_MAX_WAIT_MS, Math.max(REPLY_RATE_LIMIT_MIN_WAIT_MS, wait));
31304
31354
  if (rateLimited >= REPLY_RATE_LIMIT_RETRIES) {
31305
- throw e instanceof RateLimitedError ? e : new RateLimitedError(channelId, waitMs);
31355
+ throw e instanceof RateLimitedError ? e : new RateLimitedError(channelId, waitMs2);
31306
31356
  }
31307
31357
  rateLimited++;
31308
31358
  this.replyRateLimitRetries++;
31309
- writeServiceLog("info", "reply.rate_limited_retry", { attempt: rateLimited, wait_ms: waitMs });
31310
- await this.replySleep(waitMs);
31359
+ writeServiceLog("info", "reply.rate_limited_retry", { attempt: rateLimited, wait_ms: waitMs2 });
31360
+ await this.replySleep(waitMs2);
31311
31361
  }
31312
31362
  }
31313
31363
  }
@@ -31796,6 +31846,13 @@ var BridgeAgent = class _BridgeAgent {
31796
31846
  if (needsRuntime(text)) return { kind: "ack", line: deferAck(messageId), intent: true };
31797
31847
  return { kind: "fast" };
31798
31848
  }
31849
+ noteVoiceCoalesced(messageId) {
31850
+ this.voiceCoalescedIds.add(messageId);
31851
+ if (this.voiceCoalescedIds.size > _BridgeAgent.FAST_PATH_SEEN_CAP) {
31852
+ const oldest = this.voiceCoalescedIds.values().next().value;
31853
+ if (oldest !== void 0) this.voiceCoalescedIds.delete(oldest);
31854
+ }
31855
+ }
31799
31856
  markFastPathSeen(messageId) {
31800
31857
  this.fastPathSeen.add(messageId);
31801
31858
  if (this.fastPathSeen.size > _BridgeAgent.FAST_PATH_SEEN_CAP) {
@@ -31812,22 +31869,36 @@ var BridgeAgent = class _BridgeAgent {
31812
31869
  * reserve a room send-floor token until they finish. A job never throws.
31813
31870
  */
31814
31871
  enqueueFastReply(channelId, job, spends = true) {
31815
- if (spends) {
31816
- this.fastReplyJobs++;
31817
- this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
31818
- }
31872
+ const release = spends ? this.reserveFastReply(channelId) : void 0;
31819
31873
  const prev = this.fastReplyChains.get(channelId) ?? Promise.resolve();
31820
31874
  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
- }
31875
+ release?.();
31827
31876
  if (this.fastReplyChains.get(channelId) === next) this.fastReplyChains.delete(channelId);
31828
31877
  });
31829
31878
  this.fastReplyChains.set(channelId, next);
31830
31879
  }
31880
+ /** Count a fast reply against FAST_REPLY_MAX_JOBS and reserve its room send-floor token; returns the release. */
31881
+ reserveFastReply(channelId) {
31882
+ this.fastReplyJobs++;
31883
+ this.fastReplyReserved.set(channelId, (this.fastReplyReserved.get(channelId) ?? 0) + 1);
31884
+ return () => {
31885
+ this.fastReplyJobs--;
31886
+ const left3 = (this.fastReplyReserved.get(channelId) ?? 1) - 1;
31887
+ if (left3 > 0) this.fastReplyReserved.set(channelId, left3);
31888
+ else this.fastReplyReserved.delete(channelId);
31889
+ };
31890
+ }
31891
+ /**
31892
+ * Speak a planned fast turn ("ack" or "fast") and settle its held frame:
31893
+ * retired when the fast model answered alone, otherwise released to the
31894
+ * runtime (with the "ack already spoken" guidance only when an ack or a
31895
+ * deferring fast reply was actually published — F3).
31896
+ */
31897
+ async speakAndSettle(turn, plan, held, heldAfterAck, fastText, guidance, fastSpeaker, greeting) {
31898
+ const outcome = plan.kind === "ack" ? await this.sendFastLine(turn.channelId, turn.messageId, plan.line) ? "deferred" : "failed" : await this.runFastReply(turn, fastText, guidance, fastSpeaker, greeting);
31899
+ if (outcome === "answered") await this.settleHeldRuntimeMessage(held, false);
31900
+ else await this.settleHeldRuntimeMessage(outcome === "deferred" ? heldAfterAck : held, true);
31901
+ }
31831
31902
  /**
31832
31903
  * Stage a runtime frame without overtaking a turn of the same channel that
31833
31904
  * is still queued behind the fast path (F2): while the channel has a queue,
@@ -31843,10 +31914,19 @@ var BridgeAgent = class _BridgeAgent {
31843
31914
  async settleFastReplies() {
31844
31915
  while (this.fastReplyChains.size) await Promise.all([...this.fastReplyChains.values()]);
31845
31916
  }
31917
+ /** Send a line the voice fast path speaks: its memory record is "voice" (H1). */
31918
+ async sendSpoken(channelId, text, messageId) {
31919
+ this.spokenReplyIds.add(messageId);
31920
+ try {
31921
+ await this.send(channelId, text, messageId);
31922
+ } finally {
31923
+ this.spokenReplyIds.delete(messageId);
31924
+ }
31925
+ }
31846
31926
  /** Speak a fixed acknowledgement line; failures are counted, never thrown. */
31847
31927
  async sendFastLine(channelId, messageId, line) {
31848
31928
  try {
31849
- await this.send(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
31929
+ await this.sendSpoken(channelId, line, fastReplyId(this.replyIdKey, this.cfg.identityId, channelId, messageId));
31850
31930
  this.fastPathSent++;
31851
31931
  return true;
31852
31932
  } catch (e) {
@@ -31903,7 +31983,7 @@ var BridgeAgent = class _BridgeAgent {
31903
31983
  const raw = await this.latency.time("voice.fast_llm", () => fastLlm.complete(messages));
31904
31984
  const { reply, defer } = splitFastReply(raw, 200, maxSentences);
31905
31985
  if (!reply) return "failed";
31906
- await this.send(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
31986
+ await this.sendSpoken(m.channelId, reply, fastReplyId(this.replyIdKey, this.cfg.identityId, m.channelId, m.messageId));
31907
31987
  this.fastPathSent++;
31908
31988
  if (defer) this.fastPathDeferred++;
31909
31989
  return defer ? "deferred" : "answered";
@@ -31936,15 +32016,48 @@ var BridgeAgent = class _BridgeAgent {
31936
32016
  }
31937
32017
  }
31938
32018
  /** 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) {
32019
+ async settleHeldRuntimeMessage(message, release) {
31940
32020
  if (!this.runtimePendingIds.has(message.message_id)) return;
31941
32021
  if (!release) {
31942
32022
  this.ackRuntimeMessages([message.message_id]);
31943
32023
  return;
31944
32024
  }
31945
- this.buffer.push(message);
32025
+ const memory = await this.memoryAtRelease(message);
32026
+ if (this.closingDown || !this.runtimePendingIds.has(message.message_id)) return;
32027
+ this.buffer.push(memory === message.memory ? message : { ...message, memory });
31946
32028
  this.wakeListeners();
31947
32029
  }
32030
+ /**
32031
+ * Voice repeats (b): a held frame's memory was read when its turn ARRIVED,
32032
+ * before the replies queued ahead of it (an earlier turn's fast reply, this
32033
+ * turn's own ack) were spoken. The runtime replaces its history with the
32034
+ * frame's memory, so with the arrival snapshot it answered an
32035
+ * already-answered turn again. At release the context is re-read and the
32036
+ * AGENT turns it holds beyond the snapshot (afterPublish ->
32037
+ * recordOutboundMemory) are appended to the snapshot. Peer turns recorded
32038
+ * since (this turn itself, later turns) are not, so the frame stays
32039
+ * prior-turn-only for what the runtime is about to answer. New turns are
32040
+ * found by recorded time, never by `seq` (the OpenClaw provider renumbers
32041
+ * seq per read). A failed re-read keeps the arrival snapshot.
32042
+ */
32043
+ async memoryAtRelease(message) {
32044
+ const prior = message.memory;
32045
+ if (!prior || !this.mayReadOpenClawMemoryForTurn(message.authorization)) return prior;
32046
+ let fresh;
32047
+ try {
32048
+ fresh = await this.memory.context(message.channel_id, _BridgeAgent.TURN_MEMORY_LIMIT);
32049
+ } catch {
32050
+ return prior;
32051
+ }
32052
+ const after = prior.recent.reduce((max, turn) => Math.max(max, turn.ts ?? 0), message.ts);
32053
+ const spoken = fresh.recent.filter((turn) => turn.role === "agent" && turn.ts !== void 0 && turn.ts > after);
32054
+ if (spoken.length === 0) return prior;
32055
+ return {
32056
+ recent: [...prior.recent, ...spoken].slice(-_BridgeAgent.TURN_MEMORY_LIMIT),
32057
+ facts: prior.facts
32058
+ };
32059
+ }
32060
+ static TURN_MEMORY_LIMIT = 50;
31948
32061
  /**
31949
32062
  * List channels in one enclave (default: the daemon's own home enclave).
31950
32063
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
@@ -32206,10 +32319,22 @@ var BridgeAgent = class _BridgeAgent {
32206
32319
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
32207
32320
  * guess. The returned object and grant array are frozen before they cross the
32208
32321
  * daemon boundary so one consumer cannot widen a later execution check.
32322
+ *
32323
+ * Grants (architect ruling 2026-10-02): OWNER_DM_DEFAULT_READ_GRANTS only when
32324
+ * the kill switch is on, the channel is a DM, and EITHER
32325
+ * (a) audience is owner_dm AND the sender is the pinned structural owner
32326
+ * (`channelOwner`, never "anomalous") — a lying `is_agent_owner` flag
32327
+ * alone gets nothing; OR
32328
+ * (b) the sender is this channel's server-marked relay (`is_relay`) and the
32329
+ * relayed `speaker_identity_id` (passed by the caller only for relay
32330
+ * turns) is that same pinned owner, whose roster row is the agent's
32331
+ * owner. A speaker claim from any non-relay sender gets nothing.
32332
+ * Everything else gets the frozen empty list.
32209
32333
  */
32210
- authorizationFor(channelId, senderId) {
32334
+ authorizationFor(channelId, senderId, relaySpeakerId) {
32211
32335
  const meta = this.freshMeta(channelId);
32212
- const sender = this.freshRoster(channelId)?.get(senderId);
32336
+ const roster = this.freshRoster(channelId);
32337
+ const sender = roster?.get(senderId);
32213
32338
  let audience = "unknown";
32214
32339
  if (meta && sender) {
32215
32340
  if (sender.is_agent) audience = "agent_to_agent";
@@ -32218,11 +32343,71 @@ var BridgeAgent = class _BridgeAgent {
32218
32343
  else if (sender.is_partner) audience = "partner_room";
32219
32344
  else audience = "team_room";
32220
32345
  }
32346
+ let tool_grants = NO_TOOL_GRANTS;
32347
+ if (this.ownerDmDefaultGrants && meta?.is_dm === true && sender) {
32348
+ const pinned = this.channelOwner.get(channelId);
32349
+ const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
32350
+ if (ownerId !== void 0) {
32351
+ const ownerDirect = audience === "owner_dm" && senderId === ownerId;
32352
+ const owner = roster?.get(ownerId);
32353
+ const ownerRelayed = relaySpeakerId !== void 0 && sender.is_relay && relaySpeakerId === ownerId && owner !== void 0 && !owner.is_agent && owner.is_agent_owner;
32354
+ if (ownerDirect || ownerRelayed) tool_grants = OWNER_DM_DEFAULT_READ_GRANTS;
32355
+ }
32356
+ }
32357
+ this.logAuthzDecision(channelId, senderId, relaySpeakerId, audience, tool_grants.length);
32221
32358
  return Object.freeze({
32222
32359
  version: 1,
32223
32360
  audience,
32224
32361
  sender_membership: sender ? "active" : "unknown",
32225
- tool_grants: Object.freeze([])
32362
+ tool_grants
32363
+ });
32364
+ }
32365
+ /**
32366
+ * One content-free INFO line per authorization decision (every call site of
32367
+ * authorizationFor stages exactly one runtime turn). It says WHY a turn got
32368
+ * the grants it got, without any identifier, name or text: every field is a
32369
+ * boolean, a count, or a closed-set label.
32370
+ *
32371
+ * owner_pin pinned | anomalous | missing (channelOwner cache)
32372
+ * owner_row_is_agent_owner the pinned owner's roster row; `absent` when
32373
+ * there is no pin or the row is not in the roster
32374
+ * sender_is_owner sender identity == pinned owner identity
32375
+ * relay sender's roster row has is_relay
32376
+ * relay_speaker_is_owner `na` unless a relay speaker was passed
32377
+ * kill_switch SECURECOMS_OWNER_DM_DEFAULT_GRANTS as read at
32378
+ * startup: `on` = default grants enabled
32379
+ * meta_fresh / roster_fresh whether the channel meta / roster caches were
32380
+ * fresh (either stale forces audience=unknown)
32381
+ */
32382
+ logAuthzDecision(channelId, senderId, relaySpeakerId, audience, grants) {
32383
+ const meta = this.freshMeta(channelId);
32384
+ const roster = this.freshRoster(channelId);
32385
+ const sender = roster?.get(senderId);
32386
+ const pinned = this.channelOwner.get(channelId);
32387
+ const ownerId = pinned && pinned !== "anomalous" ? pinned.identityId : void 0;
32388
+ const ownerRow = ownerId !== void 0 ? roster?.get(ownerId) : void 0;
32389
+ const ownerPin = pinned === void 0 ? "missing" : pinned === "anomalous" ? "anomalous" : "pinned";
32390
+ const ownerRowIsAgentOwner = ownerRow === void 0 ? "absent" : ownerRow.is_agent_owner ? "true" : "false";
32391
+ const speakerIsOwner = relaySpeakerId === void 0 ? "na" : ownerId !== void 0 && relaySpeakerId === ownerId ? "true" : "false";
32392
+ writeServiceLog("info", "authz.decision", {
32393
+ audience: ServiceLogEnum.of(audience, [
32394
+ "owner_dm",
32395
+ "other_human_dm",
32396
+ "team_room",
32397
+ "partner_room",
32398
+ "agent_to_agent",
32399
+ "unknown"
32400
+ ]),
32401
+ grants,
32402
+ owner_pin: ServiceLogEnum.of(ownerPin, ["pinned", "anomalous", "missing"]),
32403
+ owner_row_is_agent_owner: ServiceLogEnum.of(ownerRowIsAgentOwner, ["true", "false", "absent"]),
32404
+ sender_is_owner: ownerId !== void 0 && senderId === ownerId,
32405
+ relay: sender?.is_relay === true,
32406
+ relay_speaker_is_owner: ServiceLogEnum.of(speakerIsOwner, ["true", "false", "na"]),
32407
+ is_dm: meta?.is_dm === true,
32408
+ kill_switch: ServiceLogEnum.of(this.ownerDmDefaultGrants ? "on" : "off", ["on", "off"]),
32409
+ meta_fresh: meta !== void 0,
32410
+ roster_fresh: roster !== void 0
32226
32411
  });
32227
32412
  }
32228
32413
  /**
@@ -32730,11 +32915,36 @@ var BridgeAgent = class _BridgeAgent {
32730
32915
  this.ingestInFlight = entry;
32731
32916
  return promise;
32732
32917
  }
32918
+ /**
32919
+ * (4) recv.lag = server message created_at -> this pull's completion, per
32920
+ * application row, counted once per queue id. It makes the daemon-side
32921
+ * receive delay measurable (recv.pull alone times only the HTTP call).
32922
+ * Cross-clock: daemon-vs-server skew lands in the sample; a negative lag
32923
+ * is clamped to 0. The server serialises a naive UTC datetime (no offset),
32924
+ * which Date.parse would read as LOCAL time, so a bare timestamp is UTC.
32925
+ */
32926
+ recordRecvLag(rows) {
32927
+ const now = Date.now();
32928
+ for (const row of rows) {
32929
+ if (row.message_type !== "application" || typeof row.queue_id !== "string") continue;
32930
+ if (typeof row.created_at !== "string" || this.recvLagSeen.has(row.queue_id)) continue;
32931
+ const iso = /(Z|[+-]\d{2}:?\d{2})$/i.test(row.created_at) ? row.created_at : `${row.created_at}Z`;
32932
+ const createdAt = Date.parse(iso);
32933
+ if (!Number.isFinite(createdAt)) continue;
32934
+ this.recvLagSeen.add(row.queue_id);
32935
+ if (this.recvLagSeen.size > _BridgeAgent.RECV_LAG_SEEN_CAP) {
32936
+ const oldest = this.recvLagSeen.values().next().value;
32937
+ if (oldest !== void 0) this.recvLagSeen.delete(oldest);
32938
+ }
32939
+ this.latency.record("recv.lag", Math.max(0, now - createdAt));
32940
+ }
32941
+ }
32733
32942
  async ingestCycle() {
32734
32943
  if (this.closingDown) return;
32735
32944
  const cycleStart = performance.now();
32736
32945
  try {
32737
32946
  const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
32947
+ this.recordRecvLag(rows);
32738
32948
  const channelIds = /* @__PURE__ */ new Set();
32739
32949
  for (const row of rows) {
32740
32950
  if (row.connect_channel_id) channelIds.add(row.connect_channel_id);
@@ -32991,21 +33201,23 @@ var BridgeAgent = class _BridgeAgent {
32991
33201
  )?.name;
32992
33202
  const turnAuthorization = this.authorizationFor(
32993
33203
  m.channelId,
32994
- m.envelope.sender_id
33204
+ m.envelope.sender_id,
33205
+ speaker?.identityId
32995
33206
  );
32996
33207
  const runtimeMemory = this.memoryForTurn(m.channelId, turnAuthorization);
32997
33208
  const voiceGuidance = m.voiceMode ? selectVoiceGuidance(meta?.is_dm, m.voiceDefaultResponder) : void 0;
32998
33209
  const humanTurn = senderIsAgent !== true || speaker !== void 0;
32999
33210
  const fastPathEligible = Boolean(m.voiceMode && humanTurn && this.fastLlm && voiceGuidance);
33211
+ const turnText = parsed.text;
33212
+ const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
33213
+ const coalescible = this.voiceCoalesceWindowMs > 0 && m.voiceMode === true && speaker !== void 0 && parsed.transcribed === true && voiceGuidance !== void 0 && !isCallStart;
33000
33214
  let fastPathRoomBlocked = false;
33001
- if (fastPathEligible && meta && meta.is_dm === false) {
33215
+ if (!coalescible && fastPathEligible && meta && meta.is_dm === false) {
33002
33216
  const reserved = this.fastReplyReserved.get(m.channelId) ?? 0;
33003
33217
  if (this.rateFloor.available(`room:${m.channelId}`) - reserved < 2) fastPathRoomBlocked = true;
33004
33218
  }
33005
33219
  if (fastPathRoomBlocked) this.fastPathSkipped++;
33006
33220
  const fastPathFiring = fastPathEligible && !fastPathRoomBlocked && !this.pausedChannels.has(m.channelId);
33007
- const turnText = parsed.text;
33008
- const isCallStart = turnText.trim() === VOICE_CALL_START_MARKER;
33009
33221
  const fastGreeting = isCallStart && fastPathFiring;
33010
33222
  const runtimeVoice = (ackSent) => m.voiceMode ? {
33011
33223
  voice: true,
@@ -33026,6 +33238,95 @@ var BridgeAgent = class _BridgeAgent {
33026
33238
  ...speaker ? { speaker } : {},
33027
33239
  ...runtimeVoice(ackSent)
33028
33240
  });
33241
+ if (coalescible) {
33242
+ if (this.voiceCoalescedIds.has(m.messageId) || this.runtimePendingIds.has(m.messageId)) {
33243
+ acked.push(m.queueId);
33244
+ continue;
33245
+ }
33246
+ const windowKey = `${m.channelId}\0${speaker.identityId}`;
33247
+ const open = this.voiceCoalescing.get(windowKey);
33248
+ if (open && !open.closed) {
33249
+ open.ids.push(m.messageId);
33250
+ open.parts.push(turnText);
33251
+ const staging = (async () => {
33252
+ const ok = await this.stageHeldRuntimeMessage(runtimeEntry(false, await runtimeMemory));
33253
+ if (!ok) {
33254
+ const i = open.ids.indexOf(m.messageId);
33255
+ if (i > 0) {
33256
+ open.ids.splice(i, 1);
33257
+ open.parts.splice(i, 1);
33258
+ }
33259
+ }
33260
+ return ok;
33261
+ })();
33262
+ open.pending = Promise.all([open.pending, staging]);
33263
+ if (!await staging) continue;
33264
+ this.noteVoiceCoalesced(m.messageId);
33265
+ acked.push(m.queueId);
33266
+ continue;
33267
+ }
33268
+ const held = runtimeEntry(false, await runtimeMemory);
33269
+ if (!await this.stageHeldRuntimeMessage(held)) continue;
33270
+ this.noteVoiceCoalesced(m.messageId);
33271
+ const win = {
33272
+ speakerId: speaker.identityId,
33273
+ ids: [m.messageId],
33274
+ parts: [turnText],
33275
+ closed: false,
33276
+ pending: Promise.resolve()
33277
+ };
33278
+ this.voiceCoalescing.set(windowKey, win);
33279
+ const openedAt = Date.now();
33280
+ const channelId = m.channelId;
33281
+ const senderId = m.envelope.sender_id;
33282
+ const isRoom = meta?.is_dm === false;
33283
+ this.enqueueFastReply(channelId, async () => {
33284
+ await waitMs(openedAt + this.voiceCoalesceWindowMs - Date.now());
33285
+ win.closed = true;
33286
+ if (this.voiceCoalescing.get(windowKey) === win) this.voiceCoalescing.delete(windowKey);
33287
+ await win.pending;
33288
+ if (this.closingDown) return;
33289
+ const [firstId, ...tailIds] = win.ids;
33290
+ if (tailIds.length) this.voiceCoalescedTails.set(firstId, tailIds);
33291
+ const text = win.parts.join(" ");
33292
+ const merged = { ...held, text };
33293
+ void recordInboundMemory(this.memory, channelId, senderId, text, firstId, "voice");
33294
+ let roomBlocked = false;
33295
+ if (fastPathEligible && isRoom) {
33296
+ const reserved = this.fastReplyReserved.get(channelId) ?? 0;
33297
+ if (this.rateFloor.available(`room:${channelId}`) - reserved < 2) roomBlocked = true;
33298
+ }
33299
+ if (roomBlocked) this.fastPathSkipped++;
33300
+ const plan = fastPathEligible && !roomBlocked && !this.pausedChannels.has(channelId) ? this.planFastPath(firstId, text, false) : void 0;
33301
+ if (!plan || plan.kind === "dup" || plan.kind === "skip") {
33302
+ if (plan?.kind === "skip" && plan.full) this.fastPathSkipped++;
33303
+ await this.settleHeldRuntimeMessage(merged, true);
33304
+ return;
33305
+ }
33306
+ this.markFastPathSeen(firstId);
33307
+ if (plan.kind === "ack") {
33308
+ this.fastPathDeferred++;
33309
+ if (plan.intent) this.fastPathIntentDeferred++;
33310
+ }
33311
+ const release = this.reserveFastReply(channelId);
33312
+ try {
33313
+ await this.speakAndSettle(
33314
+ { channelId, messageId: firstId, senderId, authorization: turnAuthorization },
33315
+ plan,
33316
+ merged,
33317
+ { ...merged, ...runtimeVoice(true) },
33318
+ text,
33319
+ voiceGuidance,
33320
+ speaker,
33321
+ false
33322
+ );
33323
+ } finally {
33324
+ release();
33325
+ }
33326
+ }, false);
33327
+ acked.push(m.queueId);
33328
+ continue;
33329
+ }
33029
33330
  if (fastPathFiring) {
33030
33331
  const fastSpeaker = speaker ?? (senderName ? { identityId: m.envelope.sender_id, displayName: senderName } : void 0);
33031
33332
  const fastText = fastGreeting ? greetingTurnFor(this.voiceSettings ?? DEFAULT_VOICE_SETTINGS, fastSpeaker?.displayName) : parsed.text;
@@ -33051,11 +33352,11 @@ var BridgeAgent = class _BridgeAgent {
33051
33352
  this.fastPathDeferred++;
33052
33353
  if (ack.intent) this.fastPathIntentDeferred++;
33053
33354
  }
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
- });
33355
+ const heldAfterAck = { ...held, ...runtimeVoice(true) };
33356
+ this.enqueueFastReply(
33357
+ m.channelId,
33358
+ () => this.speakAndSettle(turn, ack ?? plan, held, heldAfterAck, fastText, guidanceForFast, fastSpeaker, greeting)
33359
+ );
33059
33360
  }
33060
33361
  } else {
33061
33362
  if (!await this.stageRuntimeMessageInOrder(runtimeEntry(false, await runtimeMemory))) continue;
@@ -33476,6 +33777,11 @@ var BridgeAgent = class _BridgeAgent {
33476
33777
  this.deliveryAttempts.delete(messageId);
33477
33778
  this.redeliverNotBefore.delete(messageId);
33478
33779
  this.supersedeRefundable.delete(messageId);
33780
+ const tails = this.voiceCoalescedTails.get(messageId);
33781
+ if (tails) {
33782
+ this.voiceCoalescedTails.delete(messageId);
33783
+ this.ackRuntimeMessages(tails);
33784
+ }
33479
33785
  }
33480
33786
  }
33481
33787
  async restoreRuntimeInbox() {