@perkos/perkos-voice 0.1.0
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/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/a2aEnrollment.d.ts +41 -0
- package/dist/a2aEnrollment.js +110 -0
- package/dist/acceptance.d.ts +25 -0
- package/dist/acceptance.js +147 -0
- package/dist/acceptanceCli.d.ts +2 -0
- package/dist/acceptanceCli.js +6 -0
- package/dist/adapters/livekit.d.ts +15 -0
- package/dist/adapters/livekit.js +236 -0
- package/dist/adapters/openaiSpeech.d.ts +24 -0
- package/dist/adapters/openaiSpeech.js +194 -0
- package/dist/adapters/openclaw.d.ts +58 -0
- package/dist/adapters/openclaw.js +236 -0
- package/dist/adapters/speech.d.ts +13 -0
- package/dist/adapters/speech.js +57 -0
- package/dist/adapters/zeroclaw.d.ts +12 -0
- package/dist/adapters/zeroclaw.js +36 -0
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.js +68 -0
- package/dist/bragiDelivery.d.ts +31 -0
- package/dist/bragiDelivery.js +263 -0
- package/dist/bragiDeliveryCli.d.ts +2 -0
- package/dist/bragiDeliveryCli.js +32 -0
- package/dist/capability.d.ts +15 -0
- package/dist/capability.js +84 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +188 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +107 -0
- package/dist/doctor.d.ts +55 -0
- package/dist/doctor.js +423 -0
- package/dist/doctorCli.d.ts +2 -0
- package/dist/doctorCli.js +36 -0
- package/dist/echoSuppression.d.ts +13 -0
- package/dist/echoSuppression.js +42 -0
- package/dist/fakes.d.ts +42 -0
- package/dist/fakes.js +80 -0
- package/dist/gateway.d.ts +46 -0
- package/dist/gateway.js +418 -0
- package/dist/grants.d.ts +22 -0
- package/dist/grants.js +40 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/installer.d.ts +41 -0
- package/dist/installer.js +83 -0
- package/dist/mediaMetrics.d.ts +15 -0
- package/dist/mediaMetrics.js +43 -0
- package/dist/mediaStages.d.ts +12 -0
- package/dist/mediaStages.js +48 -0
- package/dist/onboarding.d.ts +67 -0
- package/dist/onboarding.js +72 -0
- package/dist/openclaw-plugin.d.ts +16 -0
- package/dist/openclaw-plugin.js +47 -0
- package/dist/ports.d.ts +23 -0
- package/dist/ports.js +1 -0
- package/dist/presenceTone.d.ts +5 -0
- package/dist/presenceTone.js +43 -0
- package/dist/readiness.d.ts +27 -0
- package/dist/readiness.js +68 -0
- package/dist/service.d.ts +10 -0
- package/dist/service.js +42 -0
- package/dist/sessionControl.d.ts +55 -0
- package/dist/sessionControl.js +118 -0
- package/dist/speechErrors.d.ts +18 -0
- package/dist/speechErrors.js +26 -0
- package/dist/state-machine.d.ts +55 -0
- package/dist/state-machine.js +107 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/voiceSubtask.d.ts +12 -0
- package/dist/voiceSubtask.js +46 -0
- package/dist/workCallContext.d.ts +14 -0
- package/dist/workCallContext.js +26 -0
- package/docs/external-agent-onboarding.md +152 -0
- package/external-agent-contract.schema.json +46 -0
- package/openclaw.plugin.json +27 -0
- package/package.json +78 -0
- package/scripts/hermes/install.mjs +70 -0
- package/scripts/run-with-env.mjs +23 -0
- package/scripts/zeroclaw/install.mjs +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PerkOS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# PerkOS Voice
|
|
2
|
+
|
|
3
|
+
Runtime-facing voice gateway contracts and orchestration for PerkOS agents.
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
PerkOS Web ── media ── LiveKit ── media ── PerkOS Voice Gateway ── agent runtime
|
|
7
|
+
│ │
|
|
8
|
+
└──────── control: PerkOS API/Meetings ┘
|
|
9
|
+
|
|
10
|
+
PerkOS-A2A and PerkOS-Chat: capability/control only; never audio or transcripts.
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Runnable gateway foundation
|
|
14
|
+
|
|
15
|
+
This repository now provides a container-ready gateway foundation:
|
|
16
|
+
|
|
17
|
+
- strict, provider-neutral voice capability handshake validation;
|
|
18
|
+
- availability decisions for absent/invalid capability, ownership, expiry,
|
|
19
|
+
requested mode, interruption, and transcript support;
|
|
20
|
+
- an explicit session state machine covering authorization, join, listening,
|
|
21
|
+
thinking, speaking, correlated cancellation/barge-in, failure, and closure;
|
|
22
|
+
- a real outbound LiveKit Node adapter with microphone-only publication;
|
|
23
|
+
- a provider-neutral BYO speech HTTP boundary;
|
|
24
|
+
- a verified OpenClaw OpenResponses runtime adapter (not Talk integration);
|
|
25
|
+
- file-based or authenticated M2M short-lived grant delivery;
|
|
26
|
+
- `/health`, `/ready`, and strictly redacted `/capabilities` endpoints;
|
|
27
|
+
- deterministic in-memory/fake implementations and gateway orchestration;
|
|
28
|
+
- an API-level rule that `saved` transcripts require explicit consent.
|
|
29
|
+
|
|
30
|
+
This phase includes no paid speech provider, provider account or key, deployment,
|
|
31
|
+
Bragi mutation, Web change, audio/transcript persistence, or content logging.
|
|
32
|
+
`off` is the default transcript policy. Saved transcripts are not advertised or
|
|
33
|
+
accepted by this runnable adapter.
|
|
34
|
+
|
|
35
|
+
External invited agents use `external_owner` (BYO Voice): the owner configures
|
|
36
|
+
and pays the provider beside their runtime. PerkOS receives only the public
|
|
37
|
+
capability handshake and an agent-scoped authorization grant. Provider names,
|
|
38
|
+
credentials, raw audio, transcripts, and private addresses are rejected from or
|
|
39
|
+
absent in the public control contracts.
|
|
40
|
+
|
|
41
|
+
## Local development
|
|
42
|
+
|
|
43
|
+
Requires Node.js 20+.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm ci
|
|
47
|
+
npm run lint
|
|
48
|
+
npm run build
|
|
49
|
+
npm test
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The tests use no live networking, LiveKit room, or speech provider.
|
|
53
|
+
|
|
54
|
+
External-agent installation and acceptance are standardized by
|
|
55
|
+
`external-agent-contract.schema.json` and
|
|
56
|
+
`docs/external-agent-onboarding.md`. The contract is deliberately independent
|
|
57
|
+
of the agent's chat surface and its preflight reports only allow-listed stage
|
|
58
|
+
codes.
|
|
59
|
+
|
|
60
|
+
Runtime installation follows a hybrid plugin model. Hermes, OpenClaw and
|
|
61
|
+
ZeroClaw use thin native plugins that manage the same immutable Voice gateway;
|
|
62
|
+
the plugins do not carry media or expose private chat. The neutral install
|
|
63
|
+
contract and built-in profiles are exported from `src/installer.ts`. See
|
|
64
|
+
`docs/plans/2026-09-01-standard-runtime-plugin-installer-design.md`.
|
|
65
|
+
|
|
66
|
+
When PerkOS-A2A is already installed, optional Voice enrollment uses two exact
|
|
67
|
+
control markers: `PERKOS_VOICE_PROBE` and `PERKOS_VOICE_ENROLL`. The exported
|
|
68
|
+
`@perkos/perkos-voice/a2a` boundary reports runtime support and writes a claimed
|
|
69
|
+
credential directly to a mode-0600 plugin file. It never returns the credential
|
|
70
|
+
to A2A or the model. Enrollment remains action-required until the native Voice
|
|
71
|
+
service and owner-selected speech provider are ready and publish capability.
|
|
72
|
+
|
|
73
|
+
OpenClaw installs the npm extension and supervises the gateway as a native
|
|
74
|
+
service. Hermes uses the equivalent managed-service emitter:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
perkos-voice-install-hermes --emit \
|
|
78
|
+
--agent-id AGENT_ID --agent-name Athena --environment dev \
|
|
79
|
+
--voice-env-file ~/.perkos/voice/athena-dev.env \
|
|
80
|
+
--secret-file ~/.perkos/voice/athena-dev.secrets.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Review the emitted launchd plist or systemd unit before installing it. Secrets
|
|
84
|
+
remain in the referenced mode-0600 file and are never embedded in the unit.
|
|
85
|
+
|
|
86
|
+
ZeroClaw uses the same review-first service model because its sandboxed WASM
|
|
87
|
+
plugins cannot supervise host processes or read the gateway secret file. The
|
|
88
|
+
installer binds the verified authenticated local `POST /webhook` adapter:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
perkos-voice-install-zeroclaw --emit \
|
|
92
|
+
--agent-id AGENT_ID --agent-name Nova --environment dev \
|
|
93
|
+
--voice-env-file ~/.perkos/voice/nova-dev.env \
|
|
94
|
+
--secret-file ~/.perkos/voice/nova-dev.secrets.json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Do not expose ZeroClaw's webhook publicly. The installer rejects non-loopback,
|
|
98
|
+
credential-bearing, query-bearing, or non-`/webhook` runtime URLs.
|
|
99
|
+
|
|
100
|
+
## Configuration and startup
|
|
101
|
+
|
|
102
|
+
Copy `config.example.env` only as a field reference. The production container
|
|
103
|
+
expects a root-owned mode-0600 JSON file shaped like
|
|
104
|
+
`config.secrets.example.json` at `VOICE_SECRET_SOURCE_FILE`. Its audited
|
|
105
|
+
bootstrap copies it into a 0600 least-privilege path on required tmpfs, clears
|
|
106
|
+
its temporary buffer, drops supplementary groups and root privileges, and only
|
|
107
|
+
then starts the gateway. Do not commit real values. LiveKit grants are either mounted at
|
|
108
|
+
`VOICE_GRANT_FILE` or fetched just in time from `VOICE_GRANT_ENDPOINT` with the
|
|
109
|
+
separate gateway credential in the secret file. Tokens must never be placed in
|
|
110
|
+
URLs, logs, A2A, or Chat.
|
|
111
|
+
|
|
112
|
+
The generic BYO speech sidecar contract is:
|
|
113
|
+
|
|
114
|
+
- `POST <VOICE_SPEECH_ENDPOINT>/transcribe`, body `audio/pcm`, response `{ "text": "..." }`;
|
|
115
|
+
- `POST <VOICE_SPEECH_ENDPOINT>/synthesize`, JSON request, streaming signed
|
|
116
|
+
16-bit little-endian 48 kHz mono PCM response.
|
|
117
|
+
|
|
118
|
+
The external owner operates that sidecar and retains the provider key. PerkOS
|
|
119
|
+
does not select a provider or receive its key. The OpenClaw adapter supports
|
|
120
|
+
the local `/v1/responses` interface and the OpenAI-compatible
|
|
121
|
+
`/v1/chat/completions` interface. Select the latter with
|
|
122
|
+
`OPENCLAW_API_MODE=chat_completions`, `OPENCLAW_ENDPOINT`, and an explicit
|
|
123
|
+
`OPENCLAW_CHAT_MODEL` that routes to Bragi's own runtime. Tool execution stays
|
|
124
|
+
inside OpenClaw. `UnavailableOpenClawTalkAdapter`
|
|
125
|
+
exists explicitly because a supported Talk runtime contract has not been
|
|
126
|
+
verified.
|
|
127
|
+
|
|
128
|
+
For the cost-controlled OpenAI pilot, set `VOICE_SPEECH_MODE=openai_turn` and
|
|
129
|
+
place `openAiApiKey` only in the mounted runtime secret JSON. The gateway uses
|
|
130
|
+
the Audio transcription and speech endpoints, never the Realtime API. Input is
|
|
131
|
+
segmented into bounded silence-delimited turns; the default mini models limit
|
|
132
|
+
cost, and audio/transcript content is neither logged nor persisted. Provider
|
|
133
|
+
readiness probes model metadata and does not submit billable speech content.
|
|
134
|
+
|
|
135
|
+
When `VOICE_CAPABILITY_PUBLISH_ENDPOINT` and its separate runtime credential
|
|
136
|
+
are configured, the gateway publishes only the strict public capability
|
|
137
|
+
handshake after provider and OpenClaw probes pass. A failed probe publishes an
|
|
138
|
+
unavailable handshake on the next refresh. The receiving control plane must
|
|
139
|
+
authenticate, validate, expire, and serve that handshake to Web.
|
|
140
|
+
`VOICE_AGENT_ID` remains the enrolled agent identifier used by the endpoint,
|
|
141
|
+
M2M authorization, and dynamic session control. Set the non-secret
|
|
142
|
+
`VOICE_CANONICAL_AGENT_NAME` to the registry name placed in the public
|
|
143
|
+
capability payload; production publication fails closed when it is absent.
|
|
144
|
+
|
|
145
|
+
Dynamic production sessions use `VOICE_SESSION_CONTROL_ENDPOINT`. The gateway
|
|
146
|
+
polls with its existing M2M credential, atomically claims only a matching
|
|
147
|
+
short-lived agent session, obtains the existing scoped LiveKit grant, and
|
|
148
|
+
reports joined/completed/failed lifecycle state. Project and meeting IDs are no
|
|
149
|
+
longer production startup settings; they arrive only in the claimed,
|
|
150
|
+
consent-bound session. Cancellation or expiry closes the media room fail-closed.
|
|
151
|
+
|
|
152
|
+
The control plane also snapshots the owner-selected `speechVoice` into each
|
|
153
|
+
claimed session. OpenAI turn synthesis uses that allow-listed value without a
|
|
154
|
+
gateway restart. Precedence is `OPENAI_SPEECH_VOICE_OVERRIDE` (optional operator
|
|
155
|
+
emergency override), then the session preference, then legacy
|
|
156
|
+
`OPENAI_SPEECH_VOICE`, then `alloy`. `speechVoice` is public configuration only;
|
|
157
|
+
provider credentials and endpoints remain runtime secrets.
|
|
158
|
+
|
|
159
|
+
The initial Bragi pilot also includes a root-only outbound encrypted-delivery
|
|
160
|
+
receiver. It uses an RSA-3072 ephemeral key, signed one-use claim, OAEP-SHA256
|
|
161
|
+
decryption, signed nonce-protected in-memory delivery discovery, atomic
|
|
162
|
+
mode-0600 gateway-secret update, and a credential-derived
|
|
163
|
+
receipt proof that does not transmit the bearer credential. See
|
|
164
|
+
`docs/plans/2026-08-14-bragi-encrypted-server-delivery.md`. This bridge is
|
|
165
|
+
deliberately bound to Bragi and is not a general enrollment workflow.
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
docker build -t perkos-voice:local .
|
|
169
|
+
docker compose -f docker-compose.production.example.yml config
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The Compose file is a host-specific template: use an immutable release image,
|
|
173
|
+
replace only the root-owned source path, and keep its read-only filesystem,
|
|
174
|
+
restricted capabilities and tmpfs mount. `VOICE_RUNTIME_REQUIRE_TMPFS=false`
|
|
175
|
+
exists for local diagnostics only and must not be used for production.
|
|
176
|
+
The runtime image installs Debian's standard `ca-certificates` bundle and
|
|
177
|
+
fails its image build if that trust bundle is absent or empty. TLS verification
|
|
178
|
+
remains enabled; no custom CA path or `NODE_EXTRA_CA_CERTS` override is needed.
|
|
179
|
+
|
|
180
|
+
Liveness is `GET /health`. Readiness is `GET /ready` and returns 503 until
|
|
181
|
+
grant delivery, speech-provider access, and local OpenClaw access are probed.
|
|
182
|
+
`GET /capabilities` is always public-safe and never includes tokens, provider
|
|
183
|
+
details, private addresses, audio, or transcripts.
|
|
184
|
+
|
|
185
|
+
Session failures emit only a fixed stage enum (`grant_obtain`,
|
|
186
|
+
`livekit_connect`, `audio_source_create`, `track_create`, `track_publish`,
|
|
187
|
+
`gateway_start`, `status_joined`, or `turn_loop`). Upstream errors, URLs,
|
|
188
|
+
tokens, identifiers, audio, and transcript content are never copied into logs.
|
|
189
|
+
LiveKit connect and initial track publication are bounded and clean up partial
|
|
190
|
+
room/track state before returning a fixed failure.
|
|
191
|
+
|
|
192
|
+
## Public surface
|
|
193
|
+
|
|
194
|
+
The package root exports public types, capability validation/reasoning, the
|
|
195
|
+
state machine, gateway, provider-neutral ports, and deterministic fakes. The
|
|
196
|
+
existing `@perkos/perkos-voice/types` subpath remains compatible.
|
|
197
|
+
|
|
198
|
+
## Integration handoff
|
|
199
|
+
|
|
200
|
+
1. **PerkOS API/Meetings** should authenticate the caller and later mint a
|
|
201
|
+
short-lived `VoiceGatewayGrant` scoped to the exact meeting and agent. Tokens
|
|
202
|
+
stay out of A2A/Chat payloads and logs.
|
|
203
|
+
2. **PerkOS Web** should request that grant, join LiveKit directly, and render
|
|
204
|
+
the state/cancellation outcomes. It must not receive provider credentials.
|
|
205
|
+
3. **Agent runtimes** should run this gateway beside the runtime and connect
|
|
206
|
+
outbound to LiveKit. External agents declare `external_owner`; managed AWS
|
|
207
|
+
agents will use future server-side managed adapters.
|
|
208
|
+
4. **PerkOS-A2A/Chat** may exchange the allow-listed capability handshake and
|
|
209
|
+
session control identifiers only. They must never carry audio chunks, base64
|
|
210
|
+
audio, provider keys, or raw transcripts.
|
|
211
|
+
5. A production deployment still requires an owner-supplied speech adapter,
|
|
212
|
+
an enrolled M2M credential, operational LiveKit/OpenClaw configuration, and
|
|
213
|
+
an API consumer for the verified capability publication.
|
|
214
|
+
Bragi remains unavailable until those separate operations are approved.
|
|
215
|
+
|
|
216
|
+
The P0 design remains in `docs/plans/2026-08-14-p0-voice-gateway-design.md`;
|
|
217
|
+
the runnable phase note is in
|
|
218
|
+
`docs/plans/2026-08-14-runnable-gateway-foundation.md`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type RuntimeProtocol, type SupportedRuntime } from "./installer.js";
|
|
2
|
+
export declare const PERKOS_VOICE_PROBE_MARKER: "PERKOS_VOICE_PROBE";
|
|
3
|
+
export declare const PERKOS_VOICE_ENROLL_MARKER: "PERKOS_VOICE_ENROLL";
|
|
4
|
+
export type A2AVoiceEnrollment = {
|
|
5
|
+
version: "perkos.voice.a2a-enrollment/v1";
|
|
6
|
+
agentId: string;
|
|
7
|
+
agentName: string;
|
|
8
|
+
environment: "dev" | "qa" | "production";
|
|
9
|
+
credential: string;
|
|
10
|
+
audience: "perkos-voice-gateway-grant:v1";
|
|
11
|
+
expiresAt: string;
|
|
12
|
+
sessionControlEndpoint: string;
|
|
13
|
+
grantEndpoint: string;
|
|
14
|
+
capabilityPublishEndpoint: string;
|
|
15
|
+
};
|
|
16
|
+
export type A2AVoiceHandlerOptions = {
|
|
17
|
+
apiBaseUrl: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
relayApiKey: string;
|
|
20
|
+
runtime: SupportedRuntime;
|
|
21
|
+
protocol?: RuntimeProtocol;
|
|
22
|
+
fetcher?: typeof fetch;
|
|
23
|
+
configure: (enrollment: A2AVoiceEnrollment) => Promise<{
|
|
24
|
+
ready: boolean;
|
|
25
|
+
actionCode?: string;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Persist a claimed enrollment at the native plugin boundary. The durable
|
|
30
|
+
* credential never returns to A2A or the model. Runtime installers may watch
|
|
31
|
+
* this file and complete service activation; until then the safe response is
|
|
32
|
+
* action-required rather than a false ready signal.
|
|
33
|
+
*/
|
|
34
|
+
export declare function configureA2AVoiceEnrollment(enrollment: A2AVoiceEnrollment, options?: {
|
|
35
|
+
stateDirectory?: string;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
ready: false;
|
|
38
|
+
actionCode: "service_activation_required";
|
|
39
|
+
}>;
|
|
40
|
+
/** Handle only exact fixed markers. Arbitrary natural-language prompts remain normal agent input. */
|
|
41
|
+
export declare function handleA2AVoiceMarker(message: string, options: A2AVoiceHandlerOptions): Promise<string | null>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { RUNTIME_PROFILES } from "./installer.js";
|
|
2
|
+
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
export const PERKOS_VOICE_PROBE_MARKER = "PERKOS_VOICE_PROBE";
|
|
7
|
+
export const PERKOS_VOICE_ENROLL_MARKER = "PERKOS_VOICE_ENROLL";
|
|
8
|
+
/**
|
|
9
|
+
* Persist a claimed enrollment at the native plugin boundary. The durable
|
|
10
|
+
* credential never returns to A2A or the model. Runtime installers may watch
|
|
11
|
+
* this file and complete service activation; until then the safe response is
|
|
12
|
+
* action-required rather than a false ready signal.
|
|
13
|
+
*/
|
|
14
|
+
export async function configureA2AVoiceEnrollment(enrollment, options = {}) {
|
|
15
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{2,127}$/u.test(enrollment.agentId))
|
|
16
|
+
throw new Error("agent_id_invalid");
|
|
17
|
+
const root = options.stateDirectory ?? join(homedir(), ".perkos", "voice");
|
|
18
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
19
|
+
await chmod(root, 0o700);
|
|
20
|
+
const target = join(root, `${enrollment.agentId}-${enrollment.environment}.enrollment.json`);
|
|
21
|
+
const temporary = join(root, `.${enrollment.agentId}-${randomUUID()}.tmp`);
|
|
22
|
+
const document = JSON.stringify({
|
|
23
|
+
version: enrollment.version,
|
|
24
|
+
agentId: enrollment.agentId,
|
|
25
|
+
agentName: enrollment.agentName,
|
|
26
|
+
environment: enrollment.environment,
|
|
27
|
+
grantCredential: enrollment.credential,
|
|
28
|
+
capabilityPublishCredential: enrollment.credential,
|
|
29
|
+
expiresAt: enrollment.expiresAt,
|
|
30
|
+
sessionControlEndpoint: enrollment.sessionControlEndpoint,
|
|
31
|
+
grantEndpoint: enrollment.grantEndpoint,
|
|
32
|
+
capabilityPublishEndpoint: enrollment.capabilityPublishEndpoint,
|
|
33
|
+
});
|
|
34
|
+
await writeFile(temporary, `${document}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
35
|
+
await rename(temporary, target);
|
|
36
|
+
await chmod(target, 0o600);
|
|
37
|
+
return { ready: false, actionCode: "service_activation_required" };
|
|
38
|
+
}
|
|
39
|
+
function endpoint(options, suffix) {
|
|
40
|
+
return `${options.apiBaseUrl.replace(/\/$/u, "")}/agents/${encodeURIComponent(options.agentId)}/voice-credential/${suffix}`;
|
|
41
|
+
}
|
|
42
|
+
function supported(options) {
|
|
43
|
+
const profile = RUNTIME_PROFILES[options.runtime];
|
|
44
|
+
return Boolean(profile && (!options.protocol || profile.protocols.includes(options.protocol)));
|
|
45
|
+
}
|
|
46
|
+
async function post(options, suffix, body) {
|
|
47
|
+
return (options.fetcher ?? fetch)(endpoint(options, suffix), {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: {
|
|
50
|
+
authorization: `Bearer ${options.relayApiKey}`,
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify(body ?? {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function safeFailure(response) {
|
|
57
|
+
if (response.status === 401 || response.status === 403)
|
|
58
|
+
return "auth_rejected";
|
|
59
|
+
if (response.status === 404)
|
|
60
|
+
return "agent_not_found";
|
|
61
|
+
if (response.status === 409)
|
|
62
|
+
return "enrollment_not_pending";
|
|
63
|
+
if (response.status === 410)
|
|
64
|
+
return "enrollment_expired";
|
|
65
|
+
return "control_plane_unavailable";
|
|
66
|
+
}
|
|
67
|
+
function validEnrollment(value) {
|
|
68
|
+
const item = value && typeof value === "object" ? value : {};
|
|
69
|
+
return item.version === "perkos.voice.a2a-enrollment/v1"
|
|
70
|
+
&& typeof item.agentId === "string"
|
|
71
|
+
&& typeof item.agentName === "string"
|
|
72
|
+
&& (item.environment === "dev" || item.environment === "qa" || item.environment === "production")
|
|
73
|
+
&& typeof item.credential === "string" && item.credential.startsWith("vgc_")
|
|
74
|
+
&& item.audience === "perkos-voice-gateway-grant:v1"
|
|
75
|
+
&& typeof item.expiresAt === "string" && Number.isFinite(Date.parse(item.expiresAt))
|
|
76
|
+
&& [item.sessionControlEndpoint, item.grantEndpoint, item.capabilityPublishEndpoint]
|
|
77
|
+
.every((entry) => typeof entry === "string" && /^https?:\/\//u.test(entry));
|
|
78
|
+
}
|
|
79
|
+
/** Handle only exact fixed markers. Arbitrary natural-language prompts remain normal agent input. */
|
|
80
|
+
export async function handleA2AVoiceMarker(message, options) {
|
|
81
|
+
const marker = message.trim();
|
|
82
|
+
if (marker !== PERKOS_VOICE_PROBE_MARKER && marker !== PERKOS_VOICE_ENROLL_MARKER)
|
|
83
|
+
return null;
|
|
84
|
+
if (marker === PERKOS_VOICE_PROBE_MARKER) {
|
|
85
|
+
const isSupported = supported(options);
|
|
86
|
+
const response = await post(options, "capability-report", isSupported
|
|
87
|
+
? { state: "available", runtime: options.runtime }
|
|
88
|
+
: { state: "unsupported", runtime: options.runtime, reasonCode: "runtime_protocol_unsupported" });
|
|
89
|
+
if (!response.ok)
|
|
90
|
+
return `PERKOS_VOICE_FAILED:${safeFailure(response)}`;
|
|
91
|
+
return isSupported ? "PERKOS_VOICE_AVAILABLE" : "PERKOS_VOICE_UNSUPPORTED:runtime_protocol_unsupported";
|
|
92
|
+
}
|
|
93
|
+
if (!supported(options))
|
|
94
|
+
return "PERKOS_VOICE_FAILED:runtime_protocol_unsupported";
|
|
95
|
+
const response = await post(options, "claim-a2a");
|
|
96
|
+
if (!response.ok)
|
|
97
|
+
return `PERKOS_VOICE_FAILED:${safeFailure(response)}`;
|
|
98
|
+
const payload = await response.json().catch(() => null);
|
|
99
|
+
if (!validEnrollment(payload?.enrollment))
|
|
100
|
+
return "PERKOS_VOICE_FAILED:invalid_enrollment";
|
|
101
|
+
try {
|
|
102
|
+
const result = await options.configure(payload.enrollment);
|
|
103
|
+
if (result.ready)
|
|
104
|
+
return "PERKOS_VOICE_READY";
|
|
105
|
+
return `PERKOS_VOICE_ACTION_REQUIRED:${result.actionCode ?? "doctor_not_ready"}`;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return "PERKOS_VOICE_FAILED:local_configuration_failed";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface AcceptanceResult {
|
|
2
|
+
schemaVersion: 1;
|
|
3
|
+
mode: "local" | "staging";
|
|
4
|
+
status: "passed" | "failed";
|
|
5
|
+
checks: {
|
|
6
|
+
turnsCompleted: number;
|
|
7
|
+
stallRecovered: boolean;
|
|
8
|
+
selfLoopSuppressed: boolean;
|
|
9
|
+
audioPublished: boolean;
|
|
10
|
+
closed: boolean;
|
|
11
|
+
rawFiles: number;
|
|
12
|
+
};
|
|
13
|
+
stages: {
|
|
14
|
+
stt: number;
|
|
15
|
+
runtime: number;
|
|
16
|
+
ttsFirstByte: number;
|
|
17
|
+
ttsCompleted: number;
|
|
18
|
+
audioStarted: number;
|
|
19
|
+
audioCompleted: number;
|
|
20
|
+
};
|
|
21
|
+
failure?: "configuration" | "timeout" | "pipeline";
|
|
22
|
+
}
|
|
23
|
+
export declare function runLocalAcceptance(): Promise<AcceptanceResult>;
|
|
24
|
+
export declare function runStagingAcceptance(env: NodeJS.ProcessEnv): Promise<AcceptanceResult>;
|
|
25
|
+
export declare function localEnvironmentIsSafe(env: NodeJS.ProcessEnv): boolean;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { VoiceGateway } from "./gateway.js";
|
|
5
|
+
import { TtsError } from "./speechErrors.js";
|
|
6
|
+
import { OpenAiTurnSpeechAdapter } from "./adapters/openaiSpeech.js";
|
|
7
|
+
import { OpenClawChatCompletionsAdapter } from "./adapters/openclaw.js";
|
|
8
|
+
import { EchoSuppressionGate } from "./echoSuppression.js";
|
|
9
|
+
async function* chunks(value) { yield value; }
|
|
10
|
+
class AcceptanceRoom {
|
|
11
|
+
turns;
|
|
12
|
+
audioStarted = 0;
|
|
13
|
+
audioCompleted = 0;
|
|
14
|
+
cancelled = 0;
|
|
15
|
+
closed = false;
|
|
16
|
+
constructor(audio = [new Uint8Array([1, 0]), new Uint8Array([2, 0])]) { this.turns = audio.map((value, index) => ({ turnId: `local-${index + 1}`, audio: chunks(value) })); }
|
|
17
|
+
async join(_grant) { }
|
|
18
|
+
async receiveTurn() { const turn = this.turns.shift(); if (!turn)
|
|
19
|
+
throw new Error("acceptance exhausted"); return turn; }
|
|
20
|
+
async publish(_turnId, audio) { let started = false; for await (const _ of audio) {
|
|
21
|
+
if (!started) {
|
|
22
|
+
started = true;
|
|
23
|
+
this.audioStarted += 1;
|
|
24
|
+
}
|
|
25
|
+
} if (started)
|
|
26
|
+
this.audioCompleted += 1; return "completed"; }
|
|
27
|
+
async cancelPublish(_turnId) { this.cancelled += 1; }
|
|
28
|
+
async close() { this.closed = true; }
|
|
29
|
+
}
|
|
30
|
+
class AcceptanceSpeech {
|
|
31
|
+
stt = 0;
|
|
32
|
+
firstByte = 0;
|
|
33
|
+
completed = 0;
|
|
34
|
+
cancelled = 0;
|
|
35
|
+
syntheses = 0;
|
|
36
|
+
async transcribe(_turnId, audio, _policy) { for await (const _ of audio) { /* transient */ } this.stt += 1; return "fixed-local-transcript"; }
|
|
37
|
+
async *synthesize(_turnId, _text, _mode) {
|
|
38
|
+
this.syntheses += 1;
|
|
39
|
+
this.firstByte += 1;
|
|
40
|
+
yield new Uint8Array([3, 0]);
|
|
41
|
+
if (this.syntheses === 1)
|
|
42
|
+
throw new TtsError("stream_stalled");
|
|
43
|
+
this.completed += 1;
|
|
44
|
+
}
|
|
45
|
+
async cancel(_turnId) { this.cancelled += 1; }
|
|
46
|
+
}
|
|
47
|
+
class AcceptanceRuntime {
|
|
48
|
+
calls = 0;
|
|
49
|
+
async respond(_turnId, _input, signal) { if (signal.aborted)
|
|
50
|
+
throw new Error("aborted"); this.calls += 1; return "fixed-local-response"; }
|
|
51
|
+
async cancel(_turnId) { }
|
|
52
|
+
}
|
|
53
|
+
export async function runLocalAcceptance() {
|
|
54
|
+
const scratch = await mkdtemp(join(tmpdir(), "perkos-voice-acceptance-"));
|
|
55
|
+
const originalFetch = globalThis.fetch;
|
|
56
|
+
globalThis.fetch = async () => { throw new Error("local acceptance network denied"); };
|
|
57
|
+
const room = new AcceptanceRoom();
|
|
58
|
+
const speech = new AcceptanceSpeech();
|
|
59
|
+
const runtime = new AcceptanceRuntime();
|
|
60
|
+
let clock = 0;
|
|
61
|
+
let suppressed = 0;
|
|
62
|
+
const gate = new EchoSuppressionGate(() => clock, 10, () => { suppressed += 1; });
|
|
63
|
+
gate.beginOutput();
|
|
64
|
+
const loopbackEnqueued = gate.shouldSuppress() ? 0 : 1;
|
|
65
|
+
gate.endOutput();
|
|
66
|
+
clock = 11;
|
|
67
|
+
const intentionalAllowed = !gate.shouldSuppress();
|
|
68
|
+
const events = [];
|
|
69
|
+
const gateway = new VoiceGateway(room, speech, runtime, () => new Date("2026-08-15T12:00:00.000Z"), (event) => events.push(event), 100, () => { });
|
|
70
|
+
const grant = { meetingId: "local-meeting", roomName: "local-room", agentIdentity: "local-agent", expiresAt: "2026-08-15T12:05:00.000Z", token: "local-only" };
|
|
71
|
+
const request = { projectId: "local-project", meetingId: "local-meeting", agentId: "local-agent", initiatorId: "local", mode: "turn_based", transcriptPolicy: "off" };
|
|
72
|
+
const handshake = { protocolVersion: "1", capability: { agentId: "local-agent", availability: "available", supportedModes: ["turn_based"], ownership: "external_owner", supportsInterrupt: true, supportsEphemeralTranscript: true, supportsSavedTranscript: false, checkedAt: "2026-08-15T11:59:00.000Z", expiresAt: "2026-08-15T12:05:00.000Z" } };
|
|
73
|
+
try {
|
|
74
|
+
await gateway.start({ request, handshake, grant });
|
|
75
|
+
await gateway.runTurn();
|
|
76
|
+
await gateway.runTurn();
|
|
77
|
+
await gateway.close();
|
|
78
|
+
const rawFiles = (await readdir(scratch)).length;
|
|
79
|
+
const selfLoopSuppressed = loopbackEnqueued === 0 && intentionalAllowed && suppressed === 1;
|
|
80
|
+
const passed = speech.stt === 2 && runtime.calls === 2 && speech.firstByte === 2 && speech.completed === 1 && room.audioStarted === 2 && room.audioCompleted === 1 && room.cancelled === 1 && speech.cancelled === 1 && gateway.state.snapshot.state === "closed" && rawFiles === 0 && selfLoopSuppressed;
|
|
81
|
+
return { schemaVersion: 1, mode: "local", status: passed ? "passed" : "failed", checks: { turnsCompleted: 2, stallRecovered: room.cancelled === 1, selfLoopSuppressed, audioPublished: room.audioCompleted === 1, closed: room.closed, rawFiles }, stages: { stt: speech.stt, runtime: runtime.calls, ttsFirstByte: speech.firstByte, ttsCompleted: speech.completed, audioStarted: room.audioStarted, audioCompleted: room.audioCompleted }, ...(passed ? {} : { failure: "pipeline" }) };
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
globalThis.fetch = originalFetch;
|
|
85
|
+
await rm(scratch, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function runStagingAcceptance(env) {
|
|
89
|
+
const failed = () => ({ schemaVersion: 1, mode: "staging", status: "failed", failure: "configuration", checks: { turnsCompleted: 0, stallRecovered: false, selfLoopSuppressed: false, audioPublished: false, closed: false, rawFiles: 0 }, stages: { stt: 0, runtime: 0, ttsFirstByte: 0, ttsCompleted: 0, audioStarted: 0, audioCompleted: 0 } });
|
|
90
|
+
if (env.PERKOS_VOICE_ACCEPTANCE_STAGING !== "1" || env.PERKOS_VOICE_ACCEPTANCE_CONFIRM !== "staging-only" || env.PERKOS_VOICE_ACCEPTANCE_EPHEMERAL !== "1")
|
|
91
|
+
return failed();
|
|
92
|
+
const openAiUrl = env.PERKOS_VOICE_ACCEPTANCE_OPENAI_BASE_URL;
|
|
93
|
+
const openClawUrl = env.PERKOS_VOICE_ACCEPTANCE_OPENCLAW_ENDPOINT;
|
|
94
|
+
const openAiKey = env.PERKOS_VOICE_ACCEPTANCE_OPENAI_API_KEY;
|
|
95
|
+
const openClawToken = env.PERKOS_VOICE_ACCEPTANCE_OPENCLAW_TOKEN;
|
|
96
|
+
const model = env.PERKOS_VOICE_ACCEPTANCE_OPENCLAW_MODEL;
|
|
97
|
+
const allowed = new Set((env.PERKOS_VOICE_ACCEPTANCE_ALLOWED_ORIGINS ?? "").split(",").map((value) => value.trim()).filter(Boolean));
|
|
98
|
+
const safeOrigin = (value) => { try {
|
|
99
|
+
if (!value)
|
|
100
|
+
return false;
|
|
101
|
+
const url = new URL(value);
|
|
102
|
+
const nonprod = url.hostname === "localhost" || /(^|[.-])(staging|stage|test|dev)([.-]|$)/.test(url.hostname);
|
|
103
|
+
return nonprod && allowed.has(url.origin);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return false;
|
|
107
|
+
} };
|
|
108
|
+
if (!openAiKey || !openClawToken || !model || !safeOrigin(openAiUrl) || !safeOrigin(openClawUrl))
|
|
109
|
+
return failed();
|
|
110
|
+
const seed = new OpenAiTurnSpeechAdapter({ apiKey: openAiKey, baseUrl: openAiUrl });
|
|
111
|
+
const seeded = [];
|
|
112
|
+
try {
|
|
113
|
+
for await (const chunk of seed.synthesize("staging-seed", "fixed staging acceptance phrase", "turn_based"))
|
|
114
|
+
seeded.push(chunk);
|
|
115
|
+
const size = seeded.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
116
|
+
if (!size)
|
|
117
|
+
return failed();
|
|
118
|
+
const pcm = new Uint8Array(size);
|
|
119
|
+
let offset = 0;
|
|
120
|
+
for (const chunk of seeded) {
|
|
121
|
+
pcm.set(chunk, offset);
|
|
122
|
+
offset += chunk.byteLength;
|
|
123
|
+
}
|
|
124
|
+
const room = new AcceptanceRoom([pcm, pcm]);
|
|
125
|
+
const events = [];
|
|
126
|
+
const speech = new OpenAiTurnSpeechAdapter({ apiKey: openAiKey, baseUrl: openAiUrl, observe: (event) => events.push(event), logFailure: () => { } });
|
|
127
|
+
const runtime = new OpenClawChatCompletionsAdapter({ endpoint: openClawUrl, token: openClawToken, model });
|
|
128
|
+
const gateway = new VoiceGateway(room, speech, runtime, () => new Date("2026-08-15T12:00:00.000Z"), (event) => events.push(event), 60_000, () => { });
|
|
129
|
+
const grant = { meetingId: "staging-meeting", roomName: "staging-room", agentIdentity: "staging-agent", expiresAt: "2026-08-15T12:05:00.000Z", token: "staging-local-only" };
|
|
130
|
+
const request = { projectId: "staging-project", meetingId: "staging-meeting", agentId: "staging-agent", initiatorId: "acceptance", mode: "turn_based", transcriptPolicy: "off" };
|
|
131
|
+
const handshake = { protocolVersion: "1", capability: { agentId: "staging-agent", availability: "available", supportedModes: ["turn_based"], ownership: "external_owner", supportsInterrupt: true, supportsEphemeralTranscript: true, supportsSavedTranscript: false, checkedAt: "2026-08-15T11:59:00.000Z", expiresAt: "2026-08-15T12:05:00.000Z" } };
|
|
132
|
+
await gateway.start({ request, handshake, grant });
|
|
133
|
+
await gateway.runTurn();
|
|
134
|
+
await gateway.runTurn();
|
|
135
|
+
await gateway.close();
|
|
136
|
+
const stages = { stt: events.filter((value) => value === "stt_success").length, runtime: events.filter((value) => value === "openclaw_success").length, ttsFirstByte: events.filter((value) => value === "tts_first_byte").length, ttsCompleted: events.filter((value) => value === "tts_success").length, audioStarted: room.audioStarted, audioCompleted: room.audioCompleted };
|
|
137
|
+
const passed = stages.stt === 2 && stages.runtime === 2 && stages.ttsFirstByte === 2 && stages.ttsCompleted === 2 && room.closed;
|
|
138
|
+
return { schemaVersion: 1, mode: "staging", status: passed ? "passed" : "failed", checks: { turnsCompleted: 2, stallRecovered: false, selfLoopSuppressed: false, audioPublished: room.audioCompleted === 2, closed: room.closed, rawFiles: 0 }, stages, ...(passed ? {} : { failure: "pipeline" }) };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { ...failed(), failure: "pipeline" };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function localEnvironmentIsSafe(env) {
|
|
145
|
+
const forbidden = ["OPENAI_API_KEY", "OPENAI_BASE_URL", "OPENCLAW_ENDPOINT", "OPENCLAW_TOKEN", "LIVEKIT_URL", "LIVEKIT_API_KEY", "LIVEKIT_API_SECRET", "PERKOS_VOICE_ACCEPTANCE_OPENAI_API_KEY", "PERKOS_VOICE_ACCEPTANCE_OPENCLAW_TOKEN"];
|
|
146
|
+
return forbidden.every((key) => !env[key]);
|
|
147
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { localEnvironmentIsSafe, runLocalAcceptance, runStagingAcceptance } from "./acceptance.js";
|
|
3
|
+
const staging = process.env.PERKOS_VOICE_ACCEPTANCE_STAGING === "1";
|
|
4
|
+
const result = staging ? await runStagingAcceptance(process.env) : localEnvironmentIsSafe(process.env) ? await runLocalAcceptance() : { schemaVersion: 1, mode: "local", status: "failed", failure: "configuration", checks: { turnsCompleted: 0, stallRecovered: false, selfLoopSuppressed: false, audioPublished: false, closed: false, rawFiles: 0 }, stages: { stt: 0, runtime: 0, ttsFirstByte: 0, ttsCompleted: 0, audioStarted: 0, audioCompleted: 0 } };
|
|
5
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
6
|
+
process.exitCode = result.status === "passed" ? 0 : 1;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MediaRoom, MediaTurnInput } from "../ports.js";
|
|
2
|
+
import type { VoiceGatewayGrant } from "../types.js";
|
|
3
|
+
import type { MediaSuccessObserver } from "../mediaMetrics.js";
|
|
4
|
+
export declare class LiveKitMediaRoom implements MediaRoom {
|
|
5
|
+
#private;
|
|
6
|
+
private readonly observe?;
|
|
7
|
+
constructor(observe?: MediaSuccessObserver | undefined);
|
|
8
|
+
join(grant: VoiceGatewayGrant): Promise<void>;
|
|
9
|
+
receiveTurn(): Promise<MediaTurnInput>;
|
|
10
|
+
setInputSuppressed(suppressed: boolean): void;
|
|
11
|
+
publish(turnId: string, audio: AsyncIterable<Uint8Array>): Promise<"completed" | "cancelled" | "stream_stalled">;
|
|
12
|
+
setBargeInHandler(handler: (turnId: string) => Promise<void>): void;
|
|
13
|
+
cancelPublish(turnId: string): Promise<void>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|