@securecoms/agent-bridge 0.26.0 → 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.
@@ -51,10 +51,32 @@ export type BridgeToolGrant = {
51
51
  };
52
52
  /**
53
53
  * Immutable authorization context derived from authenticated backend roster
54
- * data. The E2E plaintext never contributes to these claims. Tool grants are
55
- * intentionally empty until the channel-grant persistence/API lands; this
56
- * makes the new contract fail closed during staged deployment.
54
+ * data. The E2E plaintext never contributes to these claims, with ONE bounded
55
+ * exception: a relayed voice turn's `speaker_identity_id`, honoured only when
56
+ * the envelope's authenticated sender is the channel's server-marked relay.
57
+ *
58
+ * Tool grants (architect ruling, 2026-10-02): until channel-grant persistence
59
+ * lands, the ONLY non-empty grant list is OWNER_DM_DEFAULT_READ_GRANTS
60
+ * (`current_time` + `web_search`, read mode), and only on a DM whose sender is
61
+ * the pinned structural owner (or a relayed voice turn whose speaker is). Every
62
+ * other audience, an unpinned or anomalous owner, and the kill switch
63
+ * `SECURECOMS_OWNER_DM_DEFAULT_GRANTS` (anything but unset/`on`) get `[]`.
64
+ * SEC-06 is LIVE for these two tools: docs/security/sec-06-runtime-bounds-audit.md.
65
+ */
66
+ /**
67
+ * Architect ruling 2026-10-02: the default read grants an owner DM carries.
68
+ * A frozen constant in the daemon, NOT runtime config: a runtime cannot widen
69
+ * it, and the runtime's own tool allow-list still intersects it (a tool the
70
+ * runtime does not load is never offered). `fetch_url`, `echo`, `sql_query`
71
+ * and `http_request` are deliberately absent.
57
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.
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
  /**
@@ -382,7 +427,7 @@ export declare class InvalidMessageIdError extends Error {
382
427
  * `messageId`, formatted as an RFC 4122 UUID. Live finding 2026-09-06: the
383
428
  * previous `fast-<messageId>` string failed `resolveMessageId` ("message_id
384
429
  * must be a UUID") on every turn, so the fast path never sent once. Same input
385
- * → same UUID, so the idempotency argument in `runFastPath`'s doc still holds.
430
+ * → same UUID, so the idempotency argument in `runFastReply`'s doc still holds.
386
431
  *
387
432
  * SEC-06 #18: KEYED with the identity-seed-derived reply key and bound to the
388
433
  * agent and channel. It used to be SHA-256 of `fast:<messageId>` alone, which
@@ -732,6 +777,29 @@ export declare class BridgeAgent {
732
777
  private fastPathFailed;
733
778
  /** Cumulative count of fast-path turns skipped in a room channel because the shared send-floor bucket lacked headroom for both the ack and the runtime's own reply (controller ruling) — surfaced on health()/metrics(). */
734
779
  private fastPathSkipped;
780
+ /** Off-cycle fast replies: per-channel promise chains + the in-flight job count. */
781
+ private readonly fastReplyChains;
782
+ private fastReplyJobs;
783
+ /** Room send-floor tokens reserved by queued fast replies, per channel (F3). */
784
+ private readonly fastReplyReserved;
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;
799
+ /** Fast-path turns handed to the runtime behind a spoken ack (LLM [[DEFER]], pre-check, or ack_only). */
800
+ private fastPathDeferred;
801
+ /** Of those, `full`-mode turns the deterministic pre-check deferred without calling the fast model. */
802
+ private fastPathIntentDeferred;
735
803
  /**
736
804
  * Task 8: count of live AUTHENTICATED /v1/ws sessions, so a later `status`
737
805
  * verb can tell "bridge process up" (the app's green dot, which only
@@ -820,6 +888,8 @@ export declare class BridgeAgent {
820
888
  * back to the delivery-row-driven Task 11 behaviour only.
821
889
  */
822
890
  private readonly selfHealSweepEnabled;
891
+ /** Read once at startup; see ownerDmDefaultGrantsEnabled. */
892
+ private readonly ownerDmDefaultGrants;
823
893
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
824
894
  private static readonly SELF_HEAL_SWEEP_INTERVAL_MS;
825
895
  /**
@@ -1632,37 +1702,96 @@ export declare class BridgeAgent {
1632
1702
  private memoryForTurn;
1633
1703
  memoryContext(conversationId: string, limit?: number): Promise<import("@agentvault/connect-mls-store").MemoryContextResult>;
1634
1704
  /**
1635
- * Task 4 (self-hosted fast voice): instant persona-voiced acknowledgement
1636
- * for a live voice turn, sent ahead of the full agent reply — fired from
1637
- * the ingestOnce text branch as `void this.runFastPath(...)`.
1638
- *
1639
- * MUST NEVER throw into ingestOnce: every failure path (memory lookup, the
1640
- * LLM call, normalization, or the send itself) is caught internally, counted
1641
- * in `fastPathFailed`, and logged at most once — WITHOUT the turn text or
1642
- * the model's reply (only the channel/message id, never conversation
1643
- * content, reaches stderr).
1644
- *
1645
- * Dedupe: `fastPathSeen` is keyed by messageId so a redelivered/duplicate
1646
- * InboxEntry (e.g. after an ack race) never fires the fast LLM — and never
1647
- * double-sends — twice for the same turn. The `fastReplyId(...)` send
1648
- * id is itself also deterministic, so even a duplicate call that slipped past the
1649
- * in-memory dedupe (e.g. a process restart) resolves to the same envelope
1650
- * message id rather than a second spoken reply.
1651
- *
1652
- * Room token reservation (controller ruling): `send()` charges the shared
1653
- * `room:<channelId>` bucket (see `enforceRateFloor`) for any non-DM
1654
- * channel. A fast-path ack spent from the SAME bucket could starve the
1655
- * runtime's own reply moments later, so the CALL SITE (the ingestOnce text
1656
- * branch) only calls `runFastPath` at all when the bucket has at least 2
1657
- * tokens available — one earmarked for this ack, one reserved for the
1658
- * runtime's reply — and, critically, decides the buffered turn's guidance
1659
- * text using that SAME decision. That decision must not be re-made (and
1660
- * cannot silently diverge) here: `runFastPath` itself no longer touches the
1661
- * room bucket at all, it only handles messageId dedupe and the LLM/send
1662
- * mechanics. See the call site's `fastPathRoomBlocked` for the skip +
1663
- * `fastPathSkipped` counting.
1664
- */
1665
- private runFastPath;
1705
+ * Voice front desk, step 1 — decided SYNCHRONOUSLY inside the ingest cycle,
1706
+ * so the cycle never waits on a model call or a publish (Cortina011
1707
+ * recv.cycle max 10.8 s came from two inline fast replies back to back).
1708
+ * Pure: it changes no state; the caller commits a plan (markFastPathSeen,
1709
+ * counters) only after the turn is durably staged.
1710
+ * - "dup": this turn already went through the fast path (a redelivery);
1711
+ * it is neither spoken again nor handed to the runtime again.
1712
+ * - "skip": no fast path; the runtime gets the turn with plain guidance
1713
+ * (mode `off`, no fast client, or the reply queue is full).
1714
+ * - "ack": the turn certainly goes to the runtime (mode `ack_only`, or the
1715
+ * deterministic pre-check in `full`): `line` is spoken first.
1716
+ * - "fast": the fast model (or, in `ack_only`, the greeting template)
1717
+ * decides whether the runtime answers next.
1718
+ * Owner report 2026-10-02 (calls never used tools) motivates "ack".
1719
+ */
1720
+ private planFastPath;
1721
+ private noteVoiceCoalesced;
1722
+ private markFastPathSeen;
1723
+ /**
1724
+ * Off-cycle per-channel queue: one promise chain per channel, so within a
1725
+ * channel spoken lines publish, and runtime frames are released, strictly
1726
+ * in turn order (a turn's ack always publishes before its frame reaches
1727
+ * the runtime). `spends` marks a job that will publish a fast reply: those
1728
+ * are bounded by FAST_REPLY_MAX_JOBS (planFastPath degrades to "skip") and
1729
+ * reserve a room send-floor token until they finish. A job never throws.
1730
+ */
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;
1741
+ /**
1742
+ * Stage a runtime frame without overtaking a turn of the same channel that
1743
+ * is still queued behind the fast path (F2): while the channel has a queue,
1744
+ * the frame is staged durably but held, and released in queue order.
1745
+ */
1746
+ private stageRuntimeMessageInOrder;
1747
+ /** Test seam: resolves once every queued fast reply has settled. */
1748
+ settleFastReplies(): Promise<void>;
1749
+ /** Send a line the voice fast path speaks: its memory record is "voice" (H1). */
1750
+ private sendSpoken;
1751
+ /** Speak a fixed acknowledgement line; failures are counted, never thrown. */
1752
+ private sendFastLine;
1753
+ /**
1754
+ * Voice front desk, step 2 for a "fast" plan (runs off the ingest cycle):
1755
+ * the fast model's reply — or, in `ack_only`, the greeting template — is
1756
+ * spoken, and the outcome decides whether the runtime answers next.
1757
+ *
1758
+ * MUST NEVER throw: every failure path (memory lookup, the LLM call,
1759
+ * normalization, or the send itself) is caught, counted in
1760
+ * `fastPathFailed`, and logged WITHOUT the turn text or the model's reply.
1761
+ * The `fastReplyId(...)` send id is deterministic, so a duplicate that
1762
+ * slipped past the in-memory dedupe (e.g. a restart) resolves to the same
1763
+ * envelope message id rather than a second spoken reply.
1764
+ *
1765
+ * Room token reservation (controller ruling): the CALL SITE only plans a
1766
+ * fast path when the room bucket has headroom for this reply AND the
1767
+ * runtime's, and decides the buffered guidance from that same decision.
1768
+ */
1769
+ private runFastReply;
1770
+ /**
1771
+ * Durably stage a runtime frame WITHOUT handing it to the runtime yet (a
1772
+ * "fast" plan: the fast reply decides). Same durability and backpressure
1773
+ * as stageRuntimeMessage; a restart restores it as an ordinary pending
1774
+ * frame (the "fast path failed" semantics), so a crash mid-call never loses
1775
+ * the turn.
1776
+ */
1777
+ private stageHeldRuntimeMessage;
1778
+ /** Settle a held frame: the fast path answered alone (retire it) or the runtime answers next (release it, in channel order). */
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;
1666
1795
  /**
1667
1796
  * List channels in one enclave (default: the daemon's own home enclave).
1668
1797
  * Task 3: called per-*known* enclave by refreshChannelMeta so a
@@ -1717,6 +1846,15 @@ export declare class BridgeAgent {
1717
1846
  * 5xx, and genuine non-member 404s only.
1718
1847
  */
1719
1848
  private refreshChannelMeta;
1849
+ /**
1850
+ * Cache server-listed channel metadata (is_dm, name) for the receive cycle.
1851
+ * The ONLY input is a `listChannels` result, the same backend rows
1852
+ * refreshChannelMeta has always cached: refreshChannelMeta and the
1853
+ * self-heal sweep both feed it, so the sweep's own listing (every 90-120 s)
1854
+ * keeps the 5 min cache warm and a turn arriving after idle does not pay a
1855
+ * listing round trip per enclave before it reaches the runtime.
1856
+ */
1857
+ private recordListedChannelMeta;
1720
1858
  /** Cache for the server-side enclave enumeration (see enclavesToCover). */
1721
1859
  private enclaveEnumCache;
1722
1860
  /** Enclaves whose channel listing failed on the NEWEST sweep (audit HIGH:
@@ -1816,6 +1954,17 @@ export declare class BridgeAgent {
1816
1954
  * Missing or stale metadata is UNKNOWN with zero grants, never a permissive
1817
1955
  * guess. The returned object and grant array are frozen before they cross the
1818
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.
1819
1968
  */
1820
1969
  private authorizationFor;
1821
1970
  /**
@@ -1964,6 +2113,15 @@ export declare class BridgeAgent {
1964
2113
  */
1965
2114
  private ingestOnce;
1966
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;
1967
2125
  private ingestCycle;
1968
2126
  drain(channelId: string | undefined, waitSeconds: number): Promise<BridgeMessage[]>;
1969
2127
  /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
@@ -2164,6 +2322,12 @@ export declare class BridgeAgent {
2164
2322
  fast_path_failed: number;
2165
2323
  /** Task 4 (self-hosted fast voice): cumulative fast-path turns skipped in a room because the send-floor bucket lacked headroom for both the ack and the runtime's reply. */
2166
2324
  fast_path_skipped: number;
2325
+ /** Fast-path turns handed to the runtime behind a spoken ack (LLM [[DEFER]], pre-check, or ack_only). */
2326
+ fast_path_deferred: number;
2327
+ /** `full`-mode turns deferred by the deterministic pre-check (no fast model call). */
2328
+ fast_path_intent_deferred: number;
2329
+ /** Credential `voice_fast_path_mode`: "full" (default), "ack_only" or "off". */
2330
+ fast_path_mode: "full" | "ack_only" | "off";
2167
2331
  /** Plan 2026-09-06 (agent voice settings): version of the loaded backend
2168
2332
  * voice-settings row, or null when no backend row has ever loaded
2169
2333
  * (source is "file" or "none"). */
package/dist/config.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type VoiceConfig } from "./voice/persona.js";
2
+ import { type FastPathMode } from "./voice/defer-intent.js";
2
3
  export interface BridgeConfig {
3
4
  apiKey: string;
4
5
  identityId: string;
@@ -30,6 +31,13 @@ export interface BridgeConfig {
30
31
  memoryFactsCharBudget?: number;
31
32
  /** Optional voice persona + fast-path LLM config (self-hosted fast voice reply). Undefined when the credential has no `voice` block. */
32
33
  voice?: VoiceConfig;
34
+ /**
35
+ * Voice fast-path mode (credential top-level `voice_fast_path_mode`):
36
+ * "full" (default), "ack_only" or "off" — see voice/defer-intent.ts. Kept
37
+ * outside `voice` because backend-managed agents have no `voice` block and
38
+ * a backend voice-settings refetch replaces `voice` wholesale.
39
+ */
40
+ voiceFastPathMode?: FastPathMode;
33
41
  }
34
42
  export declare const REQUIRED: readonly ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
35
43
  export declare function loadConfig(opts: {