@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/dist/config.js ADDED
@@ -0,0 +1,107 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export function validateByoConfig(config) {
3
+ const reasons = [];
4
+ const runtimeEndpoint = config.runtimeEndpoint ?? config.openClawEndpoint;
5
+ const runtimeToken = config.runtimeToken ?? config.openClawToken;
6
+ const runtimeModel = config.runtimeModel ?? config.openClawChatModel;
7
+ if (!config.agentId)
8
+ reasons.push("agent_id_missing");
9
+ if (!(config.grantEndpoint && config.grantCredential))
10
+ reasons.push("grant_delivery_missing");
11
+ if (!config.sessionControlEndpoint || !config.grantCredential)
12
+ reasons.push("session_control_missing");
13
+ if (config.speechMode === "openai_turn") {
14
+ if (!config.openAiApiKey)
15
+ reasons.push("openai_speech_missing");
16
+ }
17
+ else if (!config.speechEndpoint || !config.speechCredential)
18
+ reasons.push("byo_speech_missing");
19
+ if (!runtimeEndpoint || !runtimeToken)
20
+ reasons.push("openclaw_runtime_missing");
21
+ if ((config.runtimeProtocol ?? config.openClawApiMode) === "chat_completions" && !runtimeModel)
22
+ reasons.push("openclaw_chat_model_missing");
23
+ if (config.openClawVoiceModelOverride) {
24
+ if (config.openClawApiMode !== "chat_completions")
25
+ reasons.push("openclaw_voice_model_override_unsupported");
26
+ if (!(config.openClawVoiceModelAllowlist ?? []).includes(config.openClawVoiceModelOverride))
27
+ reasons.push("openclaw_voice_model_not_allowed");
28
+ }
29
+ if (Boolean(config.capabilityPublishEndpoint) !== Boolean(config.capabilityPublishCredential))
30
+ reasons.push("capability_publication_incomplete");
31
+ if (config.capabilityPublishEndpoint && !config.canonicalAgentName)
32
+ reasons.push("capability_agent_name_missing");
33
+ for (const [name, value] of [["speech", config.speechEndpoint], ["openclaw", runtimeEndpoint], ["grant", config.grantEndpoint], ["capability", config.capabilityPublishEndpoint], ["session", config.sessionControlEndpoint]]) {
34
+ if (value) {
35
+ try {
36
+ const url = new URL(value);
37
+ if (url.protocol !== "https:" && !["localhost", "127.0.0.1", "::1"].includes(url.hostname))
38
+ reasons.push(`${name}_endpoint_insecure`);
39
+ }
40
+ catch {
41
+ reasons.push(`${name}_endpoint_invalid`);
42
+ }
43
+ }
44
+ }
45
+ if (config.speechMode === "openai_turn") {
46
+ try {
47
+ const url = new URL(config.openAiBaseUrl ?? "https://api.openai.com/v1");
48
+ if (url.protocol !== "https:" && !["localhost", "127.0.0.1", "::1"].includes(url.hostname))
49
+ reasons.push("openai_endpoint_insecure");
50
+ }
51
+ catch {
52
+ reasons.push("openai_endpoint_invalid");
53
+ }
54
+ }
55
+ return { valid: reasons.length === 0, reasons };
56
+ }
57
+ export async function loadGatewayConfig(env = process.env) {
58
+ const secretFile = env.VOICE_SECRET_FILE?.trim();
59
+ let secrets = {};
60
+ if (secretFile)
61
+ secrets = JSON.parse(await readFile(secretFile, "utf8"));
62
+ return {
63
+ agentId: env.VOICE_AGENT_ID?.trim() ?? "",
64
+ canonicalAgentName: env.VOICE_CANONICAL_AGENT_NAME?.trim() || undefined,
65
+ port: Number(env.VOICE_PORT ?? 8080),
66
+ transcriptPolicy: env.VOICE_TRANSCRIPT_POLICY === "ephemeral" ? "ephemeral" : "off",
67
+ projectId: env.VOICE_PROJECT_ID?.trim() || undefined,
68
+ meetingId: env.VOICE_MEETING_ID?.trim() || undefined,
69
+ sessionControlEndpoint: env.VOICE_SESSION_CONTROL_ENDPOINT?.trim() || undefined,
70
+ grantFile: env.VOICE_GRANT_FILE?.trim() || undefined,
71
+ grantEndpoint: env.VOICE_GRANT_ENDPOINT?.trim() || undefined,
72
+ grantCredential: secrets.grantCredential,
73
+ speechEndpoint: env.VOICE_SPEECH_ENDPOINT?.trim() || undefined,
74
+ speechCredential: secrets.speechCredential,
75
+ speechMode: env.VOICE_SPEECH_MODE === "openai_turn" ? "openai_turn" : "byo_http",
76
+ openAiApiKey: secrets.openAiApiKey,
77
+ openAiBaseUrl: env.OPENAI_BASE_URL?.trim() || "https://api.openai.com/v1",
78
+ openAiTranscriptionModel: env.OPENAI_TRANSCRIPTION_MODEL?.trim() || "gpt-4o-mini-transcribe",
79
+ openAiSpeechModel: env.OPENAI_SPEECH_MODEL?.trim() || "gpt-4o-mini-tts",
80
+ openAiVoice: env.OPENAI_SPEECH_VOICE?.trim() || "alloy",
81
+ openAiVoiceOverride: env.OPENAI_SPEECH_VOICE_OVERRIDE?.trim() || undefined,
82
+ openClawEndpoint: env.OPENCLAW_ENDPOINT?.trim() || env.OPENCLAW_RESPONSES_ENDPOINT?.trim() || undefined,
83
+ openClawToken: secrets.openClawToken,
84
+ openClawApiMode: env.OPENCLAW_API_MODE === "chat_completions" ? "chat_completions" : "responses",
85
+ runtimeKind: ["hermes", "openclaw", "zeroclaw"].includes(env.PERKOS_VOICE_RUNTIME ?? "") ? env.PERKOS_VOICE_RUNTIME : "openclaw",
86
+ runtimeProtocol: env.PERKOS_VOICE_RUNTIME_PROTOCOL === "zeroclaw_webhook"
87
+ ? "zeroclaw_webhook"
88
+ : env.PERKOS_VOICE_RUNTIME_PROTOCOL === "chat_completions"
89
+ ? "chat_completions"
90
+ : env.PERKOS_VOICE_RUNTIME_PROTOCOL === "responses"
91
+ ? "responses"
92
+ : undefined,
93
+ runtimeEndpoint: env.PERKOS_VOICE_RUNTIME_ENDPOINT?.trim() || undefined,
94
+ runtimeToken: secrets.runtimeToken,
95
+ runtimeModel: env.PERKOS_VOICE_RUNTIME_MODEL?.trim() || undefined,
96
+ openClawChatModel: env.OPENCLAW_CHAT_MODEL?.trim() || undefined,
97
+ openClawVoiceModelOverride: env.OPENCLAW_VOICE_MODEL?.trim() || undefined,
98
+ openClawVoiceModelAllowlist: (env.OPENCLAW_VOICE_MODEL_ALLOWLIST ?? "").split(",").map((value) => value.trim()).filter(Boolean),
99
+ openClawSpokenName: env.OPENCLAW_SPOKEN_NAME?.trim() || env.VOICE_CANONICAL_AGENT_NAME?.trim() || undefined,
100
+ openClawMaxTokens: env.OPENCLAW_MAX_TOKENS ? Number(env.OPENCLAW_MAX_TOKENS) : 80,
101
+ runtimeTimeoutMs: Number(env.VOICE_RUNTIME_TIMEOUT_MS ?? 12_000),
102
+ speechSlaMs: Number(env.VOICE_SPEECH_SLA_MS ?? 4_000),
103
+ presenceMs: env.VOICE_PRESENCE_MS === undefined ? 1_500 : Number(env.VOICE_PRESENCE_MS),
104
+ capabilityPublishEndpoint: env.VOICE_CAPABILITY_PUBLISH_ENDPOINT?.trim() || undefined,
105
+ capabilityPublishCredential: secrets.capabilityPublishCredential,
106
+ };
107
+ }
@@ -0,0 +1,55 @@
1
+ import type { GatewayConfig } from "./config.js";
2
+ import { type ExternalAgentOnboardingContract, type ExternalAgentPreflightCode, type ExternalAgentProbePorts } from "./onboarding.js";
3
+ import type { VoiceSessionStage } from "./mediaStages.js";
4
+ /**
5
+ * Fixed, allow-listed health codes shared by owner-side doctor, gateway
6
+ * readiness, and the PerkOS control plane. Never include secrets, URLs,
7
+ * transcripts, chat content, or upstream error bodies.
8
+ */
9
+ export declare const VOICE_HEALTH_CODES: readonly ["contract_invalid", "config_invalid", "runtime_unhealthy", "runtime_not_ready", "runtime_response_failed", "runtime_response_too_slow", "media_unavailable", "speech_unavailable", "control_plane_unavailable", "capability_publish_unavailable", "session_grant_failed", "session_media_failed", "session_runtime_failed", "session_stage_failed"];
10
+ export type VoiceHealthCode = (typeof VOICE_HEALTH_CODES)[number];
11
+ export type VoiceHealthSource = "doctor" | "gateway_readiness" | "session_outcome";
12
+ export interface VoiceHealthReport {
13
+ schemaVersion: 1;
14
+ ready: boolean;
15
+ codes: VoiceHealthCode[];
16
+ checkedAt: string;
17
+ source: VoiceHealthSource;
18
+ /** Optional allow-listed session stage when source is session_outcome. */
19
+ stage?: VoiceSessionStage;
20
+ }
21
+ export interface VoiceHealthPlaybook {
22
+ code: VoiceHealthCode;
23
+ title: string;
24
+ /** What the agent owner / their ops agent should do on their side. */
25
+ ownerActions: string[];
26
+ /** What PerkOS platform already does / will surface. */
27
+ platformNotes: string[];
28
+ }
29
+ export declare function isVoiceHealthCode(value: unknown): value is VoiceHealthCode;
30
+ export declare function sanitizeVoiceHealthCodes(codes: readonly unknown[]): VoiceHealthCode[];
31
+ /** Map gateway readiness reason strings → fixed health codes. */
32
+ export declare function mapReadinessReasonsToCodes(reasons: readonly string[]): VoiceHealthCode[];
33
+ export declare function mapSessionStageToCodes(stage: VoiceSessionStage | string): VoiceHealthCode[];
34
+ export declare function playbookForCode(code: VoiceHealthCode): VoiceHealthPlaybook;
35
+ export declare function playbooksForCodes(codes: readonly VoiceHealthCode[]): VoiceHealthPlaybook[];
36
+ export declare function defaultDoctorContract(config: GatewayConfig): ExternalAgentOnboardingContract;
37
+ export declare function buildDoctorProbePorts(config: GatewayConfig, fetcher?: typeof fetch): ExternalAgentProbePorts;
38
+ export interface DoctorRunResult extends VoiceHealthReport {
39
+ playbooks: VoiceHealthPlaybook[];
40
+ preflightCodes: ExternalAgentPreflightCode[];
41
+ }
42
+ export declare function runConfiguredVoiceDoctor(config: GatewayConfig, options?: {
43
+ fetcher?: typeof fetch;
44
+ now?: () => Date;
45
+ source?: VoiceHealthSource;
46
+ }): Promise<DoctorRunResult>;
47
+ export declare function healthReportFromReadiness(snapshot: {
48
+ ready: boolean;
49
+ reasons: string[];
50
+ checkedAt?: string;
51
+ }): VoiceHealthReport;
52
+ export declare function healthReportFromSessionFailure(stage: VoiceSessionStage | string): VoiceHealthReport;
53
+ export declare function reportVoiceHealthToControlPlane(endpoint: string, credential: string, report: VoiceHealthReport, fetcher?: typeof fetch): Promise<void>;
54
+ /** Derive POST .../voice-control/health from session control base endpoint. */
55
+ export declare function voiceHealthReportEndpoint(sessionControlEndpoint: string): string;
package/dist/doctor.js ADDED
@@ -0,0 +1,423 @@
1
+ import { validateByoConfig } from "./config.js";
2
+ import { EXTERNAL_AGENT_CONTRACT_VERSION, runExternalAgentPreflight, } from "./onboarding.js";
3
+ /**
4
+ * Fixed, allow-listed health codes shared by owner-side doctor, gateway
5
+ * readiness, and the PerkOS control plane. Never include secrets, URLs,
6
+ * transcripts, chat content, or upstream error bodies.
7
+ */
8
+ export const VOICE_HEALTH_CODES = [
9
+ "contract_invalid",
10
+ "config_invalid",
11
+ "runtime_unhealthy",
12
+ "runtime_not_ready",
13
+ "runtime_response_failed",
14
+ "runtime_response_too_slow",
15
+ "media_unavailable",
16
+ "speech_unavailable",
17
+ "control_plane_unavailable",
18
+ "capability_publish_unavailable",
19
+ "session_grant_failed",
20
+ "session_media_failed",
21
+ "session_runtime_failed",
22
+ "session_stage_failed",
23
+ ];
24
+ const HEALTH_CODE_SET = new Set(VOICE_HEALTH_CODES);
25
+ export function isVoiceHealthCode(value) {
26
+ return typeof value === "string" && HEALTH_CODE_SET.has(value);
27
+ }
28
+ export function sanitizeVoiceHealthCodes(codes) {
29
+ const out = [];
30
+ for (const code of codes) {
31
+ if (isVoiceHealthCode(code) && !out.includes(code))
32
+ out.push(code);
33
+ }
34
+ return out;
35
+ }
36
+ /** Map gateway readiness reason strings → fixed health codes. */
37
+ export function mapReadinessReasonsToCodes(reasons) {
38
+ const codes = new Set();
39
+ for (const reason of reasons) {
40
+ if (reason === "checks_pending") {
41
+ codes.add("runtime_not_ready");
42
+ continue;
43
+ }
44
+ if (reason === "speech_probe_failed" || reason.includes("speech") || reason.includes("openai")) {
45
+ codes.add("speech_unavailable");
46
+ continue;
47
+ }
48
+ if (reason === "control_plane_probe_failed" || reason.includes("session_control") || reason.includes("grant")) {
49
+ codes.add("control_plane_unavailable");
50
+ continue;
51
+ }
52
+ if (reason === "openclaw_probe_failed" || reason.includes("openclaw") || reason.includes("runtime")) {
53
+ codes.add("runtime_not_ready");
54
+ continue;
55
+ }
56
+ if (reason.includes("capability")) {
57
+ codes.add("capability_publish_unavailable");
58
+ continue;
59
+ }
60
+ if (reason.includes("agent_id") ||
61
+ reason.includes("missing") ||
62
+ reason.includes("invalid") ||
63
+ reason.includes("insecure") ||
64
+ reason.includes("incomplete") ||
65
+ reason.includes("not_allowed") ||
66
+ reason.includes("unsupported")) {
67
+ codes.add("config_invalid");
68
+ continue;
69
+ }
70
+ codes.add("config_invalid");
71
+ }
72
+ return [...codes];
73
+ }
74
+ export function mapSessionStageToCodes(stage) {
75
+ switch (stage) {
76
+ case "grant_obtain":
77
+ return ["session_grant_failed", "control_plane_unavailable"];
78
+ case "livekit_connect":
79
+ case "audio_source_create":
80
+ case "track_create":
81
+ case "track_publish":
82
+ return ["session_media_failed", "media_unavailable"];
83
+ case "gateway_start":
84
+ return ["session_stage_failed"];
85
+ case "status_joined":
86
+ return ["control_plane_unavailable", "session_stage_failed"];
87
+ case "turn_loop":
88
+ return ["session_runtime_failed", "runtime_response_failed"];
89
+ default:
90
+ return ["session_stage_failed"];
91
+ }
92
+ }
93
+ export function playbookForCode(code) {
94
+ const books = {
95
+ contract_invalid: {
96
+ title: "External-agent contract invalid",
97
+ ownerActions: [
98
+ "Re-read docs/external-agent-onboarding.md and external-agent-contract.schema.json.",
99
+ "Ensure non-streaming runtime, health/ready/capabilities paths, and secret mode 0600.",
100
+ ],
101
+ platformNotes: ["PerkOS will keep capability unavailable until a valid doctor/preflight passes."],
102
+ },
103
+ config_invalid: {
104
+ title: "Gateway configuration incomplete",
105
+ ownerActions: [
106
+ "Check VOICE_* env and secret file (grant, capability, OpenClaw/runtime token, speech keys).",
107
+ "Confirm endpoints use https (or localhost) and VOICE_AGENT_ID / VOICE_CANONICAL_AGENT_NAME match registry.",
108
+ "Restart only the voice gateway process after fixing config.",
109
+ ],
110
+ platformNotes: ["Capability publish is rejected or expires when the gateway is misconfigured."],
111
+ },
112
+ runtime_unhealthy: {
113
+ title: "Agent runtime process not alive",
114
+ ownerActions: [
115
+ "Start/restart your agent runtime (OpenClaw, thin OpenAI-compatible API, etc.).",
116
+ "Verify the runtime process manager (launchd/systemd/docker) KeepAlive policy.",
117
+ ],
118
+ platformNotes: ["PerkOS never SSHes into your runtime; only readiness/capability signals."],
119
+ },
120
+ runtime_not_ready: {
121
+ title: "Agent runtime not ready for voice turns",
122
+ ownerActions: [
123
+ "Confirm OPENCLAW_ENDPOINT answers authenticated /v1/models (or your health probe).",
124
+ "If you changed models, pick one that responds within the declared budget (prefer thin chat-completions).",
125
+ "Do not point voice at a full tool-heavy agent stack if turn latency exceeds ~12–15s.",
126
+ ],
127
+ platformNotes: ["Public Call button stays Voice unavailable while readiness is false."],
128
+ },
129
+ runtime_response_failed: {
130
+ title: "Synthetic runtime turn failed",
131
+ ownerActions: [
132
+ "Test a short non-streaming chat completion with the same token/model as voice.",
133
+ "Fix auth, model name, or API mode (chat_completions vs responses).",
134
+ ],
135
+ platformNotes: ["Doctor and session outcomes report only fixed codes—no response bodies."],
136
+ },
137
+ runtime_response_too_slow: {
138
+ title: "Runtime turn exceeded budget",
139
+ ownerActions: [
140
+ "Switch to a thinner/faster model for voice, or raise only within contract max (≤60s) if your SLA allows.",
141
+ "Remove multi-k system/tool dumps from the voice path (use thin adapter).",
142
+ ],
143
+ platformNotes: ["Slow runtimes cause mid-call turn_loop failures even when /ready looked green."],
144
+ },
145
+ media_unavailable: {
146
+ title: "LiveKit / media path unavailable",
147
+ ownerActions: [
148
+ "Confirm outbound network to LiveKit from the gateway host.",
149
+ "Re-run doctor after network/firewall changes; do not paste LiveKit secrets into chat.",
150
+ ],
151
+ platformNotes: ["API readiness requires LiveKit provider config on PerkOS side."],
152
+ },
153
+ speech_unavailable: {
154
+ title: "STT/TTS speech provider unavailable",
155
+ ownerActions: [
156
+ "Verify OpenAI (or BYO speech) API key in the gateway secret file.",
157
+ "Probe speech provider health; rotate key if unauthorized.",
158
+ ],
159
+ platformNotes: ["Speech failures surface as speech_unavailable without logging audio."],
160
+ },
161
+ control_plane_unavailable: {
162
+ title: "PerkOS voice control plane unreachable or unauthorized",
163
+ ownerActions: [
164
+ "Confirm VOICE_SESSION_CONTROL_ENDPOINT and grant endpoint reach api.perkos.xyz.",
165
+ "Rotate voice gateway M2M credential if 401; deliver only via secure file (never print full secret).",
166
+ ],
167
+ platformNotes: ["M2M audience is perkos-voice-gateway-grant:v1; invalid credentials fail closed."],
168
+ },
169
+ capability_publish_unavailable: {
170
+ title: "Capability publication failed",
171
+ ownerActions: [
172
+ "Ensure capability publish endpoint + credential match the grant credential.",
173
+ "Keep the gateway process up so short leases renew (~30–60s).",
174
+ ],
175
+ platformNotes: ["Expired or unpublished capability → Call UI shows Voice unavailable."],
176
+ },
177
+ session_grant_failed: {
178
+ title: "Live session grant failed",
179
+ ownerActions: [
180
+ "Verify M2M credential still valid and agent is active in the project meeting.",
181
+ "Restart gateway; re-test one short call after doctor is green.",
182
+ ],
183
+ platformNotes: ["Grants require a claimed session; credential alone cannot mint arbitrary meetings."],
184
+ },
185
+ session_media_failed: {
186
+ title: "In-call media stage failed",
187
+ ownerActions: [
188
+ "Check LiveKit connectivity and local audio publish permissions on the gateway host.",
189
+ "Inspect gateway metrics logs for voice_session_stage_failed:livekit_* (no audio content).",
190
+ ],
191
+ platformNotes: ["Session status reason media_failed is stored without transcripts."],
192
+ },
193
+ session_runtime_failed: {
194
+ title: "In-call runtime / turn loop failed",
195
+ ownerActions: [
196
+ "Run perkos-voice-doctor; fix runtime_response_* codes first.",
197
+ "Watch for model timeouts after config/model changes.",
198
+ ],
199
+ platformNotes: ["Failed sessions update voice health history for the owner panel."],
200
+ },
201
+ session_stage_failed: {
202
+ title: "Unspecified voice session stage failed",
203
+ ownerActions: [
204
+ "Run perkos-voice-doctor and restart the gateway if ready=false.",
205
+ "Retry one short Private Call after doctor is green.",
206
+ ],
207
+ platformNotes: ["Stage name may be attached when reported; content is never stored."],
208
+ },
209
+ };
210
+ return { code, ...books[code] };
211
+ }
212
+ export function playbooksForCodes(codes) {
213
+ return codes.map(playbookForCode);
214
+ }
215
+ export function defaultDoctorContract(config) {
216
+ return {
217
+ version: EXTERNAL_AGENT_CONTRACT_VERSION,
218
+ runtime: {
219
+ mode: config.runtimeProtocol ?? (config.openClawApiMode === "chat_completions" ? "chat_completions" : "responses"),
220
+ nonStreaming: true,
221
+ cancellation: true,
222
+ responseTimeoutMs: Math.min(60_000, Math.max(1_000, config.runtimeTimeoutMs ?? 12_000)),
223
+ healthTimeoutMs: 5_000,
224
+ },
225
+ media: { livekit: true, inboundAudio: true, outboundAudio: true, bargeIn: true },
226
+ speech: { stt: true, tts: true, transcriptPersistence: "off" },
227
+ control: {
228
+ dynamicSessions: true,
229
+ encryptedM2mDelivery: true,
230
+ oneTimeDiscovery: true,
231
+ capabilityHandshake: "allow_listed",
232
+ },
233
+ install: {
234
+ immutableRelease: true,
235
+ secretFileMode: "0600",
236
+ healthPath: "/health",
237
+ readinessPath: "/ready",
238
+ capabilityPath: "/capabilities",
239
+ configRevision: process.env.VOICE_CONFIG_REVISION?.trim() || "local",
240
+ },
241
+ };
242
+ }
243
+ export function buildDoctorProbePorts(config, fetcher = fetch) {
244
+ const timeout = (ms) => AbortSignal.timeout(ms);
245
+ return {
246
+ health: async () => true,
247
+ readiness: async (signal) => {
248
+ if (!config.sessionControlEndpoint || !config.grantCredential)
249
+ return false;
250
+ const response = await fetcher(`${config.sessionControlEndpoint.replace(/\/$/, "")}/readiness`, {
251
+ headers: { "x-perkos-voice-credential": config.grantCredential },
252
+ signal,
253
+ });
254
+ if (!response.ok)
255
+ return false;
256
+ const body = (await response.json());
257
+ return body.readiness?.ready === true;
258
+ },
259
+ runtimeTurn: async (signal) => {
260
+ const runtimeEndpoint = config.runtimeEndpoint ?? config.openClawEndpoint;
261
+ const runtimeToken = config.runtimeToken ?? config.openClawToken;
262
+ const runtimeModel = config.runtimeModel ?? config.openClawChatModel;
263
+ const runtimeProtocol = config.runtimeProtocol ?? config.openClawApiMode;
264
+ if (!runtimeEndpoint || !runtimeToken)
265
+ return false;
266
+ if (runtimeProtocol === "zeroclaw_webhook") {
267
+ const response = await fetcher(runtimeEndpoint, {
268
+ method: "POST",
269
+ headers: { authorization: `Bearer ${runtimeToken}`, "content-type": "application/json" },
270
+ body: JSON.stringify({ message: "Reply with one short word only: ping" }),
271
+ signal,
272
+ });
273
+ if (!response.ok)
274
+ return false;
275
+ const body = await response.json();
276
+ return typeof body.response === "string" && body.response.trim().length > 0;
277
+ }
278
+ if (runtimeProtocol === "chat_completions") {
279
+ const response = await fetcher(runtimeEndpoint, {
280
+ method: "POST",
281
+ headers: {
282
+ authorization: `Bearer ${runtimeToken}`,
283
+ "content-type": "application/json",
284
+ },
285
+ body: JSON.stringify({
286
+ model: runtimeModel,
287
+ messages: [
288
+ { role: "system", content: "Reply with one short word only." },
289
+ { role: "user", content: "ping" },
290
+ ],
291
+ max_tokens: 8,
292
+ temperature: 0,
293
+ }),
294
+ signal,
295
+ });
296
+ if (!response.ok)
297
+ return false;
298
+ const body = (await response.json());
299
+ const text = body.choices?.[0]?.message?.content;
300
+ return typeof text === "string" && text.trim().length > 0;
301
+ }
302
+ const origin = new URL(runtimeEndpoint).origin;
303
+ const response = await fetcher(`${origin}/v1/models`, {
304
+ headers: { authorization: `Bearer ${runtimeToken}` },
305
+ signal,
306
+ });
307
+ return response.ok;
308
+ },
309
+ media: async () => true,
310
+ speech: async (signal) => {
311
+ if (config.speechMode === "openai_turn") {
312
+ if (!config.openAiApiKey)
313
+ return false;
314
+ const base = (config.openAiBaseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
315
+ const response = await fetcher(`${base}/models`, {
316
+ headers: { authorization: `Bearer ${config.openAiApiKey}` },
317
+ signal,
318
+ });
319
+ return response.ok;
320
+ }
321
+ if (!config.speechEndpoint || !config.speechCredential)
322
+ return false;
323
+ const response = await fetcher(`${config.speechEndpoint.replace(/\/$/, "")}/health`, {
324
+ headers: { authorization: `Bearer ${config.speechCredential}` },
325
+ signal,
326
+ });
327
+ return response.ok;
328
+ },
329
+ controlPlane: async (signal) => {
330
+ if (!config.sessionControlEndpoint || !config.grantCredential)
331
+ return false;
332
+ const response = await fetcher(`${config.sessionControlEndpoint.replace(/\/$/, "")}/readiness`, {
333
+ headers: { "x-perkos-voice-credential": config.grantCredential },
334
+ signal,
335
+ });
336
+ return response.ok;
337
+ },
338
+ capabilityPublisher: async (signal) => {
339
+ if (!config.capabilityPublishEndpoint || !config.capabilityPublishCredential)
340
+ return false;
341
+ // OPTIONS-like: a GET is not defined; treat endpoint shape + credential presence as configured.
342
+ // Real publish is exercised by the running gateway; doctor avoids posting fake capability.
343
+ void signal;
344
+ return true;
345
+ },
346
+ };
347
+ }
348
+ export async function runConfiguredVoiceDoctor(config, options = {}) {
349
+ const now = options.now ?? (() => new Date());
350
+ const fetcher = options.fetcher ?? fetch;
351
+ const validation = validateByoConfig(config);
352
+ if (!validation.valid) {
353
+ const codes = mapReadinessReasonsToCodes(validation.reasons);
354
+ const finalCodes = codes.length > 0 ? codes : ["config_invalid"];
355
+ return {
356
+ schemaVersion: 1,
357
+ ready: false,
358
+ codes: finalCodes,
359
+ checkedAt: now().toISOString(),
360
+ source: options.source ?? "doctor",
361
+ playbooks: playbooksForCodes(finalCodes),
362
+ preflightCodes: ["contract_invalid"],
363
+ };
364
+ }
365
+ const contract = defaultDoctorContract(config);
366
+ const preflight = await runExternalAgentPreflight(contract, buildDoctorProbePorts(config, fetcher), now);
367
+ const codes = sanitizeVoiceHealthCodes(preflight.codes);
368
+ return {
369
+ schemaVersion: 1,
370
+ ready: preflight.ready && codes.length === 0,
371
+ codes,
372
+ checkedAt: preflight.checkedAt,
373
+ source: options.source ?? "doctor",
374
+ playbooks: playbooksForCodes(codes),
375
+ preflightCodes: preflight.codes,
376
+ };
377
+ }
378
+ export function healthReportFromReadiness(snapshot) {
379
+ const codes = snapshot.ready ? [] : mapReadinessReasonsToCodes(snapshot.reasons);
380
+ return {
381
+ schemaVersion: 1,
382
+ ready: snapshot.ready && codes.length === 0,
383
+ codes,
384
+ checkedAt: snapshot.checkedAt ?? new Date().toISOString(),
385
+ source: "gateway_readiness",
386
+ };
387
+ }
388
+ export function healthReportFromSessionFailure(stage) {
389
+ const codes = mapSessionStageToCodes(stage);
390
+ return {
391
+ schemaVersion: 1,
392
+ ready: false,
393
+ codes,
394
+ checkedAt: new Date().toISOString(),
395
+ source: "session_outcome",
396
+ stage: (typeof stage === "string" ? stage : "gateway_start"),
397
+ };
398
+ }
399
+ export async function reportVoiceHealthToControlPlane(endpoint, credential, report, fetcher = fetch) {
400
+ const body = {
401
+ schemaVersion: 1,
402
+ ready: report.ready,
403
+ codes: sanitizeVoiceHealthCodes(report.codes),
404
+ checkedAt: report.checkedAt,
405
+ source: report.source,
406
+ ...(report.stage ? { stage: report.stage } : {}),
407
+ };
408
+ const response = await fetcher(endpoint, {
409
+ method: "POST",
410
+ headers: {
411
+ "x-perkos-voice-credential": credential,
412
+ "content-type": "application/json",
413
+ },
414
+ body: JSON.stringify(body),
415
+ signal: AbortSignal.timeout(10_000),
416
+ });
417
+ if (!response.ok)
418
+ throw new Error(`voice_health_report_failed:${response.status}`);
419
+ }
420
+ /** Derive POST .../voice-control/health from session control base endpoint. */
421
+ export function voiceHealthReportEndpoint(sessionControlEndpoint) {
422
+ return `${sessionControlEndpoint.replace(/\/$/, "")}/health`;
423
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Owner-side Voice doctor for external (and internal) agents.
4
+ *
5
+ * Usage (same env + VOICE_SECRET_FILE as the gateway):
6
+ * perkos-voice-doctor
7
+ * perkos-voice-doctor --report # also POST codes to PerkOS control plane
8
+ *
9
+ * Exit 0 when ready; 1 when not. stdout is JSON only (no secrets).
10
+ */
11
+ import { loadGatewayConfig } from "./config.js";
12
+ import { reportVoiceHealthToControlPlane, runConfiguredVoiceDoctor, voiceHealthReportEndpoint, } from "./doctor.js";
13
+ const report = process.argv.includes("--report");
14
+ const config = await loadGatewayConfig();
15
+ const result = await runConfiguredVoiceDoctor(config);
16
+ let reported = false;
17
+ let reportError;
18
+ if (report && config.sessionControlEndpoint && config.grantCredential) {
19
+ try {
20
+ await reportVoiceHealthToControlPlane(voiceHealthReportEndpoint(config.sessionControlEndpoint), config.grantCredential, result);
21
+ reported = true;
22
+ }
23
+ catch {
24
+ reportError = "report_failed";
25
+ }
26
+ }
27
+ process.stdout.write(`${JSON.stringify({
28
+ schemaVersion: result.schemaVersion,
29
+ ready: result.ready,
30
+ codes: result.codes,
31
+ checkedAt: result.checkedAt,
32
+ source: result.source,
33
+ playbooks: result.playbooks,
34
+ ...(report ? { reported, ...(reportError ? { reportError } : {}) } : {}),
35
+ })}\n`);
36
+ process.exitCode = result.ready ? 0 : 1;
@@ -0,0 +1,13 @@
1
+ export declare class EchoSuppressionGate {
2
+ #private;
3
+ private readonly now;
4
+ private readonly cooldownMs;
5
+ private readonly onSuppressed;
6
+ constructor(now?: () => number, cooldownMs?: number, onSuppressed?: () => void);
7
+ start(barrier: Promise<void>): void;
8
+ beginOutput(): void;
9
+ endOutput(): void;
10
+ shouldSuppress(): boolean;
11
+ waitForBarrier(): Promise<void>;
12
+ }
13
+ export declare function acceptRemoteMicrophone(source: unknown, microphoneSource: unknown, remoteIdentity: string, localIdentity?: string, agentIdentity?: string): boolean;