@livekit/agents-plugin-lemonslice 1.0.40

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/src/avatar.ts ADDED
@@ -0,0 +1,312 @@
1
+ // SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import {
5
+ type APIConnectOptions,
6
+ APIConnectionError,
7
+ APIStatusError,
8
+ DEFAULT_API_CONNECT_OPTIONS,
9
+ getJobContext,
10
+ intervalForRetry,
11
+ voice,
12
+ } from '@livekit/agents';
13
+ import type { Room } from '@livekit/rtc-node';
14
+ import { TrackKind } from '@livekit/rtc-node';
15
+ import type { VideoGrant } from 'livekit-server-sdk';
16
+ import { AccessToken } from 'livekit-server-sdk';
17
+ import { log } from './log.js';
18
+
19
+ const ATTRIBUTE_PUBLISH_ON_BEHALF = 'lk.publish_on_behalf';
20
+ const DEFAULT_API_URL = 'https://lemonslice.com/api/liveai/sessions';
21
+ const SAMPLE_RATE = 16000;
22
+ const AVATAR_AGENT_IDENTITY = 'lemonslice-avatar-agent';
23
+ const AVATAR_AGENT_NAME = 'lemonslice-avatar-agent';
24
+
25
+ /**
26
+ * Exception thrown when there are errors with the LemonSlice API.
27
+ */
28
+ export class LemonSliceException extends Error {
29
+ constructor(message: string) {
30
+ super(message);
31
+ this.name = 'LemonSliceException';
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Options for configuring an AvatarSession.
37
+ */
38
+ export interface AvatarSessionOptions {
39
+ /**
40
+ * The ID of the LemonSlice agent to add to the session.
41
+ * Either agentId or agentImageUrl must be provided.
42
+ */
43
+ agentId?: string | null;
44
+ /**
45
+ * The URL of the image to use as the agent's avatar.
46
+ * Either agentId or agentImageUrl must be provided.
47
+ */
48
+ agentImageUrl?: string | null;
49
+ /**
50
+ * A prompt that subtly influences the avatar's movements and expressions.
51
+ */
52
+ agentPrompt?: string | null;
53
+ /**
54
+ * The idle timeout, in seconds. Defaults to 60 seconds.
55
+ */
56
+ idleTimeout?: number | null;
57
+ /**
58
+ * The LemonSlice API URL. Defaults to https://lemonslice.com/api/liveai/sessions
59
+ * or LEMONSLICE_API_URL environment variable.
60
+ */
61
+ apiUrl?: string;
62
+ /**
63
+ * The LemonSlice API key. Can also be set via LEMONSLICE_API_KEY environment variable.
64
+ */
65
+ apiKey?: string;
66
+ /**
67
+ * The identity of the avatar participant in the room. Defaults to 'lemonslice-avatar-agent'.
68
+ */
69
+ avatarParticipantIdentity?: string;
70
+ /**
71
+ * The name of the avatar participant in the room. Defaults to 'lemonslice-avatar-agent'.
72
+ */
73
+ avatarParticipantName?: string;
74
+ /**
75
+ * Connection options for API requests.
76
+ */
77
+ connOptions?: APIConnectOptions;
78
+ }
79
+
80
+ /**
81
+ * Options for starting an avatar session.
82
+ */
83
+ export interface StartOptions {
84
+ /**
85
+ * LiveKit server URL. Falls back to LIVEKIT_URL environment variable.
86
+ */
87
+ livekitUrl?: string;
88
+ /**
89
+ * LiveKit API key. Falls back to LIVEKIT_API_KEY environment variable.
90
+ */
91
+ livekitApiKey?: string;
92
+ /**
93
+ * LiveKit API secret. Falls back to LIVEKIT_API_SECRET environment variable.
94
+ */
95
+ livekitApiSecret?: string;
96
+ }
97
+
98
+ /**
99
+ * A LemonSlice avatar session.
100
+ *
101
+ * This class manages the connection between a LiveKit agent and a LemonSlice avatar,
102
+ * routing agent audio output to the avatar for visual representation.
103
+ *
104
+ * @example
105
+ * ```typescript
106
+ * // Using an agent ID
107
+ * const avatar = new AvatarSession({
108
+ * agentId: 'your-agent-id',
109
+ * apiKey: 'your-lemonslice-api-key',
110
+ * });
111
+ * await avatar.start(agentSession, room);
112
+ *
113
+ * // Using a custom avatar image
114
+ * const avatar = new AvatarSession({
115
+ * agentImageUrl: 'your-image-url',
116
+ * apiKey: 'your-lemonslice-api-key',
117
+ * });
118
+ * await avatar.start(agentSession, room);
119
+ * ```
120
+ */
121
+ export class AvatarSession {
122
+ private agentId: string | null;
123
+ private agentImageUrl: string | null;
124
+ private agentPrompt: string | null;
125
+ private idleTimeout: number | null;
126
+ private apiUrl: string;
127
+ private apiKey: string;
128
+ private avatarParticipantIdentity: string;
129
+ private avatarParticipantName: string;
130
+ private connOptions: APIConnectOptions;
131
+
132
+ #logger = log();
133
+
134
+ /**
135
+ * Creates a new AvatarSession.
136
+ *
137
+ * @param options - Configuration options for the avatar session
138
+ * @throws LemonSliceException if invalid agentId or agentImageUrl is provided, or if LemonSlice API key is not set
139
+ */
140
+ constructor(options: AvatarSessionOptions = {}) {
141
+ this.agentId = options.agentId ?? null;
142
+ this.agentImageUrl = options.agentImageUrl ?? null;
143
+
144
+ if (!this.agentId && !this.agentImageUrl) {
145
+ throw new LemonSliceException('Missing agentId or agentImageUrl');
146
+ }
147
+ if (this.agentId && this.agentImageUrl) {
148
+ throw new LemonSliceException('Only one of agentId or agentImageUrl can be provided');
149
+ }
150
+
151
+ this.agentPrompt = options.agentPrompt ?? null;
152
+ this.idleTimeout = options.idleTimeout ?? null;
153
+
154
+ this.apiUrl = options.apiUrl || process.env.LEMONSLICE_API_URL || DEFAULT_API_URL;
155
+ this.apiKey = options.apiKey || process.env.LEMONSLICE_API_KEY || '';
156
+
157
+ if (!this.apiKey) {
158
+ throw new LemonSliceException(
159
+ 'The api_key must be set either by passing apiKey to the client or ' +
160
+ 'by setting the LEMONSLICE_API_KEY environment variable',
161
+ );
162
+ }
163
+
164
+ this.avatarParticipantIdentity = options.avatarParticipantIdentity || AVATAR_AGENT_IDENTITY;
165
+ this.avatarParticipantName = options.avatarParticipantName || AVATAR_AGENT_NAME;
166
+ this.connOptions = options.connOptions || DEFAULT_API_CONNECT_OPTIONS;
167
+ }
168
+
169
+ /**
170
+ * Starts the avatar session and connects it to the agent.
171
+ *
172
+ * This method:
173
+ * 1. Creates a LiveKit token for the avatar participant
174
+ * 2. Calls the LemonSlice API to start the avatar session
175
+ * 3. Configures the agent's audio output to stream to the avatar
176
+ *
177
+ * @param agentSession - The agent session to connect to the avatar
178
+ * @param room - The LiveKit room where the avatar will join
179
+ * @param options - Optional LiveKit credentials (falls back to environment variables)
180
+ * @throws LemonSliceException if LiveKit credentials are not available or if the avatar session fails to start
181
+ */
182
+ async start(
183
+ agentSession: voice.AgentSession,
184
+ room: Room,
185
+ options: StartOptions = {},
186
+ ): Promise<void> {
187
+ const livekitUrl = options.livekitUrl || process.env.LIVEKIT_URL;
188
+ const livekitApiKey = options.livekitApiKey || process.env.LIVEKIT_API_KEY;
189
+ const livekitApiSecret = options.livekitApiSecret || process.env.LIVEKIT_API_SECRET;
190
+
191
+ if (!livekitUrl || !livekitApiKey || !livekitApiSecret) {
192
+ throw new LemonSliceException(
193
+ 'livekitUrl, livekitApiKey, and livekitApiSecret must be set ' +
194
+ 'by arguments or environment variables',
195
+ );
196
+ }
197
+
198
+ let localParticipantIdentity: string;
199
+ try {
200
+ const jobCtx = getJobContext();
201
+ localParticipantIdentity = jobCtx.agent?.identity || '';
202
+ if (!localParticipantIdentity && room.localParticipant) {
203
+ localParticipantIdentity = room.localParticipant.identity;
204
+ }
205
+ } catch {
206
+ if (!room.isConnected || !room.localParticipant) {
207
+ throw new LemonSliceException('failed to get local participant identity');
208
+ }
209
+ localParticipantIdentity = room.localParticipant.identity;
210
+ }
211
+
212
+ if (!localParticipantIdentity) {
213
+ throw new LemonSliceException('failed to get local participant identity');
214
+ }
215
+
216
+ const at = new AccessToken(livekitApiKey, livekitApiSecret, {
217
+ identity: this.avatarParticipantIdentity,
218
+ name: this.avatarParticipantName,
219
+ });
220
+ at.kind = 'agent';
221
+
222
+ at.addGrant({
223
+ roomJoin: true,
224
+ room: room.name,
225
+ } as VideoGrant);
226
+
227
+ // allow the avatar agent to publish audio and video on behalf of your local agent
228
+ at.attributes = {
229
+ [ATTRIBUTE_PUBLISH_ON_BEHALF]: localParticipantIdentity,
230
+ };
231
+
232
+ const livekitToken = await at.toJwt();
233
+
234
+ this.#logger.debug('starting avatar session');
235
+ await this.startAgent(livekitUrl, livekitToken);
236
+
237
+ agentSession.output.audio = new voice.DataStreamAudioOutput({
238
+ room,
239
+ destinationIdentity: this.avatarParticipantIdentity,
240
+ sampleRate: SAMPLE_RATE,
241
+ waitRemoteTrack: TrackKind.KIND_VIDEO,
242
+ });
243
+ }
244
+
245
+ private async startAgent(livekitUrl: string, livekitToken: string): Promise<void> {
246
+ for (let i = 0; i <= this.connOptions.maxRetry; i++) {
247
+ try {
248
+ const payload: Record<string, any> = {
249
+ transport_type: 'livekit',
250
+ properties: {
251
+ livekit_url: livekitUrl,
252
+ livekit_token: livekitToken,
253
+ },
254
+ };
255
+
256
+ if (this.agentId) {
257
+ payload.agent_id = this.agentId;
258
+ }
259
+
260
+ if (this.agentImageUrl) {
261
+ payload.agent_image_url = this.agentImageUrl;
262
+ }
263
+
264
+ if (this.agentPrompt) {
265
+ payload.agent_prompt = this.agentPrompt;
266
+ }
267
+
268
+ if (this.idleTimeout !== null) {
269
+ payload.idle_timeout = this.idleTimeout;
270
+ }
271
+
272
+ const response = await fetch(this.apiUrl, {
273
+ method: 'POST',
274
+ headers: {
275
+ 'Content-Type': 'application/json',
276
+ 'X-API-Key': this.apiKey,
277
+ },
278
+ body: JSON.stringify(payload),
279
+ signal: AbortSignal.timeout(this.connOptions.timeoutMs),
280
+ });
281
+
282
+ if (!response.ok) {
283
+ const text = await response.text();
284
+ throw new APIStatusError({
285
+ message: 'Server returned an error',
286
+ options: { statusCode: response.status, body: { error: text } },
287
+ });
288
+ }
289
+ return;
290
+ } catch (e) {
291
+ if (e instanceof APIStatusError && !e.retryable) {
292
+ throw e;
293
+ }
294
+ if (e instanceof APIConnectionError) {
295
+ this.#logger.warn({ error: String(e) }, 'failed to call lemonslice api');
296
+ } else {
297
+ this.#logger.error({ error: e }, 'failed to call lemonslice api');
298
+ }
299
+
300
+ if (i <= this.connOptions.maxRetry - 1) {
301
+ await new Promise((resolve) =>
302
+ setTimeout(resolve, intervalForRetry(this.connOptions, i)),
303
+ );
304
+ }
305
+ }
306
+ }
307
+
308
+ throw new APIConnectionError({
309
+ message: 'Failed to start LemonSlice Avatar Session after all retries',
310
+ });
311
+ }
312
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { Plugin } from '@livekit/agents';
5
+ import { version } from './version.js';
6
+
7
+ export * from './avatar.js';
8
+
9
+ class LemonSlicePlugin extends Plugin {
10
+ constructor() {
11
+ super({
12
+ title: 'lemonslice',
13
+ version,
14
+ package: '@livekit/agents-plugin-lemonslice',
15
+ });
16
+ }
17
+ }
18
+
19
+ Plugin.registerPlugin(new LemonSlicePlugin());
package/src/log.ts ADDED
@@ -0,0 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { log as agentsLog } from '@livekit/agents';
5
+ import type { Logger } from 'pino';
6
+
7
+ export const log = (): Logger => agentsLog().child({ plugin: 'lemonslice' });
package/src/version.ts ADDED
@@ -0,0 +1,4 @@
1
+ // SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2
+ //
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ export const version = '1.0.0';