@app-vitals/agent-runtime 0.7.0 → 0.8.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/boot.ts CHANGED
@@ -111,7 +111,13 @@ import {
111
111
  } from "./sync-config-clone.ts";
112
112
  import { createTranscriptUploader } from "./transcript-uploader.ts";
113
113
  import { resolveDisplayName, resolveUserEmail } from "./users.ts";
114
- import { synthesizeSpeech, transcribeAudio } from "./voice.ts";
114
+ import {
115
+ buildVoiceConfig,
116
+ revertRemovedVoiceEnv,
117
+ synthesizeSpeech as synthesizeSpeechStatic,
118
+ transcribeAudio as transcribeAudioStatic,
119
+ VOICE_ENV_KEYS,
120
+ } from "./voice.ts";
115
121
  import {
116
122
  buildProductionDeps as buildWorktreeReaperDeps,
117
123
  reconcileStaleWorktrees,
@@ -178,6 +184,16 @@ export async function startAgent(
178
184
  // saved to the admin DB after boot only reach this process via syncConfig()'s
179
185
  // `applyEnvToProcess(bundle.env)`, so re-reading `config.slack.*`
180
186
  // here would keep seeing the boot-time (absent) values forever.
187
+ // COS-62.1: voice settings are likewise resolved from live process.env at
188
+ // call time (per-agent ELEVENLABS_* arrive via syncConfig after boot). The
189
+ // config arg the call sites pass is the boot snapshot and is ignored.
190
+ const synthesizeSpeech: typeof synthesizeSpeechStatic = (
191
+ text,
192
+ _cfg,
193
+ ...rest
194
+ ) => synthesizeSpeechStatic(text, buildVoiceConfig(), ...rest);
195
+ const transcribeAudio: typeof transcribeAudioStatic = (path, _cfg, ...rest) =>
196
+ transcribeAudioStatic(path, buildVoiceConfig(), ...rest);
181
197
  function buildSlackAppConfig() {
182
198
  return {
183
199
  botToken: process.env.SLACK_BOT_TOKEN ?? "",
@@ -357,7 +373,7 @@ export async function startAgent(
357
373
  process.env.SENTRY_DSN ? Sentry : undefined,
358
374
  undefined, // fileDownloaderFn — default
359
375
  config.voice,
360
- undefined, // transcribeAudioFn — default
376
+ transcribeAudio, // transcribeAudioFn — live-env wrapper
361
377
  synthesizeSpeech,
362
378
  (userId, client) => resolveDisplayName(userId, client),
363
379
  undefined, // botUserId — resolved by Bolt
@@ -461,6 +477,10 @@ export async function startAgent(
461
477
 
462
478
  if (runtimeClient && agentId) {
463
479
  let configNotFoundLogged = false;
480
+ const voiceEnvBaseline: Record<string, string | undefined> = {
481
+ ...process.env,
482
+ };
483
+ let bundleVoiceKeys = new Set<string>();
464
484
 
465
485
  async function syncConfig() {
466
486
  if (!runtimeClient || !agentId) return;
@@ -472,6 +492,16 @@ export async function startAgent(
472
492
  // federation the Anthropic credential keys never reach process.env,
473
493
  // even if a stale admin still serves them.
474
494
  const changed = applyEnvToProcess(bundle.env);
495
+ changed.push(
496
+ ...revertRemovedVoiceEnv(
497
+ bundleVoiceKeys,
498
+ bundle.env,
499
+ voiceEnvBaseline,
500
+ ),
501
+ );
502
+ bundleVoiceKeys = new Set(
503
+ VOICE_ENV_KEYS.filter((k) => k in bundle.env),
504
+ );
475
505
  if (changed.length > 0) {
476
506
  console.log(`[config-sync] updated: ${changed.join(", ")}`);
477
507
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-runtime",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -40,8 +40,8 @@
40
40
  "./workspace-paths": "./workspace-paths.ts"
41
41
  },
42
42
  "dependencies": {
43
- "@app-vitals/agent-admin": "0.7.0",
44
- "@app-vitals/agent-lib": "0.7.0",
43
+ "@app-vitals/agent-admin": "0.8.0",
44
+ "@app-vitals/agent-lib": "0.8.0",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",
package/voice.ts CHANGED
@@ -16,6 +16,56 @@ export interface VoiceConfig {
16
16
  whisperServiceUrl?: string;
17
17
  }
18
18
 
19
+ /** Env vars that make up VoiceConfig — the keys per-agent env may set/remove live. */
20
+ export const VOICE_ENV_KEYS = [
21
+ "GROQ_API_KEY",
22
+ "ELEVENLABS_API_KEY",
23
+ "ELEVENLABS_VOICE_ID",
24
+ "PIPER_VOICE",
25
+ "WHISPER_SERVICE_URL",
26
+ ] as const;
27
+
28
+ /**
29
+ * Resolve VoiceConfig from the live process env (COS-62.1). Per-agent env only
30
+ * reaches process.env via syncConfig()'s applyEnvToProcess after boot, so the
31
+ * boot-time `config.voice` snapshot can never contain it — mirrors
32
+ * buildSlackAppConfig() in boot.ts. Empty strings count as unset.
33
+ */
34
+ export function buildVoiceConfig(
35
+ env: Record<string, string | undefined> = process.env,
36
+ ): VoiceConfig {
37
+ return {
38
+ groqApiKey: env.GROQ_API_KEY || undefined,
39
+ elevenLabsApiKey: env.ELEVENLABS_API_KEY || undefined,
40
+ voiceId: env.ELEVENLABS_VOICE_ID || undefined,
41
+ piperVoice: env.PIPER_VOICE || undefined,
42
+ whisperServiceUrl: env.WHISPER_SERVICE_URL || undefined,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * applyEnvToProcess only ever sets keys, so a voice key removed from the
48
+ * per-agent bundle would linger in process.env until restart. Restores each
49
+ * previously bundle-supplied voice key that is now absent to its pre-sync
50
+ * value (`baseline`: the pod env, e.g. fleet-wide admin.extraEnv) or deletes
51
+ * it. Returns the keys reverted.
52
+ */
53
+ export function revertRemovedVoiceEnv(
54
+ previousBundleKeys: ReadonlySet<string>,
55
+ bundleEnv: Record<string, string>,
56
+ baseline: Record<string, string | undefined>,
57
+ processEnv: Record<string, string | undefined> = process.env,
58
+ ): string[] {
59
+ const reverted: string[] = [];
60
+ for (const key of VOICE_ENV_KEYS) {
61
+ if (!previousBundleKeys.has(key) || key in bundleEnv) continue;
62
+ if (baseline[key] === undefined) delete processEnv[key];
63
+ else processEnv[key] = baseline[key];
64
+ reverted.push(key);
65
+ }
66
+ return reverted;
67
+ }
68
+
19
69
  // DI seam for the whisper-svc HTTP client — replaces spawnFn from the old local-Whisper path
20
70
  export type WhisperClientFn = (
21
71
  audioBuffer: Buffer,
@@ -203,6 +253,7 @@ export async function synthesizeSpeech(
203
253
  mkdirSync(VOICE_DIR, { recursive: true });
204
254
 
205
255
  const elevenKey = voiceConfig.elevenLabsApiKey;
256
+ console.log(`[voice] TTS backend: ${elevenKey ? "elevenlabs" : "piper"}`);
206
257
  if (elevenKey) {
207
258
  return synthesizeElevenLabs(
208
259
  text,
@@ -18,7 +18,11 @@
18
18
  */
19
19
 
20
20
  import { describe, expect, mock, test } from "bun:test";
21
- import { makeWhisperSvcClient } from "./voice.ts";
21
+ import {
22
+ buildVoiceConfig,
23
+ makeWhisperSvcClient,
24
+ revertRemovedVoiceEnv,
25
+ } from "./voice.ts";
22
26
 
23
27
  describe("makeWhisperSvcClient — onerahmet /asr contract", () => {
24
28
  test("POSTs to the /asr endpoint of the service URL", async () => {
@@ -103,3 +107,72 @@ describe("makeWhisperSvcClient — onerahmet /asr contract", () => {
103
107
  expect(result).toBeNull();
104
108
  });
105
109
  });
110
+
111
+ describe("buildVoiceConfig — live env (COS-62.1)", () => {
112
+ test("picks up ELEVENLABS_* set after an earlier build (env mutation)", () => {
113
+ const env: Record<string, string | undefined> = {};
114
+ expect(buildVoiceConfig(env).elevenLabsApiKey).toBeUndefined();
115
+
116
+ env.ELEVENLABS_API_KEY = "xi-key";
117
+ env.ELEVENLABS_VOICE_ID = "nPczCjzI2devNBz1zQrb";
118
+ const cfg = buildVoiceConfig(env);
119
+ expect(cfg.elevenLabsApiKey).toBe("xi-key");
120
+ expect(cfg.voiceId).toBe("nPczCjzI2devNBz1zQrb");
121
+ });
122
+
123
+ test("falls back to unset once the key is removed or empty", () => {
124
+ const env: Record<string, string | undefined> = { ELEVENLABS_API_KEY: "k" };
125
+ delete env.ELEVENLABS_API_KEY;
126
+ expect(buildVoiceConfig(env).elevenLabsApiKey).toBeUndefined();
127
+ env.ELEVENLABS_API_KEY = "";
128
+ expect(buildVoiceConfig(env).elevenLabsApiKey).toBeUndefined();
129
+ });
130
+ });
131
+
132
+ describe("revertRemovedVoiceEnv", () => {
133
+ test("deletes a removed per-agent key with no pod baseline", () => {
134
+ const proc: Record<string, string | undefined> = {
135
+ ELEVENLABS_API_KEY: "k",
136
+ ELEVENLABS_VOICE_ID: "v",
137
+ };
138
+ const reverted = revertRemovedVoiceEnv(
139
+ new Set(["ELEVENLABS_API_KEY", "ELEVENLABS_VOICE_ID"]),
140
+ {},
141
+ {},
142
+ proc,
143
+ );
144
+ expect(reverted.sort()).toEqual([
145
+ "ELEVENLABS_API_KEY",
146
+ "ELEVENLABS_VOICE_ID",
147
+ ]);
148
+ expect(proc.ELEVENLABS_API_KEY).toBeUndefined();
149
+ });
150
+
151
+ test("restores the fleet/pod value when the per-agent override is removed", () => {
152
+ const proc: Record<string, string | undefined> = {
153
+ ELEVENLABS_API_KEY: "agent",
154
+ };
155
+ revertRemovedVoiceEnv(
156
+ new Set(["ELEVENLABS_API_KEY"]),
157
+ {},
158
+ { ELEVENLABS_API_KEY: "fleet" },
159
+ proc,
160
+ );
161
+ expect(proc.ELEVENLABS_API_KEY).toBe("fleet");
162
+ });
163
+
164
+ test("leaves keys alone that are still in the bundle or never came from it", () => {
165
+ const proc: Record<string, string | undefined> = {
166
+ ELEVENLABS_API_KEY: "a",
167
+ GROQ_API_KEY: "pod",
168
+ };
169
+ const reverted = revertRemovedVoiceEnv(
170
+ new Set(["ELEVENLABS_API_KEY"]),
171
+ { ELEVENLABS_API_KEY: "a" },
172
+ {},
173
+ proc,
174
+ );
175
+ expect(reverted).toEqual([]);
176
+ expect(proc.GROQ_API_KEY).toBe("pod");
177
+ });
178
+ });