@perkos/perkos-voice 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/a2aEnrollment.d.ts +41 -0
- package/dist/a2aEnrollment.js +110 -0
- package/dist/acceptance.d.ts +25 -0
- package/dist/acceptance.js +147 -0
- package/dist/acceptanceCli.d.ts +2 -0
- package/dist/acceptanceCli.js +6 -0
- package/dist/adapters/livekit.d.ts +15 -0
- package/dist/adapters/livekit.js +236 -0
- package/dist/adapters/openaiSpeech.d.ts +24 -0
- package/dist/adapters/openaiSpeech.js +194 -0
- package/dist/adapters/openclaw.d.ts +58 -0
- package/dist/adapters/openclaw.js +236 -0
- package/dist/adapters/speech.d.ts +13 -0
- package/dist/adapters/speech.js +57 -0
- package/dist/adapters/zeroclaw.d.ts +12 -0
- package/dist/adapters/zeroclaw.js +36 -0
- package/dist/bootstrap.d.ts +2 -0
- package/dist/bootstrap.js +68 -0
- package/dist/bragiDelivery.d.ts +31 -0
- package/dist/bragiDelivery.js +263 -0
- package/dist/bragiDeliveryCli.d.ts +2 -0
- package/dist/bragiDeliveryCli.js +32 -0
- package/dist/capability.d.ts +15 -0
- package/dist/capability.js +84 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +188 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +107 -0
- package/dist/doctor.d.ts +55 -0
- package/dist/doctor.js +423 -0
- package/dist/doctorCli.d.ts +2 -0
- package/dist/doctorCli.js +36 -0
- package/dist/echoSuppression.d.ts +13 -0
- package/dist/echoSuppression.js +42 -0
- package/dist/fakes.d.ts +42 -0
- package/dist/fakes.js +80 -0
- package/dist/gateway.d.ts +46 -0
- package/dist/gateway.js +418 -0
- package/dist/grants.d.ts +22 -0
- package/dist/grants.js +40 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/installer.d.ts +41 -0
- package/dist/installer.js +83 -0
- package/dist/mediaMetrics.d.ts +15 -0
- package/dist/mediaMetrics.js +43 -0
- package/dist/mediaStages.d.ts +12 -0
- package/dist/mediaStages.js +48 -0
- package/dist/onboarding.d.ts +67 -0
- package/dist/onboarding.js +72 -0
- package/dist/openclaw-plugin.d.ts +16 -0
- package/dist/openclaw-plugin.js +47 -0
- package/dist/ports.d.ts +23 -0
- package/dist/ports.js +1 -0
- package/dist/presenceTone.d.ts +5 -0
- package/dist/presenceTone.js +43 -0
- package/dist/readiness.d.ts +27 -0
- package/dist/readiness.js +68 -0
- package/dist/service.d.ts +10 -0
- package/dist/service.js +42 -0
- package/dist/sessionControl.d.ts +55 -0
- package/dist/sessionControl.js +118 -0
- package/dist/speechErrors.d.ts +18 -0
- package/dist/speechErrors.js +26 -0
- package/dist/state-machine.d.ts +55 -0
- package/dist/state-machine.js +107 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +1 -0
- package/dist/voiceSubtask.d.ts +12 -0
- package/dist/voiceSubtask.js +46 -0
- package/dist/workCallContext.d.ts +14 -0
- package/dist/workCallContext.js +26 -0
- package/docs/external-agent-onboarding.md +152 -0
- package/external-agent-contract.schema.json +46 -0
- package/openclaw.plugin.json +27 -0
- package/package.json +78 -0
- package/scripts/hermes/install.mjs +70 -0
- package/scripts/run-with-env.mjs +23 -0
- package/scripts/zeroclaw/install.mjs +76 -0
package/dist/grants.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
function parseGrant(raw) {
|
|
3
|
+
const value = raw?.grant ?? raw;
|
|
4
|
+
if (!value || typeof value !== "object")
|
|
5
|
+
throw new Error("invalid grant response");
|
|
6
|
+
const grant = value;
|
|
7
|
+
for (const key of ["url", "roomName", "token", "expiresAt", "meetingId", "agentIdentity"]) {
|
|
8
|
+
if (typeof grant[key] !== "string" || !grant[key])
|
|
9
|
+
throw new Error(`grant ${key} missing`);
|
|
10
|
+
}
|
|
11
|
+
return grant;
|
|
12
|
+
}
|
|
13
|
+
export class FileGrantSource {
|
|
14
|
+
path;
|
|
15
|
+
constructor(path) {
|
|
16
|
+
this.path = path;
|
|
17
|
+
}
|
|
18
|
+
async obtain() { return parseGrant(JSON.parse(await readFile(this.path, "utf8"))); }
|
|
19
|
+
}
|
|
20
|
+
export class M2mGrantSource {
|
|
21
|
+
endpoint;
|
|
22
|
+
credential;
|
|
23
|
+
fetcher;
|
|
24
|
+
constructor(endpoint, credential, fetcher = fetch) {
|
|
25
|
+
this.endpoint = endpoint;
|
|
26
|
+
this.credential = credential;
|
|
27
|
+
this.fetcher = fetcher;
|
|
28
|
+
}
|
|
29
|
+
async obtain(request) {
|
|
30
|
+
const response = await this.fetcher(this.endpoint, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "content-type": "application/json", "x-perkos-voice-credential": this.credential },
|
|
33
|
+
body: JSON.stringify(request),
|
|
34
|
+
signal: AbortSignal.timeout(10_000),
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok)
|
|
37
|
+
throw new Error(`grant request failed (${response.status})`);
|
|
38
|
+
return parseGrant(await response.json());
|
|
39
|
+
}
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./mediaStages.js";
|
|
3
|
+
export * from "./bragiDelivery.js";
|
|
4
|
+
export * from "./config.js";
|
|
5
|
+
export * from "./grants.js";
|
|
6
|
+
export * from "./service.js";
|
|
7
|
+
export * from "./adapters/livekit.js";
|
|
8
|
+
export * from "./adapters/openclaw.js";
|
|
9
|
+
export * from "./adapters/speech.js";
|
|
10
|
+
export * from "./adapters/openaiSpeech.js";
|
|
11
|
+
export * from "./readiness.js";
|
|
12
|
+
export * from "./sessionControl.js";
|
|
13
|
+
export * from "./capability.js";
|
|
14
|
+
export * from "./state-machine.js";
|
|
15
|
+
export * from "./ports.js";
|
|
16
|
+
export * from "./gateway.js";
|
|
17
|
+
export * from "./fakes.js";
|
|
18
|
+
export * from "./onboarding.js";
|
|
19
|
+
export * from "./installer.js";
|
|
20
|
+
export * from "./adapters/zeroclaw.js";
|
|
21
|
+
export * from "./doctor.js";
|
|
22
|
+
export * from "./a2aEnrollment.js";
|
|
23
|
+
export * from "./workCallContext.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./mediaStages.js";
|
|
3
|
+
export * from "./bragiDelivery.js";
|
|
4
|
+
export * from "./config.js";
|
|
5
|
+
export * from "./grants.js";
|
|
6
|
+
export * from "./service.js";
|
|
7
|
+
export * from "./adapters/livekit.js";
|
|
8
|
+
export * from "./adapters/openclaw.js";
|
|
9
|
+
export * from "./adapters/speech.js";
|
|
10
|
+
export * from "./adapters/openaiSpeech.js";
|
|
11
|
+
export * from "./readiness.js";
|
|
12
|
+
export * from "./sessionControl.js";
|
|
13
|
+
export * from "./capability.js";
|
|
14
|
+
export * from "./state-machine.js";
|
|
15
|
+
export * from "./ports.js";
|
|
16
|
+
export * from "./gateway.js";
|
|
17
|
+
export * from "./fakes.js";
|
|
18
|
+
export * from "./onboarding.js";
|
|
19
|
+
export * from "./installer.js";
|
|
20
|
+
export * from "./adapters/zeroclaw.js";
|
|
21
|
+
export * from "./doctor.js";
|
|
22
|
+
export * from "./a2aEnrollment.js";
|
|
23
|
+
export * from "./workCallContext.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export declare const VOICE_INSTALL_CONTRACT_VERSION: "perkos.voice.install/v1";
|
|
2
|
+
export type SupportedRuntime = "hermes" | "openclaw" | "zeroclaw";
|
|
3
|
+
export type RuntimeProtocol = "openai_responses" | "openai_chat_completions" | "zeroclaw_webhook";
|
|
4
|
+
export type VoiceEnvironment = "dev" | "qa" | "production";
|
|
5
|
+
export type ServiceManager = "launchd" | "systemd" | "compose";
|
|
6
|
+
export interface RuntimeProfile {
|
|
7
|
+
runtime: SupportedRuntime;
|
|
8
|
+
protocols: readonly RuntimeProtocol[];
|
|
9
|
+
defaultProtocol: RuntimeProtocol;
|
|
10
|
+
defaultEndpoint?: string;
|
|
11
|
+
defaultModel?: string;
|
|
12
|
+
pluginPackage: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const RUNTIME_PROFILES: Readonly<Record<SupportedRuntime, RuntimeProfile>>;
|
|
15
|
+
export interface VoiceInstallRequest {
|
|
16
|
+
version: typeof VOICE_INSTALL_CONTRACT_VERSION;
|
|
17
|
+
runtime: SupportedRuntime;
|
|
18
|
+
environment: VoiceEnvironment;
|
|
19
|
+
agentId: string;
|
|
20
|
+
canonicalAgentName: string;
|
|
21
|
+
releaseRevision: string;
|
|
22
|
+
configRevision: string;
|
|
23
|
+
serviceManager: ServiceManager;
|
|
24
|
+
protocol?: RuntimeProtocol;
|
|
25
|
+
runtimeEndpoint?: string;
|
|
26
|
+
runtimeModel?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface VoiceInstallPlan {
|
|
29
|
+
runtime: SupportedRuntime;
|
|
30
|
+
environment: VoiceEnvironment;
|
|
31
|
+
protocol: RuntimeProtocol;
|
|
32
|
+
runtimeEndpoint: string;
|
|
33
|
+
runtimeModel?: string;
|
|
34
|
+
serviceName: string;
|
|
35
|
+
pluginPackage: string;
|
|
36
|
+
releaseRevision: string;
|
|
37
|
+
configRevision: string;
|
|
38
|
+
secretFileMode: "0600";
|
|
39
|
+
checks: readonly ["/health", "/ready", "/capabilities", "doctor_report"];
|
|
40
|
+
}
|
|
41
|
+
export declare function planVoicePluginInstall(request: VoiceInstallRequest): VoiceInstallPlan;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const VOICE_INSTALL_CONTRACT_VERSION = "perkos.voice.install/v1";
|
|
2
|
+
export const RUNTIME_PROFILES = {
|
|
3
|
+
hermes: {
|
|
4
|
+
runtime: "hermes",
|
|
5
|
+
protocols: ["openai_chat_completions", "openai_responses"],
|
|
6
|
+
defaultProtocol: "openai_chat_completions",
|
|
7
|
+
defaultEndpoint: "http://127.0.0.1:8642/v1/chat/completions",
|
|
8
|
+
defaultModel: "hermes-agent",
|
|
9
|
+
pluginPackage: "perkos-voice",
|
|
10
|
+
},
|
|
11
|
+
openclaw: {
|
|
12
|
+
runtime: "openclaw",
|
|
13
|
+
protocols: ["openai_responses", "openai_chat_completions"],
|
|
14
|
+
defaultProtocol: "openai_responses",
|
|
15
|
+
defaultEndpoint: "http://127.0.0.1:18789/v1/responses",
|
|
16
|
+
pluginPackage: "@perkos/perkos-voice",
|
|
17
|
+
},
|
|
18
|
+
zeroclaw: {
|
|
19
|
+
runtime: "zeroclaw",
|
|
20
|
+
protocols: ["zeroclaw_webhook"],
|
|
21
|
+
defaultProtocol: "zeroclaw_webhook",
|
|
22
|
+
defaultEndpoint: "http://127.0.0.1:42617/webhook",
|
|
23
|
+
pluginPackage: "xyz.perkos.voice",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/;
|
|
27
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
28
|
+
function serviceSlug(value) {
|
|
29
|
+
return value.toLocaleLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48);
|
|
30
|
+
}
|
|
31
|
+
function validatePrivateRuntimeEndpoint(endpoint) {
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(endpoint);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new Error("runtime_endpoint_invalid");
|
|
38
|
+
}
|
|
39
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
40
|
+
throw new Error("runtime_endpoint_protocol_invalid");
|
|
41
|
+
if (!LOOPBACK_HOSTS.has(url.hostname))
|
|
42
|
+
throw new Error("runtime_endpoint_not_loopback");
|
|
43
|
+
if (url.username || url.password || url.search || url.hash)
|
|
44
|
+
throw new Error("runtime_endpoint_contains_credentials_or_metadata");
|
|
45
|
+
}
|
|
46
|
+
export function planVoicePluginInstall(request) {
|
|
47
|
+
if (request.version !== VOICE_INSTALL_CONTRACT_VERSION)
|
|
48
|
+
throw new Error("install_contract_unsupported");
|
|
49
|
+
if (!SAFE_ID.test(request.agentId))
|
|
50
|
+
throw new Error("agent_id_invalid");
|
|
51
|
+
if (!request.canonicalAgentName.trim() || request.canonicalAgentName.length > 64)
|
|
52
|
+
throw new Error("canonical_agent_name_invalid");
|
|
53
|
+
if (!request.releaseRevision.trim())
|
|
54
|
+
throw new Error("release_revision_missing");
|
|
55
|
+
if (!request.configRevision.trim())
|
|
56
|
+
throw new Error("config_revision_missing");
|
|
57
|
+
const profile = RUNTIME_PROFILES[request.runtime];
|
|
58
|
+
const protocol = request.protocol ?? profile.defaultProtocol;
|
|
59
|
+
if (!profile.protocols.includes(protocol))
|
|
60
|
+
throw new Error("runtime_protocol_unsupported");
|
|
61
|
+
const runtimeEndpoint = request.runtimeEndpoint?.trim() || profile.defaultEndpoint;
|
|
62
|
+
if (!runtimeEndpoint)
|
|
63
|
+
throw new Error("runtime_endpoint_discovery_required");
|
|
64
|
+
validatePrivateRuntimeEndpoint(runtimeEndpoint);
|
|
65
|
+
const runtimeModel = request.runtimeModel?.trim() || profile.defaultModel;
|
|
66
|
+
if (protocol === "openai_chat_completions" && !runtimeModel)
|
|
67
|
+
throw new Error("runtime_model_missing");
|
|
68
|
+
const slug = serviceSlug(request.canonicalAgentName) || serviceSlug(request.agentId);
|
|
69
|
+
const prefix = request.serviceManager === "launchd" ? "xyz.perkos.voice" : "perkos-voice";
|
|
70
|
+
return {
|
|
71
|
+
runtime: request.runtime,
|
|
72
|
+
environment: request.environment,
|
|
73
|
+
protocol,
|
|
74
|
+
runtimeEndpoint,
|
|
75
|
+
...(runtimeModel ? { runtimeModel } : {}),
|
|
76
|
+
serviceName: `${prefix}.${slug}.${request.environment}`,
|
|
77
|
+
pluginPackage: profile.pluginPackage,
|
|
78
|
+
releaseRevision: request.releaseRevision,
|
|
79
|
+
configRevision: request.configRevision,
|
|
80
|
+
secretFileMode: "0600",
|
|
81
|
+
checks: ["/health", "/ready", "/capabilities", "doctor_report"],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const MEDIA_SUCCESS_EVENTS: readonly ["track_subscribed", "turn_received", "stt_success", "openclaw_success", "openclaw_model_default", "openclaw_model_canary", "openclaw_latency_fast", "openclaw_latency_acceptable", "openclaw_latency_slow", "tts_success", "audio_publish_started", "audio_publish_completed", "tts_first_byte", "publish_cancelled", "remote_track_rejected", "self_loop_suppressed", "barge_in_candidate", "barge_in_confirmed", "publish_cancelled_barge_in", "publish_cancelled_session_close", "chat_commit_started", "chat_commit_succeeded", "chat_commit_failed", "chat_commit_skipped_cancelled", "turn_based_input_suppressed", "chat_policy_normal", "chat_policy_private", "work_chat_brief_loaded", "work_chat_brief_empty", "runtime_timeout", "turn_cancelled_session_close", "queued_turns_discarded", "openclaw_response_filtered", "openclaw_language_retry", "openclaw_safe_fallback", "chat_fallback_started", "chat_fallback_succeeded", "chat_fallback_failed", "voice_subtask_started", "voice_subtask_completed", "voice_subtask_timeout", "voice_subtask_failed", "voice_presence_started", "voice_presence_cancelled"];
|
|
2
|
+
export type MediaSuccessEvent = typeof MEDIA_SUCCESS_EVENTS[number];
|
|
3
|
+
export type MediaSuccessObserver = (event: MediaSuccessEvent) => void;
|
|
4
|
+
export declare class MediaSuccessMetrics {
|
|
5
|
+
#private;
|
|
6
|
+
private readonly logger;
|
|
7
|
+
constructor(logger?: (line: string) => void);
|
|
8
|
+
mark: MediaSuccessObserver;
|
|
9
|
+
beginSession(): void;
|
|
10
|
+
summarizeSession(): void;
|
|
11
|
+
snapshot(): Record<MediaSuccessEvent, {
|
|
12
|
+
observed: boolean;
|
|
13
|
+
count: number;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const MEDIA_SUCCESS_EVENTS = [
|
|
2
|
+
"track_subscribed", "turn_received", "stt_success", "openclaw_success",
|
|
3
|
+
"openclaw_model_default", "openclaw_model_canary", "openclaw_latency_fast", "openclaw_latency_acceptable", "openclaw_latency_slow",
|
|
4
|
+
"tts_success", "audio_publish_started", "audio_publish_completed",
|
|
5
|
+
"tts_first_byte",
|
|
6
|
+
"publish_cancelled", "remote_track_rejected", "self_loop_suppressed",
|
|
7
|
+
"barge_in_candidate", "barge_in_confirmed", "publish_cancelled_barge_in",
|
|
8
|
+
"publish_cancelled_session_close", "chat_commit_started", "chat_commit_succeeded",
|
|
9
|
+
"chat_commit_failed", "chat_commit_skipped_cancelled",
|
|
10
|
+
"turn_based_input_suppressed", "chat_policy_normal", "chat_policy_private",
|
|
11
|
+
"work_chat_brief_loaded", "work_chat_brief_empty",
|
|
12
|
+
"runtime_timeout", "turn_cancelled_session_close", "queued_turns_discarded",
|
|
13
|
+
"openclaw_response_filtered", "openclaw_language_retry", "openclaw_safe_fallback",
|
|
14
|
+
"chat_fallback_started", "chat_fallback_succeeded", "chat_fallback_failed",
|
|
15
|
+
"voice_subtask_started", "voice_subtask_completed", "voice_subtask_timeout", "voice_subtask_failed",
|
|
16
|
+
"voice_presence_started", "voice_presence_cancelled",
|
|
17
|
+
];
|
|
18
|
+
export class MediaSuccessMetrics {
|
|
19
|
+
logger;
|
|
20
|
+
#counts = new Map();
|
|
21
|
+
constructor(logger = (line) => process.stderr.write(`${line}\n`)) {
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
}
|
|
24
|
+
mark = (event) => {
|
|
25
|
+
const count = (this.#counts.get(event) ?? 0) + 1;
|
|
26
|
+
this.#counts.set(event, count);
|
|
27
|
+
this.logger(`voice media success: ${event} count=${count}`);
|
|
28
|
+
};
|
|
29
|
+
beginSession() {
|
|
30
|
+
this.#counts.clear();
|
|
31
|
+
this.logger("voice session metrics: started");
|
|
32
|
+
}
|
|
33
|
+
summarizeSession() {
|
|
34
|
+
const values = MEDIA_SUCCESS_EVENTS.map((event) => `${event}=${this.#counts.get(event) ?? 0}`).join(" ");
|
|
35
|
+
this.logger(`voice session metrics: completed ${values}`);
|
|
36
|
+
}
|
|
37
|
+
snapshot() {
|
|
38
|
+
return Object.fromEntries(MEDIA_SUCCESS_EVENTS.map((event) => {
|
|
39
|
+
const count = this.#counts.get(event) ?? 0;
|
|
40
|
+
return [event, { observed: count > 0, count }];
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const VOICE_SESSION_STAGES: readonly ["grant_obtain", "livekit_connect", "audio_source_create", "track_create", "track_publish", "gateway_start", "status_joined", "turn_loop"];
|
|
2
|
+
export type VoiceSessionStage = typeof VOICE_SESSION_STAGES[number];
|
|
3
|
+
export declare class VoiceSessionStageError extends Error {
|
|
4
|
+
readonly stage: VoiceSessionStage;
|
|
5
|
+
readonly code = "VOICE_SESSION_STAGE_FAILED";
|
|
6
|
+
constructor(stage: VoiceSessionStage);
|
|
7
|
+
}
|
|
8
|
+
export declare function voiceSessionStageLog(error: unknown, fallback?: VoiceSessionStage): string;
|
|
9
|
+
export declare function runVoiceSessionStage<T>(stage: VoiceSessionStage, operation: () => T | Promise<T>, options?: {
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
cleanup?: () => void | Promise<void>;
|
|
12
|
+
}): Promise<T>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const VOICE_SESSION_STAGES = [
|
|
2
|
+
"grant_obtain",
|
|
3
|
+
"livekit_connect",
|
|
4
|
+
"audio_source_create",
|
|
5
|
+
"track_create",
|
|
6
|
+
"track_publish",
|
|
7
|
+
"gateway_start",
|
|
8
|
+
"status_joined",
|
|
9
|
+
"turn_loop",
|
|
10
|
+
];
|
|
11
|
+
export class VoiceSessionStageError extends Error {
|
|
12
|
+
stage;
|
|
13
|
+
code = "VOICE_SESSION_STAGE_FAILED";
|
|
14
|
+
constructor(stage) {
|
|
15
|
+
super(`voice_session_stage_failed:${stage}`);
|
|
16
|
+
this.stage = stage;
|
|
17
|
+
this.name = "VoiceSessionStageError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function voiceSessionStageLog(error, fallback = "gateway_start") {
|
|
21
|
+
const stage = error instanceof VoiceSessionStageError ? error.stage : fallback;
|
|
22
|
+
return `voice_session_stage_failed:${stage}`;
|
|
23
|
+
}
|
|
24
|
+
export async function runVoiceSessionStage(stage, operation, options = {}) {
|
|
25
|
+
let timer;
|
|
26
|
+
try {
|
|
27
|
+
const pending = Promise.resolve().then(operation);
|
|
28
|
+
if (!options.timeoutMs)
|
|
29
|
+
return await pending;
|
|
30
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
31
|
+
timer = setTimeout(() => reject(new VoiceSessionStageError(stage)), options.timeoutMs);
|
|
32
|
+
});
|
|
33
|
+
return await Promise.race([pending, timeout]);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
try {
|
|
37
|
+
await options.cleanup?.();
|
|
38
|
+
}
|
|
39
|
+
catch { /* fixed fail-closed stage wins */ }
|
|
40
|
+
if (error instanceof VoiceSessionStageError)
|
|
41
|
+
throw error;
|
|
42
|
+
throw new VoiceSessionStageError(stage);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
if (timer)
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export declare const EXTERNAL_AGENT_CONTRACT_VERSION: "perkos.voice.external-agent/v1";
|
|
2
|
+
export type ExternalAgentPreflightCode = "contract_invalid" | "runtime_unhealthy" | "runtime_not_ready" | "runtime_response_failed" | "runtime_response_too_slow" | "media_unavailable" | "speech_unavailable" | "control_plane_unavailable" | "capability_publish_unavailable";
|
|
3
|
+
export interface ExternalAgentOnboardingContract {
|
|
4
|
+
version: typeof EXTERNAL_AGENT_CONTRACT_VERSION;
|
|
5
|
+
runtime: {
|
|
6
|
+
mode: "responses" | "chat_completions" | "zeroclaw_webhook";
|
|
7
|
+
nonStreaming: true;
|
|
8
|
+
cancellation: true;
|
|
9
|
+
responseTimeoutMs: number;
|
|
10
|
+
healthTimeoutMs: number;
|
|
11
|
+
};
|
|
12
|
+
media: {
|
|
13
|
+
livekit: true;
|
|
14
|
+
inboundAudio: true;
|
|
15
|
+
outboundAudio: true;
|
|
16
|
+
bargeIn: true;
|
|
17
|
+
};
|
|
18
|
+
speech: {
|
|
19
|
+
stt: true;
|
|
20
|
+
tts: true;
|
|
21
|
+
transcriptPersistence: "off";
|
|
22
|
+
};
|
|
23
|
+
control: {
|
|
24
|
+
dynamicSessions: true;
|
|
25
|
+
encryptedM2mDelivery: true;
|
|
26
|
+
oneTimeDiscovery: true;
|
|
27
|
+
capabilityHandshake: "allow_listed";
|
|
28
|
+
};
|
|
29
|
+
install: {
|
|
30
|
+
immutableRelease: true;
|
|
31
|
+
secretFileMode: "0600";
|
|
32
|
+
healthPath: "/health";
|
|
33
|
+
readinessPath: "/ready";
|
|
34
|
+
capabilityPath: "/capabilities";
|
|
35
|
+
configRevision: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export interface ExternalAgentProbePorts {
|
|
39
|
+
health(signal: AbortSignal): Promise<boolean>;
|
|
40
|
+
readiness(signal: AbortSignal): Promise<boolean>;
|
|
41
|
+
runtimeTurn(signal: AbortSignal): Promise<boolean>;
|
|
42
|
+
media(signal: AbortSignal): Promise<boolean>;
|
|
43
|
+
speech(signal: AbortSignal): Promise<boolean>;
|
|
44
|
+
controlPlane(signal: AbortSignal): Promise<boolean>;
|
|
45
|
+
capabilityPublisher(signal: AbortSignal): Promise<boolean>;
|
|
46
|
+
}
|
|
47
|
+
export interface ExternalAgentPreflightResult {
|
|
48
|
+
ready: boolean;
|
|
49
|
+
codes: ExternalAgentPreflightCode[];
|
|
50
|
+
checkedAt: string;
|
|
51
|
+
}
|
|
52
|
+
export interface InstalledExternalAgentState {
|
|
53
|
+
imageRevision?: string;
|
|
54
|
+
configRevision?: string;
|
|
55
|
+
secretFileMode?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface DesiredExternalAgentState {
|
|
58
|
+
imageRevision: string;
|
|
59
|
+
configRevision: string;
|
|
60
|
+
}
|
|
61
|
+
export interface InstallDecision {
|
|
62
|
+
action: "noop" | "reconfigure" | "replace_release";
|
|
63
|
+
reasons: string[];
|
|
64
|
+
}
|
|
65
|
+
export declare function validateExternalAgentContract(contract: ExternalAgentOnboardingContract): string[];
|
|
66
|
+
export declare function runExternalAgentPreflight(contract: ExternalAgentOnboardingContract, ports: ExternalAgentProbePorts, now?: () => Date): Promise<ExternalAgentPreflightResult>;
|
|
67
|
+
export declare function evaluateExternalAgentInstall(current: InstalledExternalAgentState, desired: DesiredExternalAgentState): InstallDecision;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const EXTERNAL_AGENT_CONTRACT_VERSION = "perkos.voice.external-agent/v1";
|
|
2
|
+
export function validateExternalAgentContract(contract) {
|
|
3
|
+
const reasons = [];
|
|
4
|
+
if (contract.version !== EXTERNAL_AGENT_CONTRACT_VERSION)
|
|
5
|
+
reasons.push("version_unsupported");
|
|
6
|
+
if (contract.runtime.nonStreaming !== true)
|
|
7
|
+
reasons.push("runtime_nonstreaming_required");
|
|
8
|
+
if (contract.runtime.cancellation !== true)
|
|
9
|
+
reasons.push("runtime_cancellation_required");
|
|
10
|
+
if (!Number.isInteger(contract.runtime.responseTimeoutMs) || contract.runtime.responseTimeoutMs < 1_000 || contract.runtime.responseTimeoutMs > 60_000)
|
|
11
|
+
reasons.push("runtime_response_budget_invalid");
|
|
12
|
+
if (!Number.isInteger(contract.runtime.healthTimeoutMs) || contract.runtime.healthTimeoutMs < 250 || contract.runtime.healthTimeoutMs > 10_000)
|
|
13
|
+
reasons.push("runtime_health_budget_invalid");
|
|
14
|
+
if (!contract.media.livekit || !contract.media.inboundAudio || !contract.media.outboundAudio || !contract.media.bargeIn)
|
|
15
|
+
reasons.push("media_contract_incomplete");
|
|
16
|
+
if (!contract.speech.stt || !contract.speech.tts || contract.speech.transcriptPersistence !== "off")
|
|
17
|
+
reasons.push("speech_contract_incomplete");
|
|
18
|
+
if (!contract.control.dynamicSessions || !contract.control.encryptedM2mDelivery || !contract.control.oneTimeDiscovery || contract.control.capabilityHandshake !== "allow_listed")
|
|
19
|
+
reasons.push("control_contract_incomplete");
|
|
20
|
+
if (!contract.install.immutableRelease || contract.install.secretFileMode !== "0600")
|
|
21
|
+
reasons.push("install_hardening_incomplete");
|
|
22
|
+
if (contract.install.healthPath !== "/health" || contract.install.readinessPath !== "/ready" || contract.install.capabilityPath !== "/capabilities")
|
|
23
|
+
reasons.push("service_paths_invalid");
|
|
24
|
+
if (!contract.install.configRevision.trim())
|
|
25
|
+
reasons.push("config_revision_missing");
|
|
26
|
+
return reasons;
|
|
27
|
+
}
|
|
28
|
+
async function boundedProbe(probe, timeoutMs) {
|
|
29
|
+
try {
|
|
30
|
+
const ok = await probe(AbortSignal.timeout(timeoutMs));
|
|
31
|
+
return ok ? "ok" : "failed";
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "failed";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export async function runExternalAgentPreflight(contract, ports, now = () => new Date()) {
|
|
38
|
+
if (validateExternalAgentContract(contract).length > 0) {
|
|
39
|
+
return { ready: false, codes: ["contract_invalid"], checkedAt: now().toISOString() };
|
|
40
|
+
}
|
|
41
|
+
const codes = [];
|
|
42
|
+
const health = await boundedProbe(ports.health, contract.runtime.healthTimeoutMs);
|
|
43
|
+
if (health !== "ok")
|
|
44
|
+
codes.push("runtime_unhealthy");
|
|
45
|
+
const readiness = await boundedProbe(ports.readiness, contract.runtime.healthTimeoutMs);
|
|
46
|
+
if (readiness !== "ok")
|
|
47
|
+
codes.push("runtime_not_ready");
|
|
48
|
+
const runtime = await boundedProbe(ports.runtimeTurn, contract.runtime.responseTimeoutMs);
|
|
49
|
+
if (runtime === "timeout")
|
|
50
|
+
codes.push("runtime_response_too_slow");
|
|
51
|
+
else if (runtime !== "ok")
|
|
52
|
+
codes.push("runtime_response_failed");
|
|
53
|
+
if (await boundedProbe(ports.media, contract.runtime.healthTimeoutMs) !== "ok")
|
|
54
|
+
codes.push("media_unavailable");
|
|
55
|
+
if (await boundedProbe(ports.speech, contract.runtime.healthTimeoutMs) !== "ok")
|
|
56
|
+
codes.push("speech_unavailable");
|
|
57
|
+
if (await boundedProbe(ports.controlPlane, contract.runtime.healthTimeoutMs) !== "ok")
|
|
58
|
+
codes.push("control_plane_unavailable");
|
|
59
|
+
if (await boundedProbe(ports.capabilityPublisher, contract.runtime.healthTimeoutMs) !== "ok")
|
|
60
|
+
codes.push("capability_publish_unavailable");
|
|
61
|
+
return { ready: codes.length === 0, codes, checkedAt: now().toISOString() };
|
|
62
|
+
}
|
|
63
|
+
export function evaluateExternalAgentInstall(current, desired) {
|
|
64
|
+
if (current.imageRevision !== desired.imageRevision)
|
|
65
|
+
return { action: "replace_release", reasons: ["image_revision_changed"] };
|
|
66
|
+
const reasons = [];
|
|
67
|
+
if (current.configRevision !== desired.configRevision)
|
|
68
|
+
reasons.push("config_revision_changed");
|
|
69
|
+
if (current.secretFileMode !== "0600")
|
|
70
|
+
reasons.push("secret_permissions_invalid");
|
|
71
|
+
return reasons.length > 0 ? { action: "reconfigure", reasons } : { action: "noop", reasons: [] };
|
|
72
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface VoicePluginConfig {
|
|
2
|
+
agentId: string;
|
|
3
|
+
agentName: string;
|
|
4
|
+
environment: "dev" | "qa" | "production";
|
|
5
|
+
secretFile: string;
|
|
6
|
+
runtimeEndpoint?: string;
|
|
7
|
+
runtimeProtocol?: "responses" | "chat_completions";
|
|
8
|
+
runtimeModel?: string;
|
|
9
|
+
sessionControlEndpoint: string;
|
|
10
|
+
grantEndpoint: string;
|
|
11
|
+
capabilityPublishEndpoint: string;
|
|
12
|
+
port?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function voiceGatewayEnv(config: VoicePluginConfig): NodeJS.ProcessEnv;
|
|
15
|
+
export default function register(api: any): void;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export function voiceGatewayEnv(config) {
|
|
5
|
+
return {
|
|
6
|
+
VOICE_AGENT_ID: config.agentId,
|
|
7
|
+
VOICE_CANONICAL_AGENT_NAME: config.agentName,
|
|
8
|
+
VOICE_PORT: String(config.port ?? 18081),
|
|
9
|
+
VOICE_TRANSCRIPT_POLICY: "off",
|
|
10
|
+
VOICE_SECRET_FILE: config.secretFile,
|
|
11
|
+
VOICE_SESSION_CONTROL_ENDPOINT: config.sessionControlEndpoint,
|
|
12
|
+
VOICE_GRANT_ENDPOINT: config.grantEndpoint,
|
|
13
|
+
VOICE_CAPABILITY_PUBLISH_ENDPOINT: config.capabilityPublishEndpoint,
|
|
14
|
+
PERKOS_VOICE_RUNTIME: "openclaw",
|
|
15
|
+
PERKOS_VOICE_RUNTIME_PROTOCOL: config.runtimeProtocol ?? "responses",
|
|
16
|
+
PERKOS_VOICE_RUNTIME_ENDPOINT: config.runtimeEndpoint ?? "http://127.0.0.1:18789/v1/responses",
|
|
17
|
+
...(config.runtimeModel ? { PERKOS_VOICE_RUNTIME_MODEL: config.runtimeModel } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export default function register(api) {
|
|
21
|
+
const config = api.config?.plugins?.entries?.["perkos-voice"]?.config;
|
|
22
|
+
const logger = api.logger ?? console;
|
|
23
|
+
let child;
|
|
24
|
+
api.registerService({
|
|
25
|
+
id: "perkos-voice",
|
|
26
|
+
start: async () => {
|
|
27
|
+
if (!config)
|
|
28
|
+
throw new Error("PerkOS Voice plugin config is missing");
|
|
29
|
+
const cli = resolve(dirname(fileURLToPath(import.meta.url)), "cli.js");
|
|
30
|
+
child = spawn(process.execPath, [cli], {
|
|
31
|
+
env: { ...process.env, ...voiceGatewayEnv(config) },
|
|
32
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
33
|
+
});
|
|
34
|
+
child.once("exit", (code, signal) => logger.error(`[perkos-voice] gateway exited code=${code ?? "none"} signal=${signal ?? "none"}`));
|
|
35
|
+
logger.info(`[perkos-voice] gateway started for ${config.agentName} (${config.environment})`);
|
|
36
|
+
},
|
|
37
|
+
stop: async () => {
|
|
38
|
+
if (!child || child.exitCode !== null)
|
|
39
|
+
return;
|
|
40
|
+
child.kill("SIGTERM");
|
|
41
|
+
await new Promise((resolveStop) => {
|
|
42
|
+
const timeout = setTimeout(() => { child?.kill("SIGKILL"); resolveStop(); }, 5_000);
|
|
43
|
+
child?.once("exit", () => { clearTimeout(timeout); resolveStop(); });
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
package/dist/ports.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TranscriptPolicy, VoiceGatewayGrant, VoiceMode } from "./types.js";
|
|
2
|
+
export interface MediaTurnInput {
|
|
3
|
+
turnId: string;
|
|
4
|
+
audio: AsyncIterable<Uint8Array>;
|
|
5
|
+
}
|
|
6
|
+
export interface MediaRoom {
|
|
7
|
+
setBargeInHandler?(handler: (turnId: string) => Promise<void>): void;
|
|
8
|
+
setInputSuppressed?(suppressed: boolean): void;
|
|
9
|
+
join(grant: VoiceGatewayGrant): Promise<void>;
|
|
10
|
+
receiveTurn(): Promise<MediaTurnInput>;
|
|
11
|
+
publish(turnId: string, audio: AsyncIterable<Uint8Array>): Promise<"completed" | "cancelled" | "stream_stalled">;
|
|
12
|
+
cancelPublish(turnId: string): Promise<void>;
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface SpeechPipeline {
|
|
16
|
+
transcribe(turnId: string, audio: AsyncIterable<Uint8Array>, policy: TranscriptPolicy): Promise<string>;
|
|
17
|
+
synthesize(turnId: string, text: string, mode: VoiceMode, speechVoice?: string): AsyncIterable<Uint8Array>;
|
|
18
|
+
cancel(turnId: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface AgentRuntime {
|
|
21
|
+
respond(turnId: string, input: string, signal: AbortSignal): Promise<string>;
|
|
22
|
+
cancel(turnId: string): Promise<void>;
|
|
23
|
+
}
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** First audible presence while the runtime is still thinking. 0 disables. */
|
|
2
|
+
export declare const VOICE_PRESENCE_MS = 1500;
|
|
3
|
+
/** Soft two-tick PCM loop (48 kHz mono int16). Local — no TTS, interruptible. */
|
|
4
|
+
export declare function presenceToneFrames(signal: AbortSignal, sampleRate?: number): AsyncIterable<Uint8Array>;
|
|
5
|
+
export declare function presencePublishId(turnId: string): string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** First audible presence while the runtime is still thinking. 0 disables. */
|
|
2
|
+
export const VOICE_PRESENCE_MS = 1_500;
|
|
3
|
+
const SAMPLE_RATE = 48_000;
|
|
4
|
+
const FRAME_MS = 20;
|
|
5
|
+
const FRAME_SAMPLES = Math.round(SAMPLE_RATE * FRAME_MS / 1_000);
|
|
6
|
+
const CYCLE_MS = 1_200;
|
|
7
|
+
const AMPLITUDE = 2_400;
|
|
8
|
+
function whenAborted(signal) {
|
|
9
|
+
if (signal.aborted)
|
|
10
|
+
return Promise.resolve();
|
|
11
|
+
return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
12
|
+
}
|
|
13
|
+
function sampleAt(sampleIndex) {
|
|
14
|
+
const cycleSamples = Math.round(SAMPLE_RATE * CYCLE_MS / 1_000);
|
|
15
|
+
const pos = sampleIndex % cycleSamples;
|
|
16
|
+
const t = pos / SAMPLE_RATE;
|
|
17
|
+
const ms = t * 1_000;
|
|
18
|
+
if (ms < 70)
|
|
19
|
+
return Math.sin(2 * Math.PI * 523.25 * t) * AMPLITUDE;
|
|
20
|
+
if (ms >= 250 && ms < 320)
|
|
21
|
+
return Math.sin(2 * Math.PI * 659.25 * t) * AMPLITUDE;
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
/** Soft two-tick PCM loop (48 kHz mono int16). Local — no TTS, interruptible. */
|
|
25
|
+
export async function* presenceToneFrames(signal, sampleRate = SAMPLE_RATE) {
|
|
26
|
+
const frameSamples = sampleRate === SAMPLE_RATE ? FRAME_SAMPLES : Math.round(sampleRate * FRAME_MS / 1_000);
|
|
27
|
+
let sampleIndex = 0;
|
|
28
|
+
while (!signal.aborted) {
|
|
29
|
+
const frame = new Int16Array(frameSamples);
|
|
30
|
+
for (let index = 0; index < frameSamples; index += 1) {
|
|
31
|
+
frame[index] = sampleAt(sampleIndex + index);
|
|
32
|
+
}
|
|
33
|
+
sampleIndex += frameSamples;
|
|
34
|
+
yield new Uint8Array(frame.buffer, frame.byteOffset, frame.byteLength);
|
|
35
|
+
await Promise.race([
|
|
36
|
+
new Promise((resolve) => setTimeout(resolve, FRAME_MS)),
|
|
37
|
+
whenAborted(signal),
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function presencePublishId(turnId) {
|
|
42
|
+
return `${turnId}:presence`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { GatewayConfig } from "./config.js";
|
|
2
|
+
import type { VoiceCapabilityHandshake } from "./types.js";
|
|
3
|
+
export interface Probeable {
|
|
4
|
+
probe(): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export interface ReadinessSnapshot {
|
|
7
|
+
ready: boolean;
|
|
8
|
+
reasons: string[];
|
|
9
|
+
checkedAt?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class GatewayReadiness {
|
|
12
|
+
#private;
|
|
13
|
+
private readonly config;
|
|
14
|
+
private readonly speech?;
|
|
15
|
+
private readonly fetcher;
|
|
16
|
+
constructor(config: GatewayConfig, speech?: Probeable | undefined, fetcher?: typeof fetch);
|
|
17
|
+
snapshot(): ReadinessSnapshot;
|
|
18
|
+
check(): Promise<ReadinessSnapshot>;
|
|
19
|
+
capability(now?: Date): VoiceCapabilityHandshake;
|
|
20
|
+
}
|
|
21
|
+
export declare class CapabilityPublisher {
|
|
22
|
+
private readonly endpoint;
|
|
23
|
+
private readonly credential;
|
|
24
|
+
private readonly fetcher;
|
|
25
|
+
constructor(endpoint: string, credential: string, fetcher?: typeof fetch);
|
|
26
|
+
publish(handshake: VoiceCapabilityHandshake): Promise<void>;
|
|
27
|
+
}
|