@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -0
  3. package/dist/a2aEnrollment.d.ts +41 -0
  4. package/dist/a2aEnrollment.js +110 -0
  5. package/dist/acceptance.d.ts +25 -0
  6. package/dist/acceptance.js +147 -0
  7. package/dist/acceptanceCli.d.ts +2 -0
  8. package/dist/acceptanceCli.js +6 -0
  9. package/dist/adapters/livekit.d.ts +15 -0
  10. package/dist/adapters/livekit.js +236 -0
  11. package/dist/adapters/openaiSpeech.d.ts +24 -0
  12. package/dist/adapters/openaiSpeech.js +194 -0
  13. package/dist/adapters/openclaw.d.ts +58 -0
  14. package/dist/adapters/openclaw.js +236 -0
  15. package/dist/adapters/speech.d.ts +13 -0
  16. package/dist/adapters/speech.js +57 -0
  17. package/dist/adapters/zeroclaw.d.ts +12 -0
  18. package/dist/adapters/zeroclaw.js +36 -0
  19. package/dist/bootstrap.d.ts +2 -0
  20. package/dist/bootstrap.js +68 -0
  21. package/dist/bragiDelivery.d.ts +31 -0
  22. package/dist/bragiDelivery.js +263 -0
  23. package/dist/bragiDeliveryCli.d.ts +2 -0
  24. package/dist/bragiDeliveryCli.js +32 -0
  25. package/dist/capability.d.ts +15 -0
  26. package/dist/capability.js +84 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +188 -0
  29. package/dist/config.d.ts +47 -0
  30. package/dist/config.js +107 -0
  31. package/dist/doctor.d.ts +55 -0
  32. package/dist/doctor.js +423 -0
  33. package/dist/doctorCli.d.ts +2 -0
  34. package/dist/doctorCli.js +36 -0
  35. package/dist/echoSuppression.d.ts +13 -0
  36. package/dist/echoSuppression.js +42 -0
  37. package/dist/fakes.d.ts +42 -0
  38. package/dist/fakes.js +80 -0
  39. package/dist/gateway.d.ts +46 -0
  40. package/dist/gateway.js +418 -0
  41. package/dist/grants.d.ts +22 -0
  42. package/dist/grants.js +40 -0
  43. package/dist/index.d.ts +23 -0
  44. package/dist/index.js +23 -0
  45. package/dist/installer.d.ts +41 -0
  46. package/dist/installer.js +83 -0
  47. package/dist/mediaMetrics.d.ts +15 -0
  48. package/dist/mediaMetrics.js +43 -0
  49. package/dist/mediaStages.d.ts +12 -0
  50. package/dist/mediaStages.js +48 -0
  51. package/dist/onboarding.d.ts +67 -0
  52. package/dist/onboarding.js +72 -0
  53. package/dist/openclaw-plugin.d.ts +16 -0
  54. package/dist/openclaw-plugin.js +47 -0
  55. package/dist/ports.d.ts +23 -0
  56. package/dist/ports.js +1 -0
  57. package/dist/presenceTone.d.ts +5 -0
  58. package/dist/presenceTone.js +43 -0
  59. package/dist/readiness.d.ts +27 -0
  60. package/dist/readiness.js +68 -0
  61. package/dist/service.d.ts +10 -0
  62. package/dist/service.js +42 -0
  63. package/dist/sessionControl.d.ts +55 -0
  64. package/dist/sessionControl.js +118 -0
  65. package/dist/speechErrors.d.ts +18 -0
  66. package/dist/speechErrors.js +26 -0
  67. package/dist/state-machine.d.ts +55 -0
  68. package/dist/state-machine.js +107 -0
  69. package/dist/types.d.ts +62 -0
  70. package/dist/types.js +1 -0
  71. package/dist/voiceSubtask.d.ts +12 -0
  72. package/dist/voiceSubtask.js +46 -0
  73. package/dist/workCallContext.d.ts +14 -0
  74. package/dist/workCallContext.js +26 -0
  75. package/docs/external-agent-onboarding.md +152 -0
  76. package/external-agent-contract.schema.json +46 -0
  77. package/openclaw.plugin.json +27 -0
  78. package/package.json +78 -0
  79. package/scripts/hermes/install.mjs +70 -0
  80. package/scripts/run-with-env.mjs +23 -0
  81. 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,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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
+ }