@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { validateByoConfig } from "./config.js";
|
|
2
|
+
import { publicCapability } from "./service.js";
|
|
3
|
+
export class GatewayReadiness {
|
|
4
|
+
config;
|
|
5
|
+
speech;
|
|
6
|
+
fetcher;
|
|
7
|
+
#snapshot = { ready: false, reasons: ["checks_pending"] };
|
|
8
|
+
constructor(config, speech, fetcher = fetch) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
this.speech = speech;
|
|
11
|
+
this.fetcher = fetcher;
|
|
12
|
+
}
|
|
13
|
+
snapshot() { return { ...this.#snapshot, reasons: [...this.#snapshot.reasons] }; }
|
|
14
|
+
async check() {
|
|
15
|
+
const validation = validateByoConfig(this.config);
|
|
16
|
+
const reasons = [...validation.reasons];
|
|
17
|
+
if (validation.valid) {
|
|
18
|
+
try {
|
|
19
|
+
await this.speech?.probe();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
reasons.push("speech_probe_failed");
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const response = await this.fetcher(`${this.config.sessionControlEndpoint.replace(/\/$/, "")}/readiness`, {
|
|
26
|
+
headers: { "x-perkos-voice-credential": this.config.grantCredential },
|
|
27
|
+
signal: AbortSignal.timeout(10_000),
|
|
28
|
+
});
|
|
29
|
+
const result = response.ok ? await response.json() : null;
|
|
30
|
+
if (result?.readiness?.ready !== true)
|
|
31
|
+
reasons.push("control_plane_probe_failed");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
reasons.push("control_plane_probe_failed");
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const runtimeEndpoint = this.config.runtimeEndpoint ?? this.config.openClawEndpoint;
|
|
38
|
+
const runtimeToken = this.config.runtimeToken ?? this.config.openClawToken;
|
|
39
|
+
const endpoint = new URL(runtimeEndpoint);
|
|
40
|
+
const probeUrl = this.config.runtimeProtocol === "zeroclaw_webhook" ? `${endpoint.origin}/health` : `${endpoint.origin}/v1/models`;
|
|
41
|
+
const response = await this.fetcher(probeUrl, { headers: { authorization: `Bearer ${runtimeToken}` }, signal: AbortSignal.timeout(10_000) });
|
|
42
|
+
if (!response.ok)
|
|
43
|
+
reasons.push("openclaw_probe_failed");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
reasons.push("openclaw_probe_failed");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
this.#snapshot = { ready: reasons.length === 0, reasons, checkedAt: new Date().toISOString() };
|
|
50
|
+
return this.snapshot();
|
|
51
|
+
}
|
|
52
|
+
capability(now = new Date()) { return publicCapability(this.config, now, this.#snapshot.ready); }
|
|
53
|
+
}
|
|
54
|
+
export class CapabilityPublisher {
|
|
55
|
+
endpoint;
|
|
56
|
+
credential;
|
|
57
|
+
fetcher;
|
|
58
|
+
constructor(endpoint, credential, fetcher = fetch) {
|
|
59
|
+
this.endpoint = endpoint;
|
|
60
|
+
this.credential = credential;
|
|
61
|
+
this.fetcher = fetcher;
|
|
62
|
+
}
|
|
63
|
+
async publish(handshake) {
|
|
64
|
+
const response = await this.fetcher(this.endpoint, { method: "POST", headers: { "x-perkos-voice-credential": this.credential, "content-type": "application/json" }, body: JSON.stringify(handshake), signal: AbortSignal.timeout(10_000) });
|
|
65
|
+
if (!response.ok)
|
|
66
|
+
throw new Error(`capability publication failed (${response.status})`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import type { GatewayConfig } from "./config.js";
|
|
3
|
+
import type { VoiceCapabilityHandshake } from "./types.js";
|
|
4
|
+
export declare function publicCapability(config: GatewayConfig, now?: Date, verifiedReady?: boolean): VoiceCapabilityHandshake;
|
|
5
|
+
export declare function startHealthServer(config: GatewayConfig, readiness?: {
|
|
6
|
+
snapshot(): {
|
|
7
|
+
ready: boolean;
|
|
8
|
+
reasons: string[];
|
|
9
|
+
};
|
|
10
|
+
}): Server;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { validateByoConfig } from "./config.js";
|
|
3
|
+
export function publicCapability(config, now = new Date(), verifiedReady = false) {
|
|
4
|
+
const ready = verifiedReady && validateByoConfig(config).valid;
|
|
5
|
+
const capabilityAgentId = config.canonicalAgentName || (config.capabilityPublishEndpoint ? "unconfigured" : config.agentId || "unconfigured");
|
|
6
|
+
return { protocolVersion: "1", capability: {
|
|
7
|
+
agentId: capabilityAgentId,
|
|
8
|
+
availability: ready ? "available" : "unavailable",
|
|
9
|
+
supportedModes: ready ? ["turn_based"] : [],
|
|
10
|
+
ownership: "external_owner",
|
|
11
|
+
supportsInterrupt: false,
|
|
12
|
+
supportsEphemeralTranscript: ready,
|
|
13
|
+
supportsSavedTranscript: false,
|
|
14
|
+
checkedAt: now.toISOString(),
|
|
15
|
+
expiresAt: new Date(now.getTime() + 60_000).toISOString(),
|
|
16
|
+
} };
|
|
17
|
+
}
|
|
18
|
+
export function startHealthServer(config, readiness) {
|
|
19
|
+
const server = createServer((request, response) => {
|
|
20
|
+
response.setHeader("content-type", "application/json");
|
|
21
|
+
if (request.url === "/health") {
|
|
22
|
+
response.statusCode = 200;
|
|
23
|
+
response.end(JSON.stringify({ status: "alive" }));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (request.url === "/ready") {
|
|
27
|
+
const validation = readiness?.snapshot() ?? { ready: false, reasons: ["checks_pending"] };
|
|
28
|
+
response.statusCode = validation.ready ? 200 : 503;
|
|
29
|
+
response.end(JSON.stringify({ status: validation.ready ? "ready" : "unavailable", reasons: validation.reasons }));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (request.url === "/capabilities") {
|
|
33
|
+
response.statusCode = 200;
|
|
34
|
+
response.end(JSON.stringify(publicCapability(config, new Date(), readiness?.snapshot().ready === true)));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
response.statusCode = 404;
|
|
38
|
+
response.end(JSON.stringify({ error: "not_found" }));
|
|
39
|
+
});
|
|
40
|
+
server.listen(config.port, "0.0.0.0");
|
|
41
|
+
return server;
|
|
42
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface DynamicVoiceSession {
|
|
2
|
+
id: string;
|
|
3
|
+
projectId: string;
|
|
4
|
+
meetingId: string;
|
|
5
|
+
agentId: string;
|
|
6
|
+
enrollmentAgentId: string;
|
|
7
|
+
audience: "perkos-voice-session-v1";
|
|
8
|
+
voiceProcessingConsent: true;
|
|
9
|
+
status: "claimed";
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
speechVoice: string;
|
|
12
|
+
chatCommit: {
|
|
13
|
+
policy: "none";
|
|
14
|
+
} | {
|
|
15
|
+
policy: "final_pair";
|
|
16
|
+
consent: true;
|
|
17
|
+
scope: {
|
|
18
|
+
kind: "direct" | "project";
|
|
19
|
+
conversationId: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/** Working Call only: bounded rolling brief from bound chat (API claim). */
|
|
23
|
+
workChatBrief?: string;
|
|
24
|
+
}
|
|
25
|
+
export type SessionTerminalStatus = "joined" | "completed" | "failed" | "cancelled";
|
|
26
|
+
export declare class ChatCommitError extends Error {
|
|
27
|
+
readonly category: "terminal_conflict" | "retry_exhausted";
|
|
28
|
+
readonly code = "CHAT_COMMIT_FAILED";
|
|
29
|
+
constructor(category: "terminal_conflict" | "retry_exhausted");
|
|
30
|
+
}
|
|
31
|
+
export declare function createChatCommitSink(session: DynamicVoiceSession, client: SessionControlClient): ((turn: {
|
|
32
|
+
turnId: string;
|
|
33
|
+
transcript: string;
|
|
34
|
+
response: string;
|
|
35
|
+
outcome: "completed" | "chat_fallback";
|
|
36
|
+
}) => Promise<void>) | undefined;
|
|
37
|
+
export declare class SessionControlClient {
|
|
38
|
+
#private;
|
|
39
|
+
private readonly credential;
|
|
40
|
+
private readonly fetcher;
|
|
41
|
+
constructor(endpoint: string, credential: string, fetcher?: typeof fetch);
|
|
42
|
+
claim(): Promise<DynamicVoiceSession | null>;
|
|
43
|
+
state(sessionId: string): Promise<{
|
|
44
|
+
status: string;
|
|
45
|
+
expiresAt: string;
|
|
46
|
+
}>;
|
|
47
|
+
heartbeat(sessionId: string): Promise<{
|
|
48
|
+
expiresAt: string;
|
|
49
|
+
}>;
|
|
50
|
+
update(sessionId: string, status: SessionTerminalStatus, reason?: string, extras?: {
|
|
51
|
+
healthCodes?: string[];
|
|
52
|
+
stage?: string;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
commitChatTurn(sessionId: string, turnId: string, userText: string, assistantText: string, outcome?: "completed" | "chat_fallback"): Promise<void>;
|
|
55
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export class ChatCommitError extends Error {
|
|
2
|
+
category;
|
|
3
|
+
code = "CHAT_COMMIT_FAILED";
|
|
4
|
+
constructor(category) {
|
|
5
|
+
super("Chat commit failed");
|
|
6
|
+
this.category = category;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function createChatCommitSink(session, client) {
|
|
10
|
+
if (session.chatCommit.policy !== "final_pair")
|
|
11
|
+
return undefined;
|
|
12
|
+
return (turn) => client.commitChatTurn(session.id, turn.turnId, turn.transcript, turn.response, turn.outcome);
|
|
13
|
+
}
|
|
14
|
+
export class SessionControlClient {
|
|
15
|
+
credential;
|
|
16
|
+
fetcher;
|
|
17
|
+
#endpoint;
|
|
18
|
+
constructor(endpoint, credential, fetcher = fetch) {
|
|
19
|
+
this.credential = credential;
|
|
20
|
+
this.fetcher = fetcher;
|
|
21
|
+
this.#endpoint = endpoint.replace(/\/$/, "");
|
|
22
|
+
}
|
|
23
|
+
async claim() {
|
|
24
|
+
const response = await this.fetcher(`${this.#endpoint}/sessions/claim`, { method: "POST", headers: this.#headers(), signal: AbortSignal.timeout(10_000) });
|
|
25
|
+
if (!response.ok)
|
|
26
|
+
throw new Error(`session claim failed (${response.status})`);
|
|
27
|
+
const session = (await response.json()).session;
|
|
28
|
+
if (session === null || session === undefined)
|
|
29
|
+
return null;
|
|
30
|
+
return this.#parse(session);
|
|
31
|
+
}
|
|
32
|
+
async state(sessionId) {
|
|
33
|
+
const response = await this.fetcher(`${this.#endpoint}/sessions/${encodeURIComponent(sessionId)}`, { headers: this.#headers(), signal: AbortSignal.timeout(10_000) });
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
throw new Error(`session state failed (${response.status})`);
|
|
36
|
+
const session = (await response.json()).session;
|
|
37
|
+
if (typeof session?.status !== "string" || typeof session.expiresAt !== "string")
|
|
38
|
+
throw new Error("invalid session state");
|
|
39
|
+
return { status: session.status, expiresAt: session.expiresAt };
|
|
40
|
+
}
|
|
41
|
+
async heartbeat(sessionId) {
|
|
42
|
+
const response = await this.fetcher(`${this.#endpoint}/sessions/${encodeURIComponent(sessionId)}/heartbeat`, { method: "POST", headers: this.#headers(), signal: AbortSignal.timeout(10_000) });
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new Error(`session heartbeat failed (${response.status})`);
|
|
45
|
+
const expiresAt = (await response.json()).expiresAt;
|
|
46
|
+
if (typeof expiresAt !== "string" || Date.parse(expiresAt) <= Date.now())
|
|
47
|
+
throw new Error("invalid renewed session lease");
|
|
48
|
+
return { expiresAt };
|
|
49
|
+
}
|
|
50
|
+
async update(sessionId, status, reason, extras) {
|
|
51
|
+
const response = await this.fetcher(`${this.#endpoint}/sessions/${encodeURIComponent(sessionId)}/status`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { ...this.#headers(), "content-type": "application/json" },
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
status,
|
|
56
|
+
...(reason ? { reason } : {}),
|
|
57
|
+
...(extras?.healthCodes?.length ? { healthCodes: extras.healthCodes } : {}),
|
|
58
|
+
...(extras?.stage ? { stage: extras.stage } : {}),
|
|
59
|
+
}),
|
|
60
|
+
signal: AbortSignal.timeout(10_000),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok)
|
|
63
|
+
throw new Error(`session status failed (${response.status})`);
|
|
64
|
+
}
|
|
65
|
+
async commitChatTurn(sessionId, turnId, userText, assistantText, outcome = "completed") {
|
|
66
|
+
const url = `${this.#endpoint}/sessions/${encodeURIComponent(sessionId)}/turns/${encodeURIComponent(turnId)}/chat-commit`;
|
|
67
|
+
const body = JSON.stringify({ outcome, userText, assistantText });
|
|
68
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
69
|
+
try {
|
|
70
|
+
const response = await this.fetcher(url, { method: "POST", headers: { ...this.#headers(), "content-type": "application/json" }, body, signal: AbortSignal.timeout(10_000) });
|
|
71
|
+
if (response.status === 200 || response.status === 201)
|
|
72
|
+
return;
|
|
73
|
+
if (response.status === 409)
|
|
74
|
+
throw new ChatCommitError("terminal_conflict");
|
|
75
|
+
if (response.status !== 502)
|
|
76
|
+
throw new ChatCommitError("retry_exhausted");
|
|
77
|
+
if (attempt === 2)
|
|
78
|
+
throw new ChatCommitError("retry_exhausted");
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof ChatCommitError)
|
|
82
|
+
throw error;
|
|
83
|
+
if (attempt === 2)
|
|
84
|
+
throw new ChatCommitError("retry_exhausted");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
#headers() { return { "x-perkos-voice-credential": this.credential }; }
|
|
89
|
+
#parse(input) {
|
|
90
|
+
const value = input;
|
|
91
|
+
for (const key of ["id", "projectId", "meetingId", "agentId", "enrollmentAgentId", "expiresAt"])
|
|
92
|
+
if (typeof value?.[key] !== "string" || !value[key])
|
|
93
|
+
throw new Error(`session ${key} missing`);
|
|
94
|
+
if (value.audience !== "perkos-voice-session-v1" || value.voiceProcessingConsent !== true || value.status !== "claimed" || Date.parse(String(value.expiresAt)) <= Date.now())
|
|
95
|
+
throw new Error("invalid or expired claimed session");
|
|
96
|
+
const voices = new Set(["alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse", "marin", "cedar"]);
|
|
97
|
+
if (typeof value.speechVoice !== "string" || !voices.has(value.speechVoice))
|
|
98
|
+
throw new Error("invalid session speech voice");
|
|
99
|
+
const chatCommit = value.chatCommit;
|
|
100
|
+
if (chatCommit?.policy === "final_pair") {
|
|
101
|
+
const scope = chatCommit.scope;
|
|
102
|
+
if (chatCommit.consent !== true || !scope || !["direct", "project"].includes(String(scope.kind)) || typeof scope.conversationId !== "string" || !scope.conversationId)
|
|
103
|
+
throw new Error("invalid chat commit policy");
|
|
104
|
+
}
|
|
105
|
+
else if (chatCommit?.policy !== "none")
|
|
106
|
+
throw new Error("chat commit policy missing");
|
|
107
|
+
// workChatBrief only valid for Working Call; ignore otherwise.
|
|
108
|
+
if (chatCommit?.policy === "final_pair" && typeof value.workChatBrief === "string" && value.workChatBrief.trim()) {
|
|
109
|
+
const brief = value.workChatBrief.trim().slice(0, 2400);
|
|
110
|
+
if (brief && !/system prompt|tool_call|```/i.test(brief)) {
|
|
111
|
+
return { ...value, workChatBrief: brief };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const session = { ...value };
|
|
115
|
+
delete session.workChatBrief;
|
|
116
|
+
return session;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type TranscriptionFailureCategory = "http_4xx" | "http_5xx" | "timeout_abort" | "network" | "other";
|
|
2
|
+
export type TtsFailureCategory = "http_4xx" | "http_5xx" | "timeout_abort" | "network" | "malformed" | "stream_stalled" | "other";
|
|
3
|
+
export declare class NoSpeechError extends Error {
|
|
4
|
+
readonly code = "NO_SPEECH";
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
export declare class TranscriptionError extends Error {
|
|
8
|
+
readonly category: TranscriptionFailureCategory;
|
|
9
|
+
readonly code = "TRANSCRIPTION_FAILED";
|
|
10
|
+
constructor(category: TranscriptionFailureCategory);
|
|
11
|
+
}
|
|
12
|
+
export declare class TtsError extends Error {
|
|
13
|
+
readonly category: TtsFailureCategory;
|
|
14
|
+
readonly code = "TTS_FAILED";
|
|
15
|
+
constructor(category: TtsFailureCategory);
|
|
16
|
+
}
|
|
17
|
+
export declare function transcriptionFailureLog(error: unknown): string | undefined;
|
|
18
|
+
export declare function ttsFailureLog(error: unknown): string | undefined;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export class NoSpeechError extends Error {
|
|
2
|
+
code = "NO_SPEECH";
|
|
3
|
+
constructor() { super("No speech detected"); }
|
|
4
|
+
}
|
|
5
|
+
export class TranscriptionError extends Error {
|
|
6
|
+
category;
|
|
7
|
+
code = "TRANSCRIPTION_FAILED";
|
|
8
|
+
constructor(category) {
|
|
9
|
+
super("Transcription failed");
|
|
10
|
+
this.category = category;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class TtsError extends Error {
|
|
14
|
+
category;
|
|
15
|
+
code = "TTS_FAILED";
|
|
16
|
+
constructor(category) {
|
|
17
|
+
super("Speech synthesis failed");
|
|
18
|
+
this.category = category;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function transcriptionFailureLog(error) {
|
|
22
|
+
return error instanceof TranscriptionError ? `voice transcription failed: ${error.category}` : undefined;
|
|
23
|
+
}
|
|
24
|
+
export function ttsFailureLog(error) {
|
|
25
|
+
return error instanceof TtsError ? `voice tts failed: ${error.category}` : undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { VoiceSessionState } from "./types.js";
|
|
2
|
+
export type VoiceSessionEvent = {
|
|
3
|
+
type: "authorization_requested";
|
|
4
|
+
} | {
|
|
5
|
+
type: "authorized";
|
|
6
|
+
} | {
|
|
7
|
+
type: "join_started";
|
|
8
|
+
} | {
|
|
9
|
+
type: "joined";
|
|
10
|
+
} | {
|
|
11
|
+
type: "listening";
|
|
12
|
+
} | {
|
|
13
|
+
type: "turn_committed";
|
|
14
|
+
turnId: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: "turn_skipped";
|
|
17
|
+
turnId: string;
|
|
18
|
+
} | {
|
|
19
|
+
type: "response_started";
|
|
20
|
+
turnId: string;
|
|
21
|
+
} | {
|
|
22
|
+
type: "response_completed";
|
|
23
|
+
turnId: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "response_aborted";
|
|
26
|
+
turnId: string;
|
|
27
|
+
} | {
|
|
28
|
+
type: "barge_in";
|
|
29
|
+
turnId: string;
|
|
30
|
+
} | {
|
|
31
|
+
type: "close_requested";
|
|
32
|
+
} | {
|
|
33
|
+
type: "closed";
|
|
34
|
+
} | {
|
|
35
|
+
type: "failed";
|
|
36
|
+
reason: string;
|
|
37
|
+
};
|
|
38
|
+
export declare class InvalidVoiceTransitionError extends Error {
|
|
39
|
+
readonly code = "INVALID_VOICE_TRANSITION";
|
|
40
|
+
constructor(state: VoiceSessionState, event: VoiceSessionEvent["type"]);
|
|
41
|
+
}
|
|
42
|
+
export declare class VoiceTurnCorrelationError extends Error {
|
|
43
|
+
readonly code = "VOICE_TURN_CORRELATION_ERROR";
|
|
44
|
+
}
|
|
45
|
+
export interface VoiceSessionSnapshot {
|
|
46
|
+
state: VoiceSessionState;
|
|
47
|
+
activeTurnId?: string;
|
|
48
|
+
cancelledTurnIds: readonly string[];
|
|
49
|
+
failureReason?: string;
|
|
50
|
+
}
|
|
51
|
+
export declare class VoiceSessionStateMachine {
|
|
52
|
+
#private;
|
|
53
|
+
get snapshot(): VoiceSessionSnapshot;
|
|
54
|
+
transition(event: VoiceSessionEvent): VoiceSessionSnapshot;
|
|
55
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export class InvalidVoiceTransitionError extends Error {
|
|
2
|
+
code = "INVALID_VOICE_TRANSITION";
|
|
3
|
+
constructor(state, event) {
|
|
4
|
+
super(`Cannot apply ${event} while voice session is ${state}`);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class VoiceTurnCorrelationError extends Error {
|
|
8
|
+
code = "VOICE_TURN_CORRELATION_ERROR";
|
|
9
|
+
}
|
|
10
|
+
export class VoiceSessionStateMachine {
|
|
11
|
+
#state = "preparing";
|
|
12
|
+
#activeTurnId;
|
|
13
|
+
#cancelledTurnIds = [];
|
|
14
|
+
#failureReason;
|
|
15
|
+
get snapshot() {
|
|
16
|
+
return {
|
|
17
|
+
state: this.#state,
|
|
18
|
+
...(this.#activeTurnId ? { activeTurnId: this.#activeTurnId } : {}),
|
|
19
|
+
cancelledTurnIds: [...this.#cancelledTurnIds],
|
|
20
|
+
...(this.#failureReason ? { failureReason: this.#failureReason } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
transition(event) {
|
|
24
|
+
if (event.type === "failed") {
|
|
25
|
+
if (this.#state === "closed" || this.#state === "failed")
|
|
26
|
+
throw new InvalidVoiceTransitionError(this.#state, event.type);
|
|
27
|
+
this.#state = "failed";
|
|
28
|
+
this.#failureReason = event.reason;
|
|
29
|
+
this.#activeTurnId = undefined;
|
|
30
|
+
return this.snapshot;
|
|
31
|
+
}
|
|
32
|
+
if (event.type === "close_requested") {
|
|
33
|
+
if (["preparing", "closed", "failed", "closing"].includes(this.#state))
|
|
34
|
+
throw new InvalidVoiceTransitionError(this.#state, event.type);
|
|
35
|
+
this.#state = "closing";
|
|
36
|
+
this.#activeTurnId = undefined;
|
|
37
|
+
return this.snapshot;
|
|
38
|
+
}
|
|
39
|
+
if (event.type === "closed") {
|
|
40
|
+
if (this.#state !== "closing")
|
|
41
|
+
throw new InvalidVoiceTransitionError(this.#state, event.type);
|
|
42
|
+
this.#state = "closed";
|
|
43
|
+
return this.snapshot;
|
|
44
|
+
}
|
|
45
|
+
const simple = {
|
|
46
|
+
authorization_requested: ["preparing", "awaiting_authorization"],
|
|
47
|
+
authorized: ["awaiting_authorization", "authorized"],
|
|
48
|
+
join_started: ["authorized", "joining"],
|
|
49
|
+
joined: ["joining", "connected"],
|
|
50
|
+
listening: ["connected", "listening"],
|
|
51
|
+
};
|
|
52
|
+
const pair = simple[event.type];
|
|
53
|
+
if (pair) {
|
|
54
|
+
if (this.#state !== pair[0])
|
|
55
|
+
throw new InvalidVoiceTransitionError(this.#state, event.type);
|
|
56
|
+
this.#state = pair[1];
|
|
57
|
+
return this.snapshot;
|
|
58
|
+
}
|
|
59
|
+
if (event.type === "turn_committed") {
|
|
60
|
+
if (this.#state !== "listening" || this.#activeTurnId)
|
|
61
|
+
throw new InvalidVoiceTransitionError(this.#state, event.type);
|
|
62
|
+
if (!event.turnId)
|
|
63
|
+
throw new VoiceTurnCorrelationError("Turn id is required");
|
|
64
|
+
this.#activeTurnId = event.turnId;
|
|
65
|
+
this.#state = "thinking";
|
|
66
|
+
return this.snapshot;
|
|
67
|
+
}
|
|
68
|
+
if (event.type === "response_started") {
|
|
69
|
+
this.#assertTurn("thinking", event.turnId, event.type);
|
|
70
|
+
this.#state = "speaking";
|
|
71
|
+
return this.snapshot;
|
|
72
|
+
}
|
|
73
|
+
if (event.type === "turn_skipped") {
|
|
74
|
+
this.#assertTurn("thinking", event.turnId, event.type);
|
|
75
|
+
this.#activeTurnId = undefined;
|
|
76
|
+
this.#state = "listening";
|
|
77
|
+
return this.snapshot;
|
|
78
|
+
}
|
|
79
|
+
if (event.type === "response_completed") {
|
|
80
|
+
this.#assertTurn("speaking", event.turnId, event.type);
|
|
81
|
+
this.#activeTurnId = undefined;
|
|
82
|
+
this.#state = "listening";
|
|
83
|
+
return this.snapshot;
|
|
84
|
+
}
|
|
85
|
+
if (event.type === "response_aborted") {
|
|
86
|
+
this.#assertTurn("speaking", event.turnId, event.type);
|
|
87
|
+
this.#activeTurnId = undefined;
|
|
88
|
+
this.#state = "listening";
|
|
89
|
+
return this.snapshot;
|
|
90
|
+
}
|
|
91
|
+
if (event.type === "barge_in") {
|
|
92
|
+
this.#assertTurn("speaking", event.turnId, event.type);
|
|
93
|
+
this.#cancelledTurnIds.push(event.turnId);
|
|
94
|
+
this.#activeTurnId = undefined;
|
|
95
|
+
this.#state = "listening";
|
|
96
|
+
return this.snapshot;
|
|
97
|
+
}
|
|
98
|
+
throw new InvalidVoiceTransitionError(this.#state, event.type);
|
|
99
|
+
}
|
|
100
|
+
#assertTurn(state, turnId, event) {
|
|
101
|
+
if (this.#state !== state)
|
|
102
|
+
throw new InvalidVoiceTransitionError(this.#state, event);
|
|
103
|
+
if (!turnId || turnId !== this.#activeTurnId) {
|
|
104
|
+
throw new VoiceTurnCorrelationError(`Event ${event} does not match the active turn`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type VoiceProviderOwnership = "external_owner" | "perkos_managed" | "organization_byok";
|
|
2
|
+
export type VoiceMode = "turn_based" | "realtime";
|
|
3
|
+
export type VoiceAvailability = "available" | "unavailable" | "degraded";
|
|
4
|
+
/**
|
|
5
|
+
* Public capability declaration. It deliberately excludes provider names,
|
|
6
|
+
* credentials, network addresses, and any runtime-private configuration.
|
|
7
|
+
*/
|
|
8
|
+
export interface VoiceCapability {
|
|
9
|
+
agentId: string;
|
|
10
|
+
availability: VoiceAvailability;
|
|
11
|
+
supportedModes: VoiceMode[];
|
|
12
|
+
ownership: VoiceProviderOwnership;
|
|
13
|
+
supportsInterrupt: boolean;
|
|
14
|
+
supportsEphemeralTranscript: boolean;
|
|
15
|
+
supportsSavedTranscript: boolean;
|
|
16
|
+
checkedAt: string;
|
|
17
|
+
expiresAt: string;
|
|
18
|
+
}
|
|
19
|
+
export interface VoiceCapabilityHandshake {
|
|
20
|
+
protocolVersion: "1";
|
|
21
|
+
capability?: VoiceCapability;
|
|
22
|
+
}
|
|
23
|
+
export type VoiceAvailabilityReason = "available" | "capability_absent" | "capability_invalid" | "agent_mismatch" | "expired" | "reported_unavailable" | "mode_unsupported" | "ownership_unsupported" | "interrupt_unsupported" | "transcript_policy_unsupported";
|
|
24
|
+
export interface VoiceAvailabilityDecision {
|
|
25
|
+
available: boolean;
|
|
26
|
+
reason: VoiceAvailabilityReason;
|
|
27
|
+
capability?: VoiceCapability;
|
|
28
|
+
}
|
|
29
|
+
export type TranscriptPolicy = "off" | "ephemeral" | "saved";
|
|
30
|
+
export interface VoiceSessionRequest {
|
|
31
|
+
projectId: string;
|
|
32
|
+
meetingId: string;
|
|
33
|
+
agentId: string;
|
|
34
|
+
initiatorId: string;
|
|
35
|
+
mode: VoiceMode;
|
|
36
|
+
transcriptPolicy: TranscriptPolicy;
|
|
37
|
+
/** Owner-selected provider voice, snapshotted by the control plane. */
|
|
38
|
+
speechVoice?: string;
|
|
39
|
+
/** Required only when transcriptPolicy is `saved`. */
|
|
40
|
+
transcriptConsent?: TranscriptConsent;
|
|
41
|
+
}
|
|
42
|
+
export interface TranscriptConsent {
|
|
43
|
+
granted: true;
|
|
44
|
+
subjectId: string;
|
|
45
|
+
grantedAt: string;
|
|
46
|
+
}
|
|
47
|
+
export interface VoiceGatewayGrant {
|
|
48
|
+
url?: string;
|
|
49
|
+
meetingId: string;
|
|
50
|
+
roomName: string;
|
|
51
|
+
agentIdentity: string;
|
|
52
|
+
expiresAt: string;
|
|
53
|
+
token: string;
|
|
54
|
+
}
|
|
55
|
+
export type VoiceSessionState = "preparing" | "awaiting_authorization" | "authorized" | "joining" | "waiting_for_gateway" | "connected" | "listening" | "thinking" | "speaking" | "reconnecting" | "closing" | "closed" | "ended" | "failed";
|
|
56
|
+
export interface VoiceTurn {
|
|
57
|
+
turnId: string;
|
|
58
|
+
meetingId: string;
|
|
59
|
+
agentId: string;
|
|
60
|
+
state: "started" | "cancelled" | "completed" | "failed";
|
|
61
|
+
createdAt: string;
|
|
62
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare function languageOfUtterance(text: string): "es" | "en" | "unknown";
|
|
2
|
+
/**
|
|
3
|
+
* Detach only when the caller asked for a long process (research, investigate,
|
|
4
|
+
* write a doc, implement). Normal conversation stays on the live turn even if
|
|
5
|
+
* the runtime is slow (OpenClaw/Hermes often exceed 4s on chat).
|
|
6
|
+
*/
|
|
7
|
+
export declare function looksLikeDetachedVoiceWork(text: string): boolean;
|
|
8
|
+
/** Immediate spoken ack so the call never goes silent while work continues. */
|
|
9
|
+
export declare function voiceSubtaskAck(input: string): string;
|
|
10
|
+
export declare function voiceSubtaskTimedOut(input: string): string;
|
|
11
|
+
/** First audible SLA. Runtime work may continue after this. */
|
|
12
|
+
export declare const VOICE_SPEECH_SLA_MS = 4000;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const ES_WORDS = new Set(["el", "la", "los", "las", "de", "que", "qué", "y", "en", "para", "por", "con", "una", "un", "es", "cómo", "puedes", "puedo", "gracias", "hola", "análisis", "analiza", "proyecto"]);
|
|
2
|
+
const EN_WORDS = new Set(["the", "a", "an", "of", "that", "what", "and", "in", "for", "with", "is", "how", "can", "please", "hello", "thanks", "analyze", "project"]);
|
|
3
|
+
export function languageOfUtterance(text) {
|
|
4
|
+
const words = text.toLocaleLowerCase().match(/[\p{L}]+/gu) ?? [];
|
|
5
|
+
const es = words.filter((word) => ES_WORDS.has(word)).length;
|
|
6
|
+
const en = words.filter((word) => EN_WORDS.has(word)).length;
|
|
7
|
+
if (/[¿¡ñáéíóúü]/iu.test(text) || (es >= 1 && en === 0))
|
|
8
|
+
return "es";
|
|
9
|
+
if (en >= 2 && en > es)
|
|
10
|
+
return "en";
|
|
11
|
+
return "unknown";
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Detach only when the caller asked for a long process (research, investigate,
|
|
15
|
+
* write a doc, implement). Normal conversation stays on the live turn even if
|
|
16
|
+
* the runtime is slow (OpenClaw/Hermes often exceed 4s on chat).
|
|
17
|
+
*/
|
|
18
|
+
export function looksLikeDetachedVoiceWork(text) {
|
|
19
|
+
const normalized = text.toLocaleLowerCase().replace(/\s+/g, " ").trim();
|
|
20
|
+
if (!normalized)
|
|
21
|
+
return false;
|
|
22
|
+
if (/\b(recuerdas?|recordar|hablamos|conversamos|dijimos|mencionaste|qué te parece|que te parece|expl[ií]came|puedes o[ií]rme|me escuchas|hola|buenas)\b/iu.test(normalized)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return (/\b(research|investig(a|ar|ación|acion)|indaga|indagar)\b/iu.test(normalized)
|
|
26
|
+
|| (/\b(busca|buscar|search|look up|google)\b/iu.test(normalized)
|
|
27
|
+
&& /\b(web|internet|online|docs?|documentaci[oó]n|c[oó]digo|codigo|repo|github)\b/iu.test(normalized))
|
|
28
|
+
|| (/\b(escribe|escribir|redacta|redactar|genera|generar|write|draft)\b/iu.test(normalized)
|
|
29
|
+
&& /\b(reporte|informe|documento|spec|prd|plan|archivo|file|doc)\b/iu.test(normalized))
|
|
30
|
+
|| /\b(implementa|implementar|codea|program(a|ar)|crea un pr|open a pr)\b/iu.test(normalized)
|
|
31
|
+
|| (/\b(revisa|audita|analiza)\b/iu.test(normalized)
|
|
32
|
+
&& /\b(c[oó]digo|codigo|repo|codebase|archivos?)\b/iu.test(normalized)));
|
|
33
|
+
}
|
|
34
|
+
/** Immediate spoken ack so the call never goes silent while work continues. */
|
|
35
|
+
export function voiceSubtaskAck(input) {
|
|
36
|
+
return languageOfUtterance(input) === "es"
|
|
37
|
+
? "De acuerdo, lo mandé como subtarea. Sigo aquí; te aviso cuando avance."
|
|
38
|
+
: "Got it. I sent that as a background task. I'm still here and will update you when it moves.";
|
|
39
|
+
}
|
|
40
|
+
export function voiceSubtaskTimedOut(input) {
|
|
41
|
+
return languageOfUtterance(input) === "es"
|
|
42
|
+
? "Sigo con esa subtarea en segundo plano. Puedes seguir hablándome."
|
|
43
|
+
: "That task is still running in the background. You can keep talking.";
|
|
44
|
+
}
|
|
45
|
+
/** First audible SLA. Runtime work may continue after this. */
|
|
46
|
+
export const VOICE_SPEECH_SLA_MS = 4_000;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-owned Working Call continuity rules.
|
|
3
|
+
*
|
|
4
|
+
* Ownership:
|
|
5
|
+
* - PerkOS API claim attaches a bounded `workChatBrief` for final_pair sessions.
|
|
6
|
+
* - PerkOS Voice gateway MUST inject these rules whenever a brief is present.
|
|
7
|
+
* - Agent soul / A2A / full chat agents do NOT own this path. Media voice is thin
|
|
8
|
+
* and must not depend on agent-specific policies that deny prior context.
|
|
9
|
+
*
|
|
10
|
+
* External owners implementing a custom runtime beside PerkOS Voice must apply
|
|
11
|
+
* the same rules when `session.workChatBrief` is present on claim.
|
|
12
|
+
*/
|
|
13
|
+
export declare const WORK_CALL_CONTEXT_RULES: string;
|
|
14
|
+
export declare function formatWorkCallContextBlock(brief: string): string;
|