@johpaz/hive-sdk 0.4.9 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +85 -1
  2. package/README.md +9 -4
  3. package/docs/API-AGENTS.md +70 -0
  4. package/docs/INDEX.md +5 -1
  5. package/docs/UPGRADING.md +20 -0
  6. package/package.json +1 -1
  7. package/packages/core/src/agent/agent-loop.ts +82 -5
  8. package/packages/core/src/agent/context-compiler.ts +122 -23
  9. package/packages/core/src/agent/index.ts +2 -0
  10. package/packages/core/src/agent/jev-decisions.ts +197 -0
  11. package/packages/core/src/agent/jev-planner.ts +298 -0
  12. package/packages/core/src/agent/llm-client.ts +2 -2
  13. package/packages/core/src/agent/llm-providers/anthropic.ts +2 -1
  14. package/packages/core/src/agent/llm-providers/gemini.ts +3 -1
  15. package/packages/core/src/agent/realtime-providers/gemini-live.ts +2 -1
  16. package/packages/core/src/agent/tool-selector.ts +1 -0
  17. package/packages/core/src/canvas/emitter.ts +19 -0
  18. package/packages/core/src/events/channel-narration.ts +3 -0
  19. package/packages/core/src/multimodal/vision-service.ts +7 -7
  20. package/packages/core/src/services/swarms.ts +4 -0
  21. package/packages/core/src/storage/collections.ts +9 -2
  22. package/packages/core/src/storage/crypto.ts +51 -17
  23. package/packages/core/src/storage/index.ts +1 -0
  24. package/packages/core/src/storage/seed.ts +4 -0
  25. package/packages/core/src/storage/usage.ts +55 -2
  26. package/packages/core/src/swarm/RoleSwarm.ts +6 -0
  27. package/packages/core/src/tool-runtime/index.ts +20 -1
  28. package/packages/core/src/tools/agents/get-available-models.ts +3 -1
  29. package/packages/core/src/tools/core/index.ts +33 -2
  30. package/packages/core/src/tools/web/computer-use.ts +2 -2
  31. package/packages/core/src/voice/index.ts +13 -13
@@ -96,6 +96,7 @@ export {
96
96
  deleteSecret,
97
97
  storeProviderApiKey,
98
98
  loadProviderApiKey,
99
+ envSecret,
99
100
  storeProviderHeaders,
100
101
  loadProviderHeaders,
101
102
  deleteProviderSecrets,
@@ -117,6 +117,7 @@ export const SEED_DATA: SeedData = {
117
117
  { id: "notify", name: "notify", category: "core", description: "Enviar notificación al usuario. Sinónimos: notificar, enviar notificación, alertar, aviso" },
118
118
  { id: "save_note", name: "save_note", category: "core", description: "Guardar nota persistente en el scratchpad. Sinónimos: guardar nota, escribir nota, recordatorio rápido, apuntar" },
119
119
  { id: "report_progress", name: "report_progress", category: "core", description: "Reportar progreso actual al usuario. Sinónimos: reportar progreso, informar estado, actualizar progreso, porcentaje" },
120
+ { id: "conversation_read", name: "conversation_read", category: "core", description: "Recuperar mensajes anteriores de esta conversación por ID o búsqueda de texto. Sinónimos: leer conversación, recuperar contexto, historial omitido" },
120
121
 
121
122
  // ─────────────────────────────────────────
122
123
  // 10. OFFICE — Archivos Office (PDF, DOCX, XLSX, PPTX)
@@ -235,6 +236,9 @@ export const SEED_DATA: SeedData = {
235
236
  { id: "kimi-k2.6", providerId: "kimi", name: "Kimi K2.6", modelType: "llm", contextWindow: 262144, capabilities: JSON.stringify(["chat", "vision", "json_mode", "function_calling", "streaming", "code"]), inputPer1M: 0.6, outputPer1M: 3.41 },
236
237
 
237
238
  // ── OpenRouter (fuente: GET https://openrouter.ai/api/v1/models) ──
239
+ // Jev: modelo de decisiones (API Decisions, nunca chat). `modelType:
240
+ // "decision"` lo deja fuera de getDefaultLLM y de get_available_models.
241
+ { id: "typesafe/jev-1.13", providerId: "openrouter", name: "Jev 1.13 (Decisions)", modelType: "decision", contextWindow: 32000, capabilities: JSON.stringify(["choice", "noul", "score"]), inputPer1M: 0.042, outputPer1M: 0 },
238
242
  // Solo modelos vivos con `tools` en supported_parameters y publicados desde
239
243
  // 2025-07. contextWindow = context_length reportado por el propio catálogo,
240
244
  // y los precios también salen de ahí — son los de la ruta de OpenRouter, no
@@ -154,6 +154,15 @@ export interface UsageRecord {
154
154
  created_at: number;
155
155
  }
156
156
 
157
+ export interface JevUsageSummary {
158
+ decisions: number;
159
+ costUsd: number;
160
+ /** Estimated main-model input tokens avoided; negative when Jev added more than it removed. */
161
+ savedTokens: number;
162
+ savedCostUsd: number;
163
+ byAgent: Record<string, { decisions: number; costUsd: number; savedTokens: number; savedCostUsd: number }>;
164
+ }
165
+
157
166
  export interface UsageSummary {
158
167
  totalTokens: number;
159
168
  totalInputTokens: number;
@@ -169,6 +178,7 @@ export interface UsageSummary {
169
178
  byProvider: Record<string, { tokens: number; costUsd: number; inputTokens: number; outputTokens: number }>;
170
179
  byModel: Record<string, { tokens: number; costUsd: number; provider: string; inputTokens: number; outputTokens: number }>;
171
180
  recentRecords: UsageRecord[];
181
+ jev: JevUsageSummary;
172
182
  }
173
183
 
174
184
  export function recordUsage(options: {
@@ -233,13 +243,15 @@ export async function getUsageStats(hours: number = 24): Promise<UsageSummary> {
233
243
  const rollupsCol = await col<UsageRollupDoc>("usageRollups");
234
244
  const buckets = hourBucketsSince(hours);
235
245
  const rollups = (await Promise.all(buckets.map((id) => rollupsCol.get(id))))
236
- .map((e) => e?.doc ?? emptyRollup());
246
+ // A bucket touched only by recordJevDecision lacks the token fields.
247
+ .map((e) => ({ ...emptyRollup(), ...e?.doc }));
237
248
 
238
249
  const providerMap: UsageSummary["byProvider"] = {};
239
250
  const modelMap: UsageSummary["byModel"] = {};
240
251
  let totalInput = 0, totalOutput = 0, totalCost = 0;
241
252
  let toonSavedTokens = 0, toonSavedCost = 0, toonSavedBytes = 0;
242
253
  let toonJsonTokens = 0, toonToonTokens = 0, toonJsonBytes = 0;
254
+ const jev: JevUsageSummary = { decisions: 0, costUsd: 0, savedTokens: 0, savedCostUsd: 0, byAgent: {} };
243
255
 
244
256
  for (const r of rollups) {
245
257
  totalInput += r.inputTokens;
@@ -251,6 +263,18 @@ export async function getUsageStats(hours: number = 24): Promise<UsageSummary> {
251
263
  toonJsonTokens += r.toonJsonTokens;
252
264
  toonToonTokens += r.toonToonTokens;
253
265
  toonJsonBytes += r.toonJsonBytes;
266
+ jev.decisions += r.jevDecisions ?? 0;
267
+ jev.costUsd += r.jevCostUsd ?? 0;
268
+ jev.savedTokens += r.jevSavedTokens ?? 0;
269
+ jev.savedCostUsd += r.jevSavedCostUsd ?? 0;
270
+ for (const [agentId, a] of Object.entries(r.jevByAgent ?? {})) {
271
+ const cur = jev.byAgent[agentId] ?? { decisions: 0, costUsd: 0, savedTokens: 0, savedCostUsd: 0 };
272
+ cur.decisions += a.jevDecisions ?? 0;
273
+ cur.costUsd += a.jevCostUsd ?? 0;
274
+ cur.savedTokens += a.jevSavedTokens ?? 0;
275
+ cur.savedCostUsd += a.jevSavedCostUsd ?? 0;
276
+ jev.byAgent[agentId] = cur;
277
+ }
254
278
 
255
279
  for (const [provider, p] of Object.entries(r.byProvider ?? {})) {
256
280
  const cur = providerMap[provider] ?? { tokens: 0, costUsd: 0, inputTokens: 0, outputTokens: 0 };
@@ -301,10 +325,39 @@ export async function getUsageStats(hours: number = 24): Promise<UsageSummary> {
301
325
  toonSavingsPercent,
302
326
  byProvider: providerMap,
303
327
  byModel: modelMap,
304
- recentRecords
328
+ recentRecords,
329
+ jev,
305
330
  };
306
331
  }
307
332
 
333
+ /**
334
+ * Persists one Jev decision into the hourly rollup. The avoided tokens are
335
+ * priced with the advised agent's own model (input rate), through the same
336
+ * catalog lookup as real usage, so "saved" and "spent" are comparable.
337
+ */
338
+ export function recordJevDecision(options: {
339
+ agentId: string;
340
+ provider: string;
341
+ model: string;
342
+ savedTokens: number;
343
+ costUsd: number;
344
+ }): void {
345
+ Promise.resolve().then(async () => {
346
+ try {
347
+ const unitCost = await calculateCost(options.provider, options.model, Math.abs(options.savedTokens), 0);
348
+ const savedCostUsd = Math.sign(options.savedTokens) * unitCost;
349
+ await bumpRollup("usageRollups", hourBucket(Date.now()), {
350
+ jevDecisions: 1,
351
+ jevCostUsd: options.costUsd,
352
+ jevSavedTokens: options.savedTokens,
353
+ jevSavedCostUsd: savedCostUsd,
354
+ }, { field: "jevByAgent", key: options.agentId });
355
+ } catch (error) {
356
+ log.warn(`[JEV] Failed to record decision:`, error);
357
+ }
358
+ });
359
+ }
360
+
308
361
  /**
309
362
  * Record TOON savings for metrics tracking
310
363
  * This updates the usage_records table with complete TOON compression metrics
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import { runAgentIsolated } from "../agent/agent-loop.ts"
24
+ import type { JevOption } from "../agent/jev-decisions.ts"
24
25
  import type { ProviderCredentials } from "../agent/llm-client.ts"
25
26
  import { logger } from "../utils/logger.ts"
26
27
 
@@ -50,6 +51,7 @@ export type AgentInvoker = (input: {
50
51
  threadId: string
51
52
  channel?: string
52
53
  credentials?: ProviderCredentials
54
+ jev?: JevOption
53
55
  signal?: AbortSignal
54
56
  }) => Promise<string>
55
57
 
@@ -60,6 +62,7 @@ export const defaultInvoker: AgentInvoker = async (input) =>
60
62
  threadId: input.threadId,
61
63
  channel: input.channel,
62
64
  credentials: input.credentials,
65
+ jev: input.jev,
63
66
  signal: input.signal,
64
67
  })
65
68
 
@@ -83,6 +86,8 @@ export interface RoleSwarmOptions {
83
86
  maxDelegations?: number
84
87
  /** Credenciales del inquilino, propagadas a cada agente. */
85
88
  credentials?: ProviderCredentials
89
+ /** Jev del inquilino, propagado a cada agente igual que `credentials`. */
90
+ jev?: JevOption
86
91
  signal?: AbortSignal
87
92
  /** Se llama en cada paso; acá persiste el consumidor si quiere. */
88
93
  onMessage?: (message: SwarmMessage) => void | Promise<void>
@@ -129,6 +134,7 @@ export async function runRoleSwarm(opts: RoleSwarmOptions): Promise<RoleSwarmRes
129
134
  threadId,
130
135
  channel: opts.channel,
131
136
  credentials: opts.credentials,
137
+ jev: opts.jev,
132
138
  signal: opts.signal,
133
139
  })
134
140
  }
@@ -47,6 +47,8 @@ export type ExecuteToolBatchOptions = {
47
47
  }
48
48
  hiveConfig?: Config
49
49
  workerPool?: ToolRuntimeConfig
50
+ /** Per-batch decision (Jev). Undefined preserves the configured behavior. */
51
+ parallelToolCalls?: boolean
50
52
  mainThreadToolNames?: string[]
51
53
  signal?: AbortSignal
52
54
  }
@@ -199,6 +201,7 @@ const DEFAULT_MAIN_THREAD_TOOL_NAMES = new Set([
199
201
  // These tools depend on process-local singleton state (HiveDB handle, live
200
202
  // channel senders, schedulers, browser sessions, or in-memory services).
201
203
  "search_knowledge",
204
+ "conversation_read",
202
205
  "save_note",
203
206
  "memory_write",
204
207
  "memory_read",
@@ -670,6 +673,7 @@ export async function executeToolBatch(options: ExecuteToolBatchOptions): Promis
670
673
 
671
674
  async function executeToolBatchInner(options: ExecuteToolBatchOptions): Promise<ToolBatchResult[]> {
672
675
  const runtimeConfig = resolveRuntimeConfig(options.workerPool)
676
+ const parallelToolCalls = options.parallelToolCalls ?? runtimeConfig.parallelToolCalls
673
677
  const hiveConfig = options.hiveConfig ?? loadConfig()
674
678
  const mainThreadToolNames = [
675
679
  ...DEFAULT_MAIN_THREAD_TOOL_NAMES,
@@ -690,11 +694,26 @@ async function executeToolBatchInner(options: ExecuteToolBatchOptions): Promise<
690
694
 
691
695
  // A null pool means workers are disabled, unnecessary (single call), or
692
696
  // unavailable in this build — all three degrade to the main thread.
693
- const pool = runtimeConfig.enabled && runtimeConfig.parallelToolCalls && options.toolCalls.length > 1
697
+ const pool = runtimeConfig.enabled && parallelToolCalls && options.toolCalls.length > 1
694
698
  ? getPool(runtimeConfig.maxWorkers)
695
699
  : null
696
700
 
697
701
  if (!pool) {
702
+ // Jev judged the batch independent: run it concurrently even without workers.
703
+ if (options.parallelToolCalls === true && options.toolCalls.length > 1) {
704
+ return Promise.all(options.toolCalls.map(async (toolCall) => {
705
+ const startedAt = performance.now()
706
+ const effectiveTimeout = resolveToolTimeout(toolCall.function.name, options.allTools, hiveConfig, runtimeConfig.toolTimeoutMs)
707
+ const result = await executeInMainThreadWithTimeout({
708
+ toolCall, allTools: options.allTools, toolConfig: options.toolConfig, signal: options.signal,
709
+ }, effectiveTimeout)
710
+ return {
711
+ toolCall, toolName: toolCall.function.name, result,
712
+ ok: !(result && typeof result === "object" && (result as any).error === true),
713
+ durationMs: Math.round(performance.now() - startedAt),
714
+ }
715
+ }))
716
+ }
698
717
  const results: ToolBatchResult[] = []
699
718
  for (const toolCall of options.toolCalls) {
700
719
  const startedAt = performance.now()
@@ -60,7 +60,9 @@ export const getAvailableModelsTool: Tool = {
60
60
  .filter(m => m.enabled && providersById.has(m.provider_id));
61
61
 
62
62
  if (providerId) models = models.filter(m => m.provider_id === providerId);
63
- if (modelType) models = models.filter(m => m.model_type === modelType);
63
+ // Decision models (Jev) only answer the Decisions API, never chat: an
64
+ // agent assigned one would fail every turn.
65
+ models = modelType ? models.filter(m => m.model_type === modelType) : models.filter(m => m.model_type !== "decision");
64
66
  if (capabilities) models = models.filter(m => (m.capabilities ?? "").includes(capabilities));
65
67
 
66
68
  // Transformar a formato amigable
@@ -14,7 +14,7 @@ import {
14
14
  type CapabilityType,
15
15
  } from "../../agent/capability-search.ts";
16
16
  import { CORE_TOOL_CATALOG } from "../../agent/tool-selector.ts";
17
- import { saveScratchpadNote } from "../../agent/conversation-store.ts";
17
+ import { saveScratchpadNote, getRecentMessages, getScratchpad } from "../../agent/conversation-store.ts";
18
18
 
19
19
  const log = logger.child("core");
20
20
 
@@ -503,6 +503,37 @@ export const reportProgressTool: Tool = {
503
503
  },
504
504
  };
505
505
 
506
+ /** Recover a small, scoped slice of conversation omitted by Jev's context plan. */
507
+ export const conversationReadTool: Tool = {
508
+ name: "conversation_read",
509
+ description: "Read earlier messages from the current conversation by message IDs or text query when selected context is insufficient.",
510
+ parameters: {
511
+ type: "object",
512
+ properties: {
513
+ message_ids: { type: "array", description: "Message IDs shown in the context plan", items: { type: "number" } },
514
+ note_keys: { type: "array", description: "Scratchpad note keys shown in the context plan", items: { type: "string" } },
515
+ query: { type: "string", description: "Optional case-insensitive text to search in earlier messages" },
516
+ },
517
+ },
518
+ execute: async (params, config) => {
519
+ const threadId = config?.configurable?.thread_id as string | undefined
520
+ if (!threadId) return { ok: false, error: "Conversation scope unavailable" }
521
+ const ids = new Set(Array.isArray(params.message_ids) ? params.message_ids.map(Number).filter(Number.isFinite) : [])
522
+ const noteKeys = new Set(Array.isArray(params.note_keys) ? params.note_keys.map(String) : [])
523
+ const query = String(params.query ?? "").trim().toLocaleLowerCase()
524
+ if (!ids.size && !noteKeys.size && !query) return { ok: false, error: "Provide message_ids, note_keys or query" }
525
+ const rows = await getRecentMessages(threadId, 200)
526
+ const notes = await getScratchpad(threadId)
527
+ return {
528
+ ok: true,
529
+ messages: rows.filter(row => (ids.size && ids.has(row.id)) || (query && row.content.toLocaleLowerCase().includes(query)))
530
+ .slice(-8).map(row => ({ id: row.id, role: row.role, source: row.source, content: row.content.slice(0, 2000) })),
531
+ notes: notes.filter(note => noteKeys.has(note.key) || (query && `${note.key} ${note.value}`.toLocaleLowerCase().includes(query)))
532
+ .slice(0, 8).map(note => ({ key: note.key, value: note.value.slice(0, 2000) })),
533
+ }
534
+ },
535
+ };
536
+
506
537
  export function createTools(): Tool[] {
507
- return [searchKnowledgeTool, notifyTool, saveNoteTool, reportProgressTool];
538
+ return [searchKnowledgeTool, notifyTool, saveNoteTool, reportProgressTool, conversationReadTool];
508
539
  }
@@ -28,7 +28,7 @@
28
28
  import type { Tool } from "../types.ts";
29
29
  import { logger } from "../../utils/logger.ts";
30
30
  import { getBrowserService } from "./browser-service.ts";
31
- import { loadProviderApiKey } from "../../storage/crypto.ts";
31
+ import { envSecret, loadProviderApiKey } from "../../storage/crypto.ts";
32
32
 
33
33
  const log = logger.child("computer-use");
34
34
 
@@ -417,7 +417,7 @@ export const computerUseTaskTool: Tool = {
417
417
  const maxPasos = Math.max(1, Math.min(30, Number(params.max_pasos) || MAX_PASOS));
418
418
  const confirmado = params.confirmado === true;
419
419
 
420
- const apiKey = (await loadProviderApiKey("gemini")) || process.env.GEMINI_API_KEY;
420
+ const apiKey = (await loadProviderApiKey("gemini")) || envSecret("GEMINI_API_KEY");
421
421
  if (!apiKey) {
422
422
  return { ok: false, error: "Falta la API key de Gemini (Ajustes → Proveedores)." };
423
423
  }
@@ -1,7 +1,7 @@
1
1
  import { resolvePort } from "../utils/port.ts";
2
2
  import { col } from "../storage/hive.ts";
3
3
  import type { ChannelDoc, ModelDoc } from "../storage/collections.ts";
4
- import { loadProviderApiKey } from "../storage/crypto.ts";
4
+ import { envSecret, loadProviderApiKey } from "../storage/crypto.ts";
5
5
  import { logger } from "../utils/logger.ts";
6
6
 
7
7
  export interface VoiceConfig {
@@ -160,7 +160,7 @@ class VoiceService {
160
160
  }
161
161
 
162
162
  private async transcribeWithGroq(audio: AudioInput, modelId: string): Promise<string> {
163
- const key = await this.getProviderApiKey("groq") || process.env.GROQ_API_KEY;
163
+ const key = await this.getProviderApiKey("groq") || envSecret("GROQ_API_KEY");
164
164
  if (!key) {
165
165
  throw new Error("GROQ_API_KEY not configured. Configúrala en Proveedores o en las variables de entorno.");
166
166
  }
@@ -212,7 +212,7 @@ class VoiceService {
212
212
  }
213
213
 
214
214
  private async transcribeWithOpenAIWhisper(audio: AudioInput): Promise<string> {
215
- const key = await this.getProviderApiKey("openai") || process.env.OPENAI_API_KEY;
215
+ const key = await this.getProviderApiKey("openai") || envSecret("OPENAI_API_KEY");
216
216
  if (!key) {
217
217
  throw new Error("OPENAI_API_KEY not configured. Configúrala en Proveedores o en las variables de entorno.");
218
218
  }
@@ -302,7 +302,7 @@ class VoiceService {
302
302
 
303
303
  private async speakWithElevenLabs(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
304
304
  const apiKey = await this.getProviderApiKey("elevenlabs");
305
- const key = apiKey || process.env.ELEVENLABS_API_KEY;
305
+ const key = apiKey || envSecret("ELEVENLABS_API_KEY");
306
306
 
307
307
  if (!key) {
308
308
  throw new Error("ELEVENLABS_API_KEY not configured");
@@ -341,7 +341,7 @@ class VoiceService {
341
341
 
342
342
  private async speakWithOpenAI(text: string, modelId: string = "gpt-4o-mini-tts", voiceId?: string): Promise<AudioOutput> {
343
343
  const apiKey = await this.getProviderApiKey("openai-tts");
344
- const key = apiKey || process.env.OPENAI_API_KEY;
344
+ const key = apiKey || envSecret("OPENAI_API_KEY");
345
345
 
346
346
  if (!key) {
347
347
  throw new Error("OPENAI_API_KEY not configured");
@@ -377,7 +377,7 @@ class VoiceService {
377
377
  }
378
378
 
379
379
  private async speakWithGemini(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
380
- const key = process.env.GEMINI_API_KEY;
380
+ const key = envSecret("GEMINI_API_KEY");
381
381
 
382
382
  if (!key) {
383
383
  throw new Error("GEMINI_API_KEY not configured");
@@ -431,7 +431,7 @@ class VoiceService {
431
431
  }
432
432
 
433
433
  private async speakWithQwen(text: string, modelId: string, voiceId?: string): Promise<AudioOutput> {
434
- const key = process.env.DASHSCOPE_API_KEY;
434
+ const key = envSecret("DASHSCOPE_API_KEY");
435
435
 
436
436
  if (!key) {
437
437
  throw new Error("DASHSCOPE_API_KEY not configured");
@@ -485,11 +485,11 @@ class VoiceService {
485
485
  ]);
486
486
 
487
487
  return {
488
- groq: groq || !!(process.env.GROQ_API_KEY),
489
- elevenlabs: elevenlabs || !!(process.env.ELEVENLABS_API_KEY),
490
- openai: openai || !!(process.env.OPENAI_API_KEY),
491
- gemini: gemini || !!(process.env.GEMINI_API_KEY),
492
- qwen: qwen || !!(process.env.DASHSCOPE_API_KEY),
488
+ groq: groq || !!(envSecret("GROQ_API_KEY")),
489
+ elevenlabs: elevenlabs || !!(envSecret("ELEVENLABS_API_KEY")),
490
+ openai: openai || !!(envSecret("OPENAI_API_KEY")),
491
+ gemini: gemini || !!(envSecret("GEMINI_API_KEY")),
492
+ qwen: qwen || !!(envSecret("DASHSCOPE_API_KEY")),
493
493
  };
494
494
  }
495
495
 
@@ -561,7 +561,7 @@ class VoiceService {
561
561
 
562
562
  async getElevenLabsVoices(): Promise<Array<{ id: string; name: string; category: string }>> {
563
563
  const apiKey = await this.getProviderApiKey("elevenlabs");
564
- const key = apiKey || process.env.ELEVENLABS_API_KEY;
564
+ const key = apiKey || envSecret("ELEVENLABS_API_KEY");
565
565
 
566
566
  if (!key) {
567
567
  throw new Error("ELEVENLABS_API_KEY not configured");