@kybernesis/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/NOTICE +4 -0
- package/README.md +53 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +138 -0
- package/package.json +57 -0
package/NOTICE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @kybernesis/voice
|
|
2
|
+
|
|
3
|
+
Give a KYBER Studio agent its own realtime voice. The floating orb in Studio
|
|
4
|
+
speaks **as the agent** and delegates every real request back to the agent's own
|
|
5
|
+
session, so the voice can do anything the text chat can — recall from memory,
|
|
6
|
+
know its role and routines, create routines, run tools and connectors.
|
|
7
|
+
|
|
8
|
+
Voice is a **per-agent capability**. An agent that mounts this channel is
|
|
9
|
+
voice-capable; one that doesn't shows no orb. Each agent holds its **own** OpenAI
|
|
10
|
+
key — never a shared one, and never in Studio.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
eve add @kybernesis/voice
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Set the agent's own key in a non-generic variable so nothing else picks it up:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# in the agent's .env.local
|
|
22
|
+
KYBERNESIS_VOICE_OPENAI_KEY=sk-...
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Mount
|
|
26
|
+
|
|
27
|
+
```ts title="agent/channels/voice.ts"
|
|
28
|
+
import { voiceChannel } from "@kybernesis/voice";
|
|
29
|
+
|
|
30
|
+
export default voiceChannel({
|
|
31
|
+
openaiApiKey: process.env.KYBERNESIS_VOICE_OPENAI_KEY!,
|
|
32
|
+
voice: "cedar", // any OpenAI Live voice; Studio's per-agent setting overrides at mint time
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Routes
|
|
37
|
+
|
|
38
|
+
Both require the caller's control-plane grant for this agent (verified with
|
|
39
|
+
`@kybernesis/enterprise`), the same identity every other door uses.
|
|
40
|
+
|
|
41
|
+
- `GET /eve/v1/voice/manifest` — `{ ok, enabled, voice, displayName }`. Studio
|
|
42
|
+
calls this per agent to decide whether to show the orb.
|
|
43
|
+
- `POST /eve/v1/voice/session` — body `{ sdp }` (the browser's WebRTC offer);
|
|
44
|
+
mints an OpenAI Live session with this agent's key, configured for client
|
|
45
|
+
delegation, and returns `{ ok, sdp }` (the answer). The key never leaves the
|
|
46
|
+
agent; only SDP crosses the wire.
|
|
47
|
+
|
|
48
|
+
## How it fits together
|
|
49
|
+
|
|
50
|
+
gpt-live-1 is the spoken voice and, under client delegation, hands every real
|
|
51
|
+
turn to the client. KYBER Studio routes that delegation to this agent's own eve
|
|
52
|
+
session and speaks back the result. This package is the agent's half: advertise
|
|
53
|
+
the capability, and mint the session with the agent's own key.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface VoiceOptions {
|
|
2
|
+
/**
|
|
3
|
+
* This agent's OpenAI API key, used only to mint realtime sessions. Read it
|
|
4
|
+
* from a NON-generic env var in the mount file (e.g.
|
|
5
|
+
* `process.env.KYBERNESIS_VOICE_OPENAI_KEY`) so no other code — and no OpenAI
|
|
6
|
+
* SDK that auto-reads `OPENAI_API_KEY` — ever picks it up. It is never sent to
|
|
7
|
+
* Studio; only the short-lived realtime answer SDP is returned.
|
|
8
|
+
*/
|
|
9
|
+
openaiApiKey: string;
|
|
10
|
+
/** The spoken voice (OpenAI Live voice name). Defaults to "marin". */
|
|
11
|
+
voice?: string;
|
|
12
|
+
/** How the voice refers to itself. Defaults to KYBERNESIS_AGENT. */
|
|
13
|
+
displayName?: string;
|
|
14
|
+
/** Realtime model. Defaults to gpt-live-1 (override with OPENAI_LIVE_MODEL). */
|
|
15
|
+
model?: string;
|
|
16
|
+
/** Control-plane issuer. Defaults to KYBERNESIS_ISSUER. */
|
|
17
|
+
issuer?: string;
|
|
18
|
+
/** This agent's registered name, for grant checks. Defaults to KYBERNESIS_AGENT. */
|
|
19
|
+
agent?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function voiceChannel(options: VoiceOptions): import("eve/channels").Channel<undefined, Record<string, unknown>, Record<string, unknown>>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { verifyKybernesisRequest } from "@kybernesis/enterprise";
|
|
2
|
+
import { defineChannel, GET, POST } from "eve/channels";
|
|
3
|
+
/**
|
|
4
|
+
* @kybernesis/voice — the agent side of KYBER Studio's realtime voice orb.
|
|
5
|
+
*
|
|
6
|
+
* Voice is a per-agent capability, not a Studio-global one. An agent that mounts
|
|
7
|
+
* this channel becomes voice-capable; one that doesn't has no orb in Studio. The
|
|
8
|
+
* agent holds its OWN OpenAI key (never a shared one, never in Studio), and this
|
|
9
|
+
* channel is the only thing that reads it: the key is passed into `voiceChannel`
|
|
10
|
+
* from the mount file's `process.env` and closed over here, so nothing else in
|
|
11
|
+
* the agent picks it up by name.
|
|
12
|
+
*
|
|
13
|
+
* How a call runs: gpt-live-1 is the spoken voice and delegates every real
|
|
14
|
+
* request (client delegation); Studio routes that delegation to THIS agent's own
|
|
15
|
+
* session, where the tools, connectors, and memory live. So the voice can do
|
|
16
|
+
* anything the text chat can. This channel does two things only — say the agent
|
|
17
|
+
* is voice-capable, and mint the realtime session with the agent's key — both
|
|
18
|
+
* behind the same control-plane grant that guards every other door.
|
|
19
|
+
*
|
|
20
|
+
* ```ts title="agent/channels/voice.ts"
|
|
21
|
+
* import { voiceChannel } from "@kybernesis/voice";
|
|
22
|
+
* export default voiceChannel({
|
|
23
|
+
* openaiApiKey: process.env.KYBERNESIS_VOICE_OPENAI_KEY!,
|
|
24
|
+
* voice: "cedar",
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
/** Routes mount verbatim at the server root, so they are namespaced here. */
|
|
29
|
+
const PREFIX = "/eve/v1/voice";
|
|
30
|
+
const LIVE_SESSIONS_URL = "https://api.openai.com/v1/live/sessions";
|
|
31
|
+
const DEFAULT_MODEL = "gpt-live-1";
|
|
32
|
+
const DEFAULT_VOICE = "marin";
|
|
33
|
+
/**
|
|
34
|
+
* The spoken persona. The voice model is a mouth, not a mind: it holds none of
|
|
35
|
+
* the agent's knowledge and must delegate everything real back to the agent,
|
|
36
|
+
* then speak the result in the first person. This is what stops it answering
|
|
37
|
+
* "what is your role?" as a generic assistant instead of as the agent.
|
|
38
|
+
*/
|
|
39
|
+
function liveInstructions(displayName) {
|
|
40
|
+
return (`You are the live spoken VOICE of ${displayName}. You are only a voice: you have no knowledge, memory, opinions, tools, routines, or identity of your own. Everything the user hears must come from ${displayName}'s real mind, which you reach by delegating. ` +
|
|
41
|
+
`Delegate EVERY user message that expects an answer or an action — including who you are, your role, what you can do, your memory, your routines, creating or changing routines, any question, any task, anything factual or personal. Never answer from your own knowledge, and never describe yourself in generic terms like "a helpful assistant." ` +
|
|
42
|
+
`When the delegated result comes back, speak it in the first person as ${displayName}, naturally and concisely, as if you had known it all along. ` +
|
|
43
|
+
`The only things you may say without delegating are pure conversational sounds that carry no request: a brief greeting, "one moment", an acknowledgement, or confirming you can hear the user. When in doubt, delegate.`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Verify the SAME control-plane identity the user already signed in with — the
|
|
47
|
+
* bearer token plus policy bundle — and require a grant for this agent. Custom
|
|
48
|
+
* channels do not run the eve channel's authenticator, so each route checks
|
|
49
|
+
* here, exactly as @kybernesis/manage does. No separate shared secret.
|
|
50
|
+
*/
|
|
51
|
+
async function authorize(req, options) {
|
|
52
|
+
const issuer = options.issuer ?? process.env.KYBERNESIS_ISSUER ?? "https://agent.kybernesis.ai";
|
|
53
|
+
const agent = options.agent ?? process.env.KYBERNESIS_AGENT;
|
|
54
|
+
if (!agent) {
|
|
55
|
+
return Response.json({ ok: false, error: "This agent has no KYBERNESIS_AGENT set, so it cannot check grants." }, { status: 500 });
|
|
56
|
+
}
|
|
57
|
+
const result = await verifyKybernesisRequest(req, { issuer, agent });
|
|
58
|
+
if (result.ok)
|
|
59
|
+
return null;
|
|
60
|
+
return Response.json({ ok: false, error: result.error }, { status: result.status });
|
|
61
|
+
}
|
|
62
|
+
export function voiceChannel(options) {
|
|
63
|
+
const voice = options.voice ?? DEFAULT_VOICE;
|
|
64
|
+
const model = options.model ?? process.env.OPENAI_LIVE_MODEL ?? DEFAULT_MODEL;
|
|
65
|
+
const displayName = options.displayName ?? options.agent ?? process.env.KYBERNESIS_AGENT ?? "the agent";
|
|
66
|
+
return defineChannel({
|
|
67
|
+
routes: [
|
|
68
|
+
/**
|
|
69
|
+
* Say this agent is voice-capable, and how it should sound. Studio calls
|
|
70
|
+
* this per agent to decide whether to show the orb at all.
|
|
71
|
+
*/
|
|
72
|
+
GET(PREFIX + "/manifest", async (req) => {
|
|
73
|
+
const denied = await authorize(req, options);
|
|
74
|
+
if (denied)
|
|
75
|
+
return denied;
|
|
76
|
+
return Response.json({ ok: true, enabled: true, voice, displayName });
|
|
77
|
+
}),
|
|
78
|
+
/**
|
|
79
|
+
* Mint a realtime session for the browser's WebRTC offer, using THIS
|
|
80
|
+
* agent's own OpenAI key, and return the answer SDP. The key never leaves
|
|
81
|
+
* the agent; Studio relays only SDP. The session is configured for client
|
|
82
|
+
* delegation, so the voice hands real work back to the agent's session.
|
|
83
|
+
*/
|
|
84
|
+
POST(PREFIX + "/session", async (req) => {
|
|
85
|
+
const denied = await authorize(req, options);
|
|
86
|
+
if (denied)
|
|
87
|
+
return denied;
|
|
88
|
+
if (!options.openaiApiKey) {
|
|
89
|
+
return Response.json({ ok: false, error: "This agent has no voice key set (KYBERNESIS_VOICE_OPENAI_KEY)." }, { status: 500 });
|
|
90
|
+
}
|
|
91
|
+
let sdp;
|
|
92
|
+
let requestedVoice;
|
|
93
|
+
try {
|
|
94
|
+
const body = (await req.json());
|
|
95
|
+
if (typeof body.sdp !== "string" || !body.sdp)
|
|
96
|
+
throw new Error("missing sdp");
|
|
97
|
+
sdp = body.sdp;
|
|
98
|
+
if (typeof body.voice === "string" && body.voice)
|
|
99
|
+
requestedVoice = body.voice;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return Response.json({ ok: false, error: "Expected a JSON body with an SDP offer." }, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
const upstream = await fetch(LIVE_SESSIONS_URL, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { authorization: `Bearer ${options.openaiApiKey}`, "content-type": "application/json" },
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
session: {
|
|
109
|
+
model,
|
|
110
|
+
instructions: liveInstructions(displayName),
|
|
111
|
+
audio: { output: { voice: requestedVoice ?? voice } },
|
|
112
|
+
delegation: { type: "client" },
|
|
113
|
+
},
|
|
114
|
+
transport: { type: "webrtc", sdp },
|
|
115
|
+
}),
|
|
116
|
+
}).catch((err) => {
|
|
117
|
+
return { ok: false, status: 502, text: async () => String(err) };
|
|
118
|
+
});
|
|
119
|
+
const text = await upstream.text();
|
|
120
|
+
if (!upstream.ok) {
|
|
121
|
+
return Response.json({ ok: false, error: `OpenAI Live session ${upstream.status}: ${text.slice(0, 400)}` }, { status: 502 });
|
|
122
|
+
}
|
|
123
|
+
let data;
|
|
124
|
+
try {
|
|
125
|
+
data = JSON.parse(text);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return Response.json({ ok: false, error: "OpenAI returned a non-JSON session." }, { status: 502 });
|
|
129
|
+
}
|
|
130
|
+
const answer = data.transport?.sdp ?? data.sdp;
|
|
131
|
+
if (!answer) {
|
|
132
|
+
return Response.json({ ok: false, error: "OpenAI session had no answer SDP." }, { status: 502 });
|
|
133
|
+
}
|
|
134
|
+
return Response.json({ ok: true, sdp: answer });
|
|
135
|
+
}),
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kybernesis/voice",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Give a KYBER Studio agent its own realtime voice: the floating orb speaks as the agent and delegates every real request back to the agent's own session. The agent holds its own OpenAI key.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"NOTICE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
26
|
+
"prepublishOnly": "node ../../scripts/prepublish.mjs",
|
|
27
|
+
"test": "node --test \"test/**/*.test.mjs\""
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@kybernesis/enterprise": ">=0.3.0",
|
|
31
|
+
"eve": ">=0.51.0 <0.52.0",
|
|
32
|
+
"zod": "^3.23.0 || ^4.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@kybernesis/enterprise": "^0.8.0",
|
|
36
|
+
"@types/node": "^22.10.2",
|
|
37
|
+
"eve": "^0.51.1",
|
|
38
|
+
"typescript": "^5.7.2"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": "24.x"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://kybernesis.ai",
|
|
44
|
+
"keywords": [
|
|
45
|
+
"eve",
|
|
46
|
+
"eve-channel",
|
|
47
|
+
"voice",
|
|
48
|
+
"realtime",
|
|
49
|
+
"kybernesis",
|
|
50
|
+
"agent"
|
|
51
|
+
],
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/KybernesisAI/platform.git",
|
|
55
|
+
"directory": "packages/voice"
|
|
56
|
+
}
|
|
57
|
+
}
|