@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 +283 -36
- package/dist/bridge-agent.d.ts +110 -3
- package/dist/index.js +283 -36
- package/dist/memory-taps.d.ts +3 -2
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
31297
|
-
await this.replySleep(
|
|
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
|
|
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,
|
|
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:
|
|
31310
|
-
await this.replySleep(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
33055
|
-
|
|
33056
|
-
|
|
33057
|
-
|
|
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() {
|
package/dist/bridge-agent.d.ts
CHANGED
|
@@ -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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
31145
|
-
await this.replySleep(
|
|
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
|
|
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,
|
|
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:
|
|
31158
|
-
await this.replySleep(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
32903
|
-
|
|
32904
|
-
|
|
32905
|
-
|
|
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() {
|
package/dist/memory-taps.d.ts
CHANGED
|
@@ -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
|
|
16
|
-
|
|
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
|
+
export declare const BRIDGE_VERSION = "0.26.2";
|