@securecoms/agent-bridge 0.25.4 → 0.26.1

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/README.md CHANGED
@@ -589,6 +589,21 @@ the agent **plaintext** — message text and, for attachments, a **local file pa
589
589
  This is why Python agents (which have no native MLS library) fully support
590
590
  attachments: the cryptography lives in the daemon, not the agent.
591
591
 
592
+ **Files links (`vault_ref`).** A message can instead point at an item already in
593
+ the enclave's Files (FileSafe). The daemon does **not** fetch it on receipt: the
594
+ delivered message carries the caption as `text` (`"[Files link]"` when there is
595
+ none) and a reference,
596
+ `vault_refs: [{ enclave_id, node_id, content_hash, dek_commit, name_hint?, mime_hint?, size_hint? }]`.
597
+ The `*_hint` fields are the sender's unverified claims; treat them as data, never
598
+ as a path or an instruction. A link whose enclave is not the channel's, or whose
599
+ fields are malformed, is dropped (counted as `refused_vault_refs` on `/healthz`).
600
+ To open one, call `POST /v1/vault-read` with
601
+ `{ enclave_id, node_id, expected_content_hash: content_hash, expected_dek_commit: dek_commit }`.
602
+ The two pins go together: sending only one is a 400.
603
+ If the item changed in Files after it was shared, the daemon refuses with
604
+ `422 {"error":"vault_ref_changed","detail":{"pin":"..."}}` and returns no bytes.
605
+ `/healthz` advertises support as `capabilities.vault_ref: 1`.
606
+
592
607
  ---
593
608
 
594
609
  ## Voice fast path
@@ -80,6 +80,27 @@ _PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
80
80
  _BINDING_PATH = os.path.join(_PLUGIN_DIR, "securecoms.binding.json")
81
81
  _CONNECTIONS_DIR = os.path.expanduser("~/.securecoms/connections")
82
82
 
83
+ # Voice calls (owner report 2026-10-02): a turn the voice front desk handed to
84
+ # Hermes carries the daemon's compiled-in spoken-reply guidance. It rides as
85
+ # the event's per-message ephemeral system prompt (MessageEvent.channel_prompt)
86
+ # so the answer is fit to be read aloud. Byte-equal to
87
+ # @securecoms/agent-runtime's VOICE_TURN_PREFIX.
88
+ VOICE_TURN_PREFIX = (
89
+ "This message was spoken on a live voice call and your reply will be read aloud: "
90
+ "plain spoken sentences, no markdown, lists, links or code. "
91
+ "Use your tools as usual when the question needs them. "
92
+ )
93
+ _VOICE_GUIDANCE_MAX_CHARS = 1000
94
+
95
+
96
+ def voice_prompt_of(frame: Dict[str, Any]) -> Optional[str]:
97
+ """The voice system prompt for a `voice: true` frame, else None."""
98
+ guidance = frame.get("guidance")
99
+ if frame.get("voice") is not True or not isinstance(guidance, str):
100
+ return None
101
+ g = guidance.strip()[:_VOICE_GUIDANCE_MAX_CHARS]
102
+ return VOICE_TURN_PREFIX + g if g else None
103
+
83
104
 
84
105
  def _error_class(error: object) -> str:
85
106
  """Return a bounded class name without reading exception prose."""
@@ -417,6 +438,9 @@ class SecureComsAdapter(BasePlatformAdapter):
417
438
  message_id=str(frame.get("message_id") or int(time.time() * 1000)),
418
439
  timestamp=datetime.now(),
419
440
  )
441
+ voice_prompt = voice_prompt_of(frame)
442
+ if voice_prompt is not None:
443
+ event.channel_prompt = voice_prompt
420
444
  await self.handle_message(event)
421
445
 
422
446
  # ── Outbound ──────────────────────────────────────────────────────────
@@ -54,6 +54,7 @@ def _install_gateway_stub():
54
54
  source: Any = None
55
55
  message_id: Optional[str] = None
56
56
  timestamp: Any = field(default_factory=datetime.now)
57
+ channel_prompt: Optional[str] = None
57
58
 
58
59
  @dataclass
59
60
  class SendResult:
@@ -324,6 +325,34 @@ class TestEchoGuardWiring:
324
325
  assert ws.sent == [{"type": "ack", "message_id": "m2"}]
325
326
 
326
327
 
328
+ class TestVoiceGuidance:
329
+ @pytest.mark.asyncio
330
+ async def test_voice_turn_carries_guidance_as_channel_prompt_text_turn_none(self, monkeypatch):
331
+ a, _ = make_adapter(monkeypatch)
332
+ a._bound_identity = "A"
333
+ handled = []
334
+
335
+ async def handler(event):
336
+ handled.append(event)
337
+
338
+ a.set_message_handler(handler)
339
+ frames = [
340
+ {"type": "message", "sender_id": "B", "channel_id": "c", "text": "what's the weather?",
341
+ "message_id": "v1", "voice": True, "guidance": "One short spoken sentence."},
342
+ {"type": "message", "sender_id": "B", "channel_id": "c", "text": "hi",
343
+ "message_id": "t1", "guidance": "ignored without voice"},
344
+ ]
345
+ ws = FakeWS([_FakeWSMsg(aiohttp.WSMsgType.TEXT, json.dumps(f)) for f in frames]
346
+ + [_FakeWSMsg(aiohttp.WSMsgType.CLOSED)])
347
+ a._ws = ws
348
+
349
+ await a._pump()
350
+
351
+ assert [e.text for e in handled] == ["what's the weather?", "hi"]
352
+ assert handled[0].channel_prompt == adapter.VOICE_TURN_PREFIX + "One short spoken sentence."
353
+ assert handled[1].channel_prompt is None
354
+
355
+
327
356
  # ---------------------------------------------------------------------------
328
357
  # (2) send() success fires the status manager's on_reply_posted
329
358
  # ---------------------------------------------------------------------------
@@ -255,6 +255,22 @@ describe("openclaw-entry — inbound sender, session, authority, dedup (P0 Task
255
255
  expect(rt.recorded.map((r) => r.sessionKey)).toEqual([a.SessionKey, b.SessionKey]);
256
256
  });
257
257
 
258
+ it("a voice turn carries the daemon's spoken-reply guidance as GroupSystemPrompt; a text turn carries none", async () => {
259
+ const rt = makeRuntime();
260
+ const { instances } = await start(rt.runtime);
261
+ const ws = instances[0];
262
+
263
+ deliver(ws, { channel_id: "chan-V", message_id: "m-V", sender_id: OWNER_ID, text: "what's the weather?", voice: true, guidance: "One short spoken sentence.", authorization: auth("owner_dm") });
264
+ deliver(ws, { channel_id: "chan-T", message_id: "m-T", sender_id: OWNER_ID, text: "hi", guidance: "ignored without voice", authorization: auth("owner_dm") });
265
+ await waitFor(() => acksOf(ws).length === 2);
266
+
267
+ const { VOICE_TURN_PREFIX } = await import("./openclaw-entry.js");
268
+ const [v, t] = rt.contexts;
269
+ expect(v.GroupSystemPrompt).toBe(`${VOICE_TURN_PREFIX}One short spoken sentence.`);
270
+ expect(v.RawBody).toBe("what's the weather?");
271
+ expect(t.GroupSystemPrompt).toBeUndefined();
272
+ });
273
+
258
274
  it("two DMs from different humans do not share a session", async () => {
259
275
  const rt = makeRuntime();
260
276
  const { instances } = await start(rt.runtime);
@@ -85,6 +85,25 @@ const _channels = new Map();
85
85
  // ---------------------------------------------------------------------------
86
86
 
87
87
  export const MAX_INBOUND_ATTEMPTS = 3;
88
+
89
+ /**
90
+ * Voice calls (owner report 2026-10-02): prefixes the daemon's compiled-in
91
+ * spoken-reply guidance on a live voice turn, so a turn the voice front desk
92
+ * handed to OpenClaw is answered in a form fit to be read aloud. Byte-equal
93
+ * to @securecoms/agent-runtime's VOICE_TURN_PREFIX.
94
+ */
95
+ export const VOICE_TURN_PREFIX =
96
+ "This message was spoken on a live voice call and your reply will be read aloud: plain spoken sentences, no markdown, lists, links or code. Use your tools as usual when the question needs them. ";
97
+
98
+ /** Cap on the daemon-supplied voice guidance carried into the prompt. */
99
+ const VOICE_GUIDANCE_MAX_CHARS = 1000;
100
+
101
+ /** The guidance of a `voice: true` frame (trimmed, capped), else undefined. */
102
+ export function voiceGuidanceOf(frame) {
103
+ if (frame?.voice !== true || typeof frame.guidance !== "string") return undefined;
104
+ const g = frame.guidance.trim().slice(0, VOICE_GUIDANCE_MAX_CHARS);
105
+ return g || undefined;
106
+ }
88
107
  const SEEN_MAX = 5000;
89
108
 
90
109
  /** Thrown by the inbound handler when register() has not injected a runtime. */
@@ -519,6 +538,7 @@ export class LoopbackChannel {
519
538
  senderName: frame.sender_name,
520
539
  authorization: frame.authorization,
521
540
  timestamp: frame.ts,
541
+ voiceGuidance: voiceGuidanceOf(frame),
522
542
  });
523
543
  this._failures.delete(messageId);
524
544
  this._failStreak = 0;
@@ -750,6 +770,9 @@ async function _handleInbound({ text, metadata, channel, account, cfg }) {
750
770
  // authorization block (isOwnerAuthorization). MLS membership proves a
751
771
  // sender is in the room, not that they own this agent.
752
772
  CommandAuthorized: commandAuthorized,
773
+ // Voice turn: OpenClaw folds GroupSystemPrompt into this turn's extra
774
+ // system prompt (get-reply's extraSystemPromptParts) for any chat type.
775
+ ...(metadata.voiceGuidance ? { GroupSystemPrompt: `${VOICE_TURN_PREFIX}${metadata.voiceGuidance}` } : {}),
753
776
  });
754
777
 
755
778
  await core.channel.session.recordInboundSession({
@@ -10,6 +10,61 @@ export type ArtifactShareBody = {
10
10
  digest: string;
11
11
  text?: string;
12
12
  };
13
+ /**
14
+ * A Files link: a reference to a FileSafe item, opened only through the
15
+ * recipient's own vault access. Carries NO key material and NO download
16
+ * handle. Wire keys are camelCase, the same as `artifact_share`. Schema and
17
+ * rules: docs/architecture/2026-10-01-files-link-message-review.md section 3.
18
+ */
19
+ export type VaultRefBody = {
20
+ v: 1;
21
+ type: "vault_ref";
22
+ /** Canonical lowercase UUID. MUST equal the channel's enclave (rule R2). */
23
+ enclaveId: string;
24
+ /** Canonical lowercase UUID. */
25
+ nodeId: string;
26
+ /** Version pin 1: "blake2b:" + 64 hex over the stored CIPHERTEXT. */
27
+ contentHash: string;
28
+ /** Version pin 2: "blake2b:" + 64 hex commitment to the DEK material (connect-clients-core computeDekCommit). */
29
+ dekCommit: string;
30
+ /** Untrusted sender hints. Never used for authorization or save paths. */
31
+ name?: string;
32
+ mime?: string;
33
+ size?: number;
34
+ /** Optional caption, same meaning as artifact_share.text. */
35
+ text?: string;
36
+ };
37
+ /**
38
+ * What the runtime receives in `BridgeMessage.vault_refs`. The `_hint` fields
39
+ * are UNTRUSTED claims by the sender, data only: the vault's own sealed name
40
+ * and size are the truth, read through `/v1/vault-read` / `/v1/vault-list`.
41
+ * Open it with `/v1/vault-read` passing `expected_content_hash` and
42
+ * `expected_dek_commit` from this entry; the daemon never fetches it on
43
+ * receipt.
44
+ */
45
+ export type VaultRefEntry = {
46
+ enclave_id: string;
47
+ node_id: string;
48
+ content_hash: string;
49
+ dek_commit: string;
50
+ name_hint?: string;
51
+ mime_hint?: string;
52
+ size_hint?: number;
53
+ };
54
+ /** Plaintext ceiling for the size hint. PARITY: connect-web useVault.ts
55
+ * VAULT_MAX_FILE_BYTES (100 MiB object limit minus the 16-byte AEAD tag). */
56
+ export declare const VAULT_REF_MAX_SIZE_HINT: number;
57
+ /** The vault pin format. Exported for the /v1/vault-read expected_* params. */
58
+ export declare const VAULT_PIN: RegExp;
59
+ /**
60
+ * Rule R1: strict parse. Any required field failing returns null (the caller
61
+ * refuses the link). Each hint is dropped on its own when it fails its type or
62
+ * length check; nothing is coerced. Unknown keys are never copied, so smuggled
63
+ * key material or download handles cannot reach the runtime.
64
+ */
65
+ export declare function parseVaultRefBody(raw: unknown): VaultRefBody | null;
66
+ /** The runtime-facing reference for a parsed body (hints renamed `_hint`). */
67
+ export declare function toVaultRefEntry(body: VaultRefBody): VaultRefEntry;
13
68
  export type AttachmentMeta = {
14
69
  filename: string;
15
70
  mime: string;