@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 +15 -0
- package/connectors/hermes/adapter.py +24 -0
- package/connectors/hermes/tests/test_adapter_wiring.py +29 -0
- package/connectors/openclaw/openclaw-entry.inbound.test.ts +16 -0
- package/connectors/openclaw/openclaw-entry.js +23 -0
- package/dist/attachments.d.ts +55 -0
- package/dist/bin.js +500 -81
- package/dist/bridge-agent.d.ts +110 -35
- package/dist/config.d.ts +8 -0
- package/dist/errors.d.ts +1 -1
- package/dist/http-server.d.ts +5 -4
- package/dist/index.js +500 -81
- package/dist/vault.d.ts +23 -0
- package/dist/version.d.ts +1 -1
- package/dist/voice/defer-intent.d.ts +22 -0
- package/dist/voice/fast-prompt.d.ts +14 -0
- package/dist/voice/prompt-compiler.d.ts +8 -0
- package/package.json +1 -1
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({
|
package/dist/attachments.d.ts
CHANGED
|
@@ -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;
|