@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
@@ -0,0 +1,263 @@
1
+ import { constants as cryptoConstants, createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, privateDecrypt, randomUUID, sign, } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { chmod, mkdir, open, realpath, rename, rmdir, unlink } from "node:fs/promises";
4
+ import { dirname, join, resolve } from "node:path";
5
+ export const BRAGI_DELIVERY_AUDIENCE = "perkos-voice-gateway-grant:v1";
6
+ export const BRAGI_DELIVERY_ALGORITHM = "RSA-OAEP-256";
7
+ const MAX_RESPONSE_BYTES = 32_768;
8
+ const MAX_CREDENTIAL_BYTES = 8_192;
9
+ function fingerprint(publicKeyPem) {
10
+ const der = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
11
+ return createHash("sha256").update(der).digest("hex");
12
+ }
13
+ function assertSafeId(value, name) {
14
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(value))
15
+ throw new Error(`${name} is invalid`);
16
+ }
17
+ function assertUuid(value, name) {
18
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) {
19
+ throw new Error(`${name} is invalid`);
20
+ }
21
+ }
22
+ function assertRoot(requireRoot = true) {
23
+ if (requireRoot && process.geteuid?.() !== 0)
24
+ throw new Error("Bragi delivery receiver must run as root");
25
+ }
26
+ async function readRootFile(path, maximumBytes = 65_536) {
27
+ const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
28
+ try {
29
+ const info = await handle.stat();
30
+ if (!info.isFile() || (info.mode & 0o777) !== 0o600 || info.size > maximumBytes) {
31
+ throw new Error("protected file must be regular, mode 0600, and size-bounded");
32
+ }
33
+ if (process.geteuid?.() === 0 && info.uid !== 0)
34
+ throw new Error("protected file must be root-owned");
35
+ return await handle.readFile();
36
+ }
37
+ finally {
38
+ await handle.close();
39
+ }
40
+ }
41
+ async function writeProtectedAtomic(path, content) {
42
+ const directory = dirname(path);
43
+ await mkdir(directory, { recursive: true, mode: 0o700 });
44
+ if (await realpath(directory) !== resolve(directory))
45
+ throw new Error("protected directory must not be a symlink");
46
+ await chmod(directory, 0o700);
47
+ const temporary = join(directory, `.${process.pid}.${randomUUID()}.tmp`);
48
+ const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
49
+ try {
50
+ await handle.writeFile(content);
51
+ await handle.sync();
52
+ }
53
+ finally {
54
+ await handle.close();
55
+ }
56
+ await chmod(temporary, 0o600);
57
+ await rename(temporary, path);
58
+ const directoryHandle = await open(directory, fsConstants.O_RDONLY);
59
+ try {
60
+ await directoryHandle.sync();
61
+ }
62
+ finally {
63
+ await directoryHandle.close();
64
+ }
65
+ }
66
+ export async function prepareBragiDelivery(stateDirectory, options = {}) {
67
+ assertRoot(options.requireRoot);
68
+ const absoluteDirectory = resolve(stateDirectory);
69
+ await mkdir(absoluteDirectory, { recursive: false, mode: 0o700 });
70
+ await chmod(absoluteDirectory, 0o700);
71
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
72
+ modulusLength: 3072,
73
+ publicKeyEncoding: { type: "spki", format: "pem" },
74
+ privateKeyEncoding: { type: "pkcs8", format: "der" },
75
+ });
76
+ const now = options.now ?? new Date();
77
+ const state = {
78
+ createdAt: now.toISOString(),
79
+ expiresAt: new Date(now.getTime() + 5 * 60_000).toISOString(),
80
+ agentId: "Bragi",
81
+ audience: BRAGI_DELIVERY_AUDIENCE,
82
+ publicKeyFingerprint: fingerprint(publicKey),
83
+ };
84
+ try {
85
+ await writeProtectedAtomic(join(absoluteDirectory, "private.der"), privateKey);
86
+ await writeProtectedAtomic(join(absoluteDirectory, "public.pem"), publicKey);
87
+ await writeProtectedAtomic(join(absoluteDirectory, "state.json"), JSON.stringify(state));
88
+ return state;
89
+ }
90
+ catch (error) {
91
+ for (const name of ["private.der", "public.pem", "state.json"]) {
92
+ try {
93
+ await unlink(join(absoluteDirectory, name));
94
+ }
95
+ catch { /* absent */ }
96
+ }
97
+ try {
98
+ await rmdir(absoluteDirectory);
99
+ }
100
+ catch { /* preserve unexpected contents */ }
101
+ throw error;
102
+ }
103
+ finally {
104
+ privateKey.fill(0);
105
+ }
106
+ }
107
+ export function canonicalClaim(path, claimId, timestamp) {
108
+ assertUuid(claimId, "claimId");
109
+ const body = JSON.stringify({ claimId, timestamp });
110
+ const bodyHash = createHash("sha256").update(body).digest("hex");
111
+ return { body, canonical: `POST\n${path}\n${timestamp}\n${claimId}\n${bodyHash}` };
112
+ }
113
+ export function canonicalDiscovery(path, publicKeyFingerprint, audience, timestamp, nonce) {
114
+ if (!/^[a-f0-9]{64}$/.test(publicKeyFingerprint))
115
+ throw new Error("publicKeyFingerprint is invalid");
116
+ assertUuid(nonce, "nonce");
117
+ const body = JSON.stringify({ publicKeyFingerprint, audience, timestamp, nonce });
118
+ const bodyHash = createHash("sha256").update(body).digest("hex");
119
+ return {
120
+ body,
121
+ canonical: `POST\n${path}\n${timestamp}\n${nonce}\n${audience}\n${publicKeyFingerprint}\n${bodyHash}`,
122
+ };
123
+ }
124
+ async function boundedJson(response) {
125
+ const declared = Number(response.headers.get("content-length") ?? 0);
126
+ if (declared > MAX_RESPONSE_BYTES)
127
+ throw new Error("delivery response is oversized");
128
+ const bytes = Buffer.from(await response.arrayBuffer());
129
+ if (bytes.byteLength > MAX_RESPONSE_BYTES)
130
+ throw new Error("delivery response is oversized");
131
+ try {
132
+ return JSON.parse(bytes.toString("utf8"));
133
+ }
134
+ finally {
135
+ bytes.fill(0);
136
+ }
137
+ }
138
+ export async function claimBragiDelivery(options) {
139
+ assertRoot(options.requireRoot);
140
+ assertSafeId(options.enrollmentAgentId, "enrollmentAgentId");
141
+ const stateDirectory = resolve(options.stateDirectory);
142
+ const stateBuffer = await readRootFile(join(stateDirectory, "state.json"));
143
+ const privateKeyBuffer = await readRootFile(join(stateDirectory, "private.der"), 16_384);
144
+ const publicKeyBuffer = await readRootFile(join(stateDirectory, "public.pem"), 16_384);
145
+ let credential;
146
+ try {
147
+ const state = JSON.parse(stateBuffer.toString("utf8"));
148
+ if (state.agentId !== "Bragi" || state.audience !== BRAGI_DELIVERY_AUDIENCE)
149
+ throw new Error("delivery state binding is invalid");
150
+ if (Date.parse(state.expiresAt) <= (options.now ?? new Date()).getTime())
151
+ throw new Error("delivery state expired");
152
+ if (fingerprint(publicKeyBuffer.toString("utf8")) !== state.publicKeyFingerprint)
153
+ throw new Error("public key fingerprint mismatch");
154
+ const privateKey = createPrivateKey({ key: privateKeyBuffer, type: "pkcs8", format: "der" });
155
+ if (createPublicKey(privateKey).export({ type: "spki", format: "pem" }).toString() !== publicKeyBuffer.toString("utf8")) {
156
+ throw new Error("ephemeral key pair mismatch");
157
+ }
158
+ const endpoint = new URL(options.endpoint);
159
+ if (endpoint.protocol !== "https:" && !["localhost", "127.0.0.1", "::1"].includes(endpoint.hostname))
160
+ throw new Error("delivery endpoint must use HTTPS");
161
+ const basePath = endpoint.pathname.replace(/\/$/, "");
162
+ const timestamp = (options.now ?? new Date()).toISOString();
163
+ const nonce = randomUUID();
164
+ const discoveryPath = `${basePath}/deliveries/discover`;
165
+ const discovery = canonicalDiscovery(discoveryPath, state.publicKeyFingerprint, BRAGI_DELIVERY_AUDIENCE, timestamp, nonce);
166
+ const discoverySignature = sign("sha256", Buffer.from(discovery.canonical), {
167
+ key: privateKey,
168
+ padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
169
+ saltLength: 32,
170
+ }).toString("base64");
171
+ const discoveryResponse = await (options.fetcher ?? fetch)(new URL(discoveryPath, endpoint.origin), {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json" },
174
+ body: JSON.stringify({ ...JSON.parse(discovery.body), signature: discoverySignature }),
175
+ signal: AbortSignal.timeout(10_000),
176
+ });
177
+ if (!discoveryResponse.ok)
178
+ throw new Error(`encrypted delivery discovery failed (${discoveryResponse.status})`);
179
+ const discovered = await boundedJson(discoveryResponse);
180
+ const metadata = discovered.delivery;
181
+ if (discovered.ok !== true || typeof metadata?.id !== "string" || typeof metadata.claimId !== "string" ||
182
+ typeof metadata.expiresAt !== "string" || Date.parse(metadata.expiresAt) <= (options.now ?? new Date()).getTime()) {
183
+ throw new Error("encrypted delivery discovery response is invalid");
184
+ }
185
+ const deliveryId = metadata.id;
186
+ const claimId = metadata.claimId;
187
+ assertUuid(deliveryId, "deliveryId");
188
+ assertUuid(claimId, "claimId");
189
+ const path = `${basePath}/deliveries/${encodeURIComponent(deliveryId)}/claim`;
190
+ const { body: unsignedBody, canonical } = canonicalClaim(path, claimId, timestamp);
191
+ const signature = sign("sha256", Buffer.from(canonical), {
192
+ key: privateKey,
193
+ padding: cryptoConstants.RSA_PKCS1_PSS_PADDING,
194
+ saltLength: 32,
195
+ }).toString("base64");
196
+ const claimResponse = await (options.fetcher ?? fetch)(new URL(path, endpoint.origin), {
197
+ method: "POST",
198
+ headers: { "content-type": "application/json" },
199
+ body: JSON.stringify({ ...JSON.parse(unsignedBody), signature }),
200
+ signal: AbortSignal.timeout(10_000),
201
+ });
202
+ if (!claimResponse.ok)
203
+ throw new Error(`encrypted delivery claim failed (${claimResponse.status})`);
204
+ const claim = await boundedJson(claimResponse);
205
+ const delivery = claim.delivery;
206
+ if (claim.ok !== true || delivery?.id !== deliveryId || delivery.algorithm !== BRAGI_DELIVERY_ALGORITHM ||
207
+ delivery.audience !== BRAGI_DELIVERY_AUDIENCE || delivery.publicKeyFingerprint !== state.publicKeyFingerprint ||
208
+ typeof delivery.expiresAt !== "string" || Date.parse(delivery.expiresAt) <= (options.now ?? new Date()).getTime() ||
209
+ typeof delivery.ciphertext !== "string")
210
+ throw new Error("encrypted delivery response binding is invalid");
211
+ const ciphertext = Buffer.from(delivery.ciphertext, "base64");
212
+ if (!ciphertext.length || ciphertext.toString("base64") !== delivery.ciphertext)
213
+ throw new Error("encrypted delivery ciphertext is invalid");
214
+ try {
215
+ credential = privateDecrypt({ key: privateKey, oaepHash: "sha256", padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING }, ciphertext);
216
+ }
217
+ finally {
218
+ ciphertext.fill(0);
219
+ }
220
+ if (!credential.length || credential.length > MAX_CREDENTIAL_BYTES || credential.includes(0))
221
+ throw new Error("decrypted credential is invalid");
222
+ let currentBuffer;
223
+ try {
224
+ currentBuffer = await readRootFile(resolve(options.gatewaySecretFile));
225
+ const current = JSON.parse(currentBuffer.toString("utf8"));
226
+ current.grantCredential = credential.toString("utf8");
227
+ current.capabilityPublishCredential = credential.toString("utf8");
228
+ await writeProtectedAtomic(resolve(options.gatewaySecretFile), JSON.stringify(current));
229
+ try {
230
+ const credentialProof = createHmac("sha256", credential)
231
+ .update(`receipt\n${options.enrollmentAgentId}\n${deliveryId}\n${claimId}`)
232
+ .digest("base64url");
233
+ const receiptResponse = await (options.fetcher ?? fetch)(new URL(`${basePath}/deliveries/${encodeURIComponent(deliveryId)}/receipt`, endpoint.origin), {
234
+ method: "POST",
235
+ headers: { "content-type": "application/json" },
236
+ body: JSON.stringify({ claimId, credentialProof }),
237
+ signal: AbortSignal.timeout(10_000),
238
+ });
239
+ if (!receiptResponse.ok)
240
+ throw new Error(`encrypted delivery receipt failed (${receiptResponse.status})`);
241
+ const receipt = await boundedJson(receiptResponse);
242
+ if (receipt.ok !== true || receipt.status !== "received")
243
+ throw new Error("encrypted delivery receipt is invalid");
244
+ }
245
+ catch (error) {
246
+ await writeProtectedAtomic(resolve(options.gatewaySecretFile), currentBuffer);
247
+ throw error;
248
+ }
249
+ }
250
+ finally {
251
+ currentBuffer?.fill(0);
252
+ }
253
+ for (const name of ["private.der", "public.pem", "state.json"])
254
+ await unlink(join(stateDirectory, name));
255
+ await rmdir(stateDirectory);
256
+ }
257
+ finally {
258
+ stateBuffer.fill(0);
259
+ privateKeyBuffer.fill(0);
260
+ publicKeyBuffer.fill(0);
261
+ credential?.fill(0);
262
+ }
263
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import { claimBragiDelivery, prepareBragiDelivery } from "./bragiDelivery.js";
3
+ function option(name) {
4
+ const index = process.argv.indexOf(name);
5
+ const value = index >= 0 ? process.argv[index + 1] : undefined;
6
+ if (!value || value.startsWith("--"))
7
+ throw new Error(`${name} is required`);
8
+ return value;
9
+ }
10
+ async function main() {
11
+ const command = process.argv[2];
12
+ if (command === "prepare") {
13
+ await prepareBragiDelivery(option("--state-dir"));
14
+ process.stdout.write("Bragi encrypted delivery receiver prepared.\n");
15
+ return;
16
+ }
17
+ if (command === "claim") {
18
+ await claimBragiDelivery({
19
+ endpoint: option("--endpoint"),
20
+ enrollmentAgentId: option("--enrollment-agent-id"),
21
+ stateDirectory: option("--state-dir"),
22
+ gatewaySecretFile: option("--gateway-secret-file"),
23
+ });
24
+ process.stdout.write("Bragi encrypted delivery received and acknowledged.\n");
25
+ return;
26
+ }
27
+ throw new Error("usage: bragi-delivery prepare|claim [options]");
28
+ }
29
+ main().catch(() => {
30
+ process.stderr.write("Bragi encrypted delivery failed closed.\n");
31
+ process.exitCode = 1;
32
+ });
@@ -0,0 +1,15 @@
1
+ import type { TranscriptPolicy, VoiceAvailabilityDecision, VoiceCapabilityHandshake, VoiceMode, VoiceProviderOwnership } from "./types.js";
2
+ export declare class CapabilityValidationError extends Error {
3
+ readonly code = "INVALID_CAPABILITY_HANDSHAKE";
4
+ }
5
+ /** Strictly parses the public handshake; unknown/private fields are rejected. */
6
+ export declare function validateCapabilityHandshake(value: unknown): VoiceCapabilityHandshake;
7
+ export interface AvailabilityRequirements {
8
+ agentId: string;
9
+ mode: VoiceMode;
10
+ transcriptPolicy: TranscriptPolicy;
11
+ requireInterrupt?: boolean;
12
+ allowedOwnership?: readonly VoiceProviderOwnership[];
13
+ now?: Date;
14
+ }
15
+ export declare function reasonVoiceAvailability(rawHandshake: unknown, requirements: AvailabilityRequirements): VoiceAvailabilityDecision;
@@ -0,0 +1,84 @@
1
+ const OWNERSHIP = [
2
+ "external_owner",
3
+ "perkos_managed",
4
+ "organization_byok",
5
+ ];
6
+ const MODES = ["turn_based", "realtime"];
7
+ const AVAILABILITY = ["available", "unavailable", "degraded"];
8
+ const CAPABILITY_KEYS = new Set([
9
+ "agentId", "availability", "supportedModes", "ownership", "supportsInterrupt",
10
+ "supportsEphemeralTranscript", "supportsSavedTranscript", "checkedAt", "expiresAt",
11
+ ]);
12
+ const HANDSHAKE_KEYS = new Set(["protocolVersion", "capability"]);
13
+ export class CapabilityValidationError extends Error {
14
+ code = "INVALID_CAPABILITY_HANDSHAKE";
15
+ }
16
+ function isRecord(value) {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+ function isoDate(value) {
20
+ return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
21
+ }
22
+ /** Strictly parses the public handshake; unknown/private fields are rejected. */
23
+ export function validateCapabilityHandshake(value) {
24
+ if (!isRecord(value) || Object.keys(value).some((key) => !HANDSHAKE_KEYS.has(key))) {
25
+ throw new CapabilityValidationError("Handshake must contain only protocolVersion and capability");
26
+ }
27
+ if (value.protocolVersion !== "1") {
28
+ throw new CapabilityValidationError("Unsupported capability protocol version");
29
+ }
30
+ if (value.capability === undefined)
31
+ return { protocolVersion: "1" };
32
+ if (!isRecord(value.capability) || Object.keys(value.capability).some((key) => !CAPABILITY_KEYS.has(key))) {
33
+ throw new CapabilityValidationError("Capability contains unknown or private fields");
34
+ }
35
+ const capability = value.capability;
36
+ if (typeof capability.agentId !== "string" || capability.agentId.length === 0 ||
37
+ !AVAILABILITY.includes(capability.availability) ||
38
+ !Array.isArray(capability.supportedModes) ||
39
+ capability.supportedModes.some((mode) => !MODES.includes(mode)) ||
40
+ !OWNERSHIP.includes(capability.ownership) ||
41
+ typeof capability.supportsInterrupt !== "boolean" ||
42
+ typeof capability.supportsEphemeralTranscript !== "boolean" ||
43
+ typeof capability.supportsSavedTranscript !== "boolean" ||
44
+ !isoDate(capability.checkedAt) || !isoDate(capability.expiresAt)) {
45
+ throw new CapabilityValidationError("Capability fields are invalid");
46
+ }
47
+ if (Date.parse(capability.checkedAt) >= Date.parse(capability.expiresAt)) {
48
+ throw new CapabilityValidationError("Capability expiry must follow its check time");
49
+ }
50
+ return { protocolVersion: "1", capability: capability };
51
+ }
52
+ export function reasonVoiceAvailability(rawHandshake, requirements) {
53
+ let handshake;
54
+ try {
55
+ handshake = validateCapabilityHandshake(rawHandshake);
56
+ }
57
+ catch {
58
+ return { available: false, reason: "capability_invalid" };
59
+ }
60
+ const capability = handshake.capability;
61
+ if (!capability)
62
+ return { available: false, reason: "capability_absent" };
63
+ const reject = (reason) => ({ available: false, reason, capability });
64
+ if (capability.agentId !== requirements.agentId)
65
+ return reject("agent_mismatch");
66
+ if (Date.parse(capability.expiresAt) <= (requirements.now ?? new Date()).getTime())
67
+ return reject("expired");
68
+ if (capability.availability === "unavailable")
69
+ return reject("reported_unavailable");
70
+ if (!capability.supportedModes.includes(requirements.mode))
71
+ return reject("mode_unsupported");
72
+ if (requirements.allowedOwnership && !requirements.allowedOwnership.includes(capability.ownership)) {
73
+ return reject("ownership_unsupported");
74
+ }
75
+ if (requirements.requireInterrupt && !capability.supportsInterrupt)
76
+ return reject("interrupt_unsupported");
77
+ if (requirements.transcriptPolicy === "ephemeral" && !capability.supportsEphemeralTranscript) {
78
+ return reject("transcript_policy_unsupported");
79
+ }
80
+ if (requirements.transcriptPolicy === "saved" && !capability.supportsSavedTranscript) {
81
+ return reject("transcript_policy_unsupported");
82
+ }
83
+ return { available: true, reason: "available", capability };
84
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ import { loadGatewayConfig, validateByoConfig } from "./config.js";
3
+ import { startHealthServer } from "./service.js";
4
+ import { M2mGrantSource } from "./grants.js";
5
+ import { LiveKitMediaRoom } from "./adapters/livekit.js";
6
+ import { ByoSpeechHttpAdapter } from "./adapters/speech.js";
7
+ import { OpenAiTurnSpeechAdapter } from "./adapters/openaiSpeech.js";
8
+ import { OpenClawChatCompletionsAdapter, OpenClawResponsesAdapter } from "./adapters/openclaw.js";
9
+ import { ZeroClawWebhookAdapter } from "./adapters/zeroclaw.js";
10
+ import { VoiceGateway } from "./gateway.js";
11
+ import { CapabilityPublisher, GatewayReadiness } from "./readiness.js";
12
+ import { createChatCommitSink, SessionControlClient } from "./sessionControl.js";
13
+ import { runVoiceSessionStage, VoiceSessionStageError, voiceSessionStageLog } from "./mediaStages.js";
14
+ import { MediaSuccessMetrics } from "./mediaMetrics.js";
15
+ import { transcriptionFailureLog, ttsFailureLog } from "./speechErrors.js";
16
+ import { healthReportFromReadiness, healthReportFromSessionFailure, mapSessionStageToCodes, reportVoiceHealthToControlPlane, voiceHealthReportEndpoint, } from "./doctor.js";
17
+ const config = await loadGatewayConfig();
18
+ const validation = validateByoConfig(config);
19
+ const mediaMetrics = new MediaSuccessMetrics();
20
+ const speech = config.speechMode === "openai_turn"
21
+ ? new OpenAiTurnSpeechAdapter({ apiKey: config.openAiApiKey ?? "", baseUrl: config.openAiBaseUrl, transcriptionModel: config.openAiTranscriptionModel, speechModel: config.openAiSpeechModel, voice: config.openAiVoice, observe: (event) => mediaMetrics.mark(event), logFailure: (line) => process.stderr.write(`${line}\n`) })
22
+ : new ByoSpeechHttpAdapter(config.speechEndpoint ?? "", config.speechCredential ?? "");
23
+ const readiness = new GatewayReadiness(config, "probe" in speech ? speech : undefined);
24
+ startHealthServer(config, readiness);
25
+ const publisher = config.capabilityPublishEndpoint && config.capabilityPublishCredential
26
+ ? new CapabilityPublisher(config.capabilityPublishEndpoint, config.capabilityPublishCredential)
27
+ : undefined;
28
+ const reportHealth = async (snapshot) => {
29
+ if (!config.sessionControlEndpoint || !config.grantCredential)
30
+ return;
31
+ try {
32
+ await reportVoiceHealthToControlPlane(voiceHealthReportEndpoint(config.sessionControlEndpoint), config.grantCredential, healthReportFromReadiness(snapshot));
33
+ }
34
+ catch {
35
+ process.stderr.write("voice health report failed\n");
36
+ }
37
+ };
38
+ const refresh = async () => {
39
+ const current = await readiness.check();
40
+ if (publisher) {
41
+ try {
42
+ await publisher.publish(readiness.capability());
43
+ }
44
+ catch {
45
+ process.stderr.write("voice capability publication failed\n");
46
+ }
47
+ }
48
+ await reportHealth(current);
49
+ if (!current.ready)
50
+ process.stderr.write(`voice gateway unavailable: ${current.reasons.join(",")}\n`);
51
+ return current;
52
+ };
53
+ const current = await refresh();
54
+ setInterval(() => { void refresh(); }, 30_000).unref();
55
+ if (!validation.valid)
56
+ process.stderr.write(`voice gateway configuration invalid: ${validation.reasons.join(",")}\n`);
57
+ if (current.ready) {
58
+ const grantSource = new M2mGrantSource(config.grantEndpoint, config.grantCredential);
59
+ const control = new SessionControlClient(config.sessionControlEndpoint, config.grantCredential);
60
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
61
+ while (true) {
62
+ let session;
63
+ try {
64
+ session = await control.claim();
65
+ }
66
+ catch (error) {
67
+ const message = error.message ?? "claim failed";
68
+ process.stderr.write(`voice session claim unavailable: ${message}\n`);
69
+ // Back off harder on rate limits so we don't dig a deeper 429 hole.
70
+ await delay(/\b429\b/.test(message) ? 15_000 : 5_000);
71
+ continue;
72
+ }
73
+ if (!session) {
74
+ await delay(3_000);
75
+ continue;
76
+ }
77
+ mediaMetrics.beginSession();
78
+ const runtimeProtocol = config.runtimeProtocol ?? config.openClawApiMode;
79
+ const runtimeEndpoint = config.runtimeEndpoint ?? config.openClawEndpoint;
80
+ const runtimeToken = config.runtimeToken ?? config.openClawToken;
81
+ const runtimeModel = config.runtimeModel ?? config.openClawChatModel;
82
+ const runtime = runtimeProtocol === "zeroclaw_webhook"
83
+ ? new ZeroClawWebhookAdapter(runtimeEndpoint, runtimeToken, config.runtimeTimeoutMs)
84
+ : runtimeProtocol === "chat_completions"
85
+ ? new OpenClawChatCompletionsAdapter({
86
+ endpoint: runtimeEndpoint,
87
+ token: runtimeToken,
88
+ model: runtimeModel,
89
+ backendModel: config.openClawVoiceModelOverride,
90
+ spokenName: config.openClawSpokenName,
91
+ // Working Call only: API attaches a bounded brief from the bound chat.
92
+ workChatBrief: session.workChatBrief,
93
+ maxTokens: config.openClawMaxTokens,
94
+ timeoutMs: config.runtimeTimeoutMs,
95
+ observe: (event) => mediaMetrics.mark(event),
96
+ })
97
+ : new OpenClawResponsesAdapter(runtimeEndpoint, runtimeToken);
98
+ const finalTurnSink = createChatCommitSink(session, control);
99
+ mediaMetrics.mark(finalTurnSink ? "chat_policy_normal" : "chat_policy_private");
100
+ if (session.workChatBrief)
101
+ process.stderr.write(`work_chat_brief_loaded chars=${session.workChatBrief.length}\n`);
102
+ else if (session.chatCommit.policy === "final_pair")
103
+ process.stderr.write("work_chat_brief_empty\n");
104
+ const gateway = new VoiceGateway(new LiveKitMediaRoom(mediaMetrics.mark), speech, runtime, undefined, mediaMetrics.mark, 30_000, (line) => process.stderr.write(`${line}\n`), finalTurnSink, config.runtimeTimeoutMs, config.speechSlaMs, config.presenceMs);
105
+ try {
106
+ const grant = await runVoiceSessionStage("grant_obtain", () => grantSource.obtain({ sessionId: session.id, projectId: session.projectId, meetingId: session.meetingId, voiceProcessingConsent: true }));
107
+ await runVoiceSessionStage("gateway_start", async () => {
108
+ try {
109
+ const speechVoice = config.openAiVoiceOverride ?? session.speechVoice ?? config.openAiVoice;
110
+ await gateway.start({ request: { projectId: session.projectId, meetingId: session.meetingId, agentId: session.agentId, initiatorId: "gateway", mode: "turn_based", transcriptPolicy: config.transcriptPolicy, speechVoice }, handshake: readiness.capability(), grant });
111
+ }
112
+ catch (error) {
113
+ if (error instanceof VoiceSessionStageError)
114
+ throw error;
115
+ throw new VoiceSessionStageError("gateway_start");
116
+ }
117
+ });
118
+ await runVoiceSessionStage("status_joined", () => control.update(session.id, "joined"));
119
+ await runVoiceSessionStage("turn_loop", async () => {
120
+ // Soft-fail individual turns: a single STT/TTS/runtime blip must not hang up the call.
121
+ const startTurn = () => gateway.runTurn().catch((error) => {
122
+ const line = transcriptionFailureLog(error) ?? ttsFailureLog(error);
123
+ if (line)
124
+ process.stderr.write(`${line}\n`);
125
+ else
126
+ process.stderr.write(`voice turn soft-fail: ${String(error.message ?? error).slice(0, 120)}\n`);
127
+ return "turn-error";
128
+ });
129
+ let leaseExpiresAt = (await control.heartbeat(session.id)).expiresAt;
130
+ let nextHeartbeatAt = Date.now() + 30_000;
131
+ let nextStateAt = Date.now();
132
+ const sessionHardStopAt = Date.now() + 20 * 60_000; // heartbeat must not keep a zombie call forever
133
+ let turn = startTurn();
134
+ while (Date.parse(leaseExpiresAt) > Date.now() && Date.now() < sessionHardStopAt) {
135
+ const outcome = await Promise.race([turn.then(() => "turn"), delay(1_000).then(() => "poll")]);
136
+ if (outcome === "turn") {
137
+ turn = startTurn();
138
+ continue;
139
+ }
140
+ if (Date.now() >= nextHeartbeatAt) {
141
+ try {
142
+ leaseExpiresAt = (await control.heartbeat(session.id)).expiresAt;
143
+ }
144
+ catch { /* existing lease remains authoritative and bounded */ }
145
+ nextHeartbeatAt = Date.now() + 30_000;
146
+ }
147
+ // State poll every 3s (was every 1s) — cuts control-plane RPS ~3x during calls.
148
+ if (Date.now() >= nextStateAt) {
149
+ nextStateAt = Date.now() + 3_000;
150
+ try {
151
+ const state = await control.state(session.id);
152
+ leaseExpiresAt = state.expiresAt;
153
+ if (["cancelled", "expired", "failed", "completed"].includes(state.status))
154
+ break;
155
+ }
156
+ catch {
157
+ // Transient 429/5xx on control plane must not tear down live media.
158
+ }
159
+ }
160
+ }
161
+ });
162
+ await gateway.close();
163
+ const final = await control.state(session.id);
164
+ if (!["cancelled", "expired", "failed", "completed"].includes(final.status))
165
+ await control.update(session.id, "completed", "ended");
166
+ }
167
+ catch (error) {
168
+ process.stderr.write(`${voiceSessionStageLog(error)}\n`);
169
+ const stage = error instanceof VoiceSessionStageError ? error.stage : "gateway_start";
170
+ const healthCodes = mapSessionStageToCodes(stage);
171
+ try {
172
+ await control.update(session.id, "failed", "media_failed", { healthCodes, stage });
173
+ }
174
+ catch { /* fail closed */ }
175
+ if (config.sessionControlEndpoint && config.grantCredential) {
176
+ try {
177
+ await reportVoiceHealthToControlPlane(voiceHealthReportEndpoint(config.sessionControlEndpoint), config.grantCredential, healthReportFromSessionFailure(stage));
178
+ }
179
+ catch { /* fail closed */ }
180
+ }
181
+ try {
182
+ await gateway.close();
183
+ }
184
+ catch { /* fail closed */ }
185
+ }
186
+ mediaMetrics.summarizeSession();
187
+ }
188
+ }
@@ -0,0 +1,47 @@
1
+ export interface GatewayConfig {
2
+ agentId: string;
3
+ canonicalAgentName?: string;
4
+ port: number;
5
+ transcriptPolicy: "off" | "ephemeral";
6
+ projectId?: string;
7
+ meetingId?: string;
8
+ sessionControlEndpoint?: string;
9
+ grantFile?: string;
10
+ grantEndpoint?: string;
11
+ grantCredential?: string;
12
+ speechEndpoint?: string;
13
+ speechCredential?: string;
14
+ speechMode?: "byo_http" | "openai_turn";
15
+ openAiApiKey?: string;
16
+ openAiBaseUrl?: string;
17
+ openAiTranscriptionModel?: string;
18
+ openAiSpeechModel?: string;
19
+ openAiVoice?: string;
20
+ openAiVoiceOverride?: string;
21
+ openClawEndpoint?: string;
22
+ openClawToken?: string;
23
+ openClawApiMode?: "responses" | "chat_completions";
24
+ runtimeKind?: "hermes" | "openclaw" | "zeroclaw";
25
+ runtimeProtocol?: "responses" | "chat_completions" | "zeroclaw_webhook";
26
+ runtimeEndpoint?: string;
27
+ runtimeToken?: string;
28
+ runtimeModel?: string;
29
+ openClawChatModel?: string;
30
+ openClawVoiceModelOverride?: string;
31
+ openClawVoiceModelAllowlist?: string[];
32
+ /** Short spoken identity for thin voice policy (e.g. Athena). Never dump tools/history here. */
33
+ openClawSpokenName?: string;
34
+ /** Completion token budget for spoken turns (default 80). */
35
+ openClawMaxTokens?: number;
36
+ runtimeTimeoutMs?: number;
37
+ speechSlaMs?: number;
38
+ presenceMs?: number;
39
+ capabilityPublishEndpoint?: string;
40
+ capabilityPublishCredential?: string;
41
+ }
42
+ export interface ConfigValidation {
43
+ valid: boolean;
44
+ reasons: string[];
45
+ }
46
+ export declare function validateByoConfig(config: GatewayConfig): ConfigValidation;
47
+ export declare function loadGatewayConfig(env?: NodeJS.ProcessEnv): Promise<GatewayConfig>;