@jc_stack/ez-agents 0.1.0-beta.38 → 0.1.0-beta.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/.env.example CHANGED
@@ -19,6 +19,8 @@ EZ_EXECUTOR_CLI=codex
19
19
 
20
20
  # Optional: Audio transcription (STT) and voice synthesis (TTS)
21
21
  # GEMINI_API_KEY=
22
+ # EZ_SPEECH_VOICE_EN=Kore
23
+ # EZ_SPEECH_VOICE_ES=Kore
22
24
  # OPENAI_API_KEY=
23
25
 
24
26
  # Optional: OpenRouter API key for OpenCode CLI (Nemotron 3.5 Lightning)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.40
4
+
5
+ - Apply eligible npm releases under each agent's saved automatic update policy
6
+ through the existing guarded updater. Queue one update at a time, preserve
7
+ manual policies, and wait for a newer version after a failed automatic update.
8
+
9
+ ## 0.1.0-beta.39
10
+
11
+ - Render completed, binding-scoped application replies as WAV speech with
12
+ configurable English and Spanish voices, while retaining Ogg/Opus for Telegram.
13
+ Recheck the original application binding after synthesis and report depleted
14
+ speech credits without provider details.
15
+ - Preserve live delivery sockets during upgrade backups and restart Linux host
16
+ services after a successful core replacement.
17
+
3
18
  ## 0.1.0-beta.38
4
19
 
5
20
  - Remove the retired Antigravity CLI integration and its unused engine paths.
@@ -10,6 +10,24 @@ Run admission, status and inbox receipts include the immutable `preset` captured
10
10
  for that run so an application can label delivered messages with the engine choice
11
11
  that actually produced them.
12
12
 
13
+ ## Speech playback
14
+
15
+ `POST /v1/runs/<id>/speech` renders the last delivered text reply of a completed
16
+ application run as `audio/wav` (24 kHz mono). It uses the same binding/owner
17
+ authorization as run readback, rechecked after synthesis, and accepts no client
18
+ text or provider credentials. Replies are limited to 8,000 characters. An
19
+ unfinished run returns 409, missing speech configuration 503, and provider
20
+ failure 502. Depleted Gemini credits return 503 with
21
+ `code: speech_credits_depleted`. Audio responses have `Cache-Control: no-store`.
22
+
23
+ Configure `GEMINI_API_KEY` on the relay. The existing speech renderer uses
24
+ `gemini-3.8-flash-lite-tts`; Telegram still receives Ogg Opus. Applications should
25
+ send `{"language":"en"}` or `{"language":"es"}` to select
26
+ `EZ_SPEECH_VOICE_EN` or `EZ_SPEECH_VOICE_ES` (both default to Kore). Telegram
27
+ voice notes use the English voice setting. Applications should retain the returned
28
+ audio for local replay; each POST generates fresh audio.
29
+ Speech is a rendering operation and does not start another agent turn.
30
+
13
31
  ## Shared runtime controls
14
32
 
15
33
  Register with `--share-owner` (the existing `--share-telegram` spelling is an
@@ -24,7 +24,7 @@ Environment=EZ_DEPLOYMENT_DIR=/absolute/private/agents/family
24
24
  Environment=PATH=/absolute/node/bin:/absolute/package-manager/bin:/absolute/cli/bin:/usr/local/bin:/usr/bin:/bin
25
25
  EnvironmentFile=-%h/.config/ez/family-provider.env
26
26
  ExecStart=/absolute/ez-package/package/bin/ezenciel-agents-host
27
- Restart=on-failure
27
+ Restart=always
28
28
  RestartSec=5
29
29
 
30
30
  [Install]
package/docs/upgrades.md CHANGED
@@ -12,12 +12,13 @@ automatic channel for core and plugins without a saved policy. Existing explicit
12
12
  stable or manual policies are preserved. The owner may select either per target.
13
13
  The main target and installed plugins version independently.
14
14
 
15
- The agent owns release review, policy decisions and communication. The host
16
- supervisor owns interruption-safe replacement. There is one host service per
17
- agent, not a second agent or an additional updater daemon. Its replaceable child
18
- runs the normal CLI transport. It checks npm every six hours while running and
19
- records automatic-channel changes; discovery never wakes the agent or injects
20
- wrapper state into a session. Updates stay owner-invoked through `ez updates`.
15
+ The owner chooses the saved update policy. The host supervisor owns
16
+ interruption-safe replacement. There is one host service per agent, not a second
17
+ agent or an additional updater daemon. Its replaceable child runs the normal CLI
18
+ transport. It checks npm every six hours while running and queues one eligible
19
+ release at a time when the policy is automatic. Discovery and replacement never
20
+ wake the agent or inject wrapper state into a session. Manual policies retain
21
+ owner-invoked updates through `ez updates`.
21
22
  Checks use the installed scoped npm identity; failures are
22
23
  visible in `updates check` and private `tools/updates/available.json`. Plugins marked
23
24
  `private: true` in their package metadata are reported as local-source updates
@@ -265,5 +266,7 @@ execution authority.
265
266
 
266
267
  Update discovery runs independently of active host work. A discovery failure is
267
268
  logged locally and retried at the next regular six-hour check; it does not stop
268
- the host or create a repair task. Inspect `ez updates check` for target diagnostics.
269
+ the host or create a repair task. An automatically failed or rolled-back version
270
+ is not retried until a newer version appears or the owner explicitly retries.
271
+ Inspect `ez updates check` and `ez updates status` for target diagnostics.
269
272
  Actual update transactions retain their existing admission, drain and rollback rules.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.38",
3
+ "version": "0.1.0-beta.40",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
@@ -11,6 +11,7 @@ import { MAX_INCOMING_ATTACHMENT_BYTES, stageChatAttachment } from './files.js'
11
11
  import { assertEffort } from './model-policy.js'
12
12
  import { ApprovalStore } from './approval.js'
13
13
  import { Tasks } from './tasks.js'
14
+ import { SpeechCreditsDepletedError } from './audio.js'
14
15
 
15
16
  const hash = (value: string) => createHash('sha256').update(value).digest('hex')
16
17
  export const applicationScope = (bindingId: string, scope: string) => hash(JSON.stringify([bindingId, scope]))
@@ -74,6 +75,7 @@ export class ApplicationChannel {
74
75
  controlDir: string; workspace?: string; initial: AiPreset
75
76
  wake: () => void
76
77
  cancel: (id: string) => Promise<void>
78
+ speech?: (text: string, language: 'en' | 'es') => Promise<{ buffer: Buffer; mimeType: string }>
77
79
  createTelegramPairing?: (bindingId: string, owner: Owner) => Promise<{ connected: true } | { connected: false; url: string; expiresAt: string }>
78
80
  telegramAvailable?: () => boolean
79
81
  aiControls?: {
@@ -385,6 +387,31 @@ export class ApplicationChannel {
385
387
  const admitted = await this.runs.attachTelegramApplication(target.id, application)
386
388
  send(200, { id: admitted.id, scope, status: admitted.status }); return
387
389
  }
390
+ const speechMatch = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})\/speech$/)
391
+ if (speechMatch && request.method === 'POST') {
392
+ const chunks: Buffer[] = []; let size = 0
393
+ for await (const chunk of request) { size += chunk.length; if (size > 128) throw new Error('Invalid speech request'); chunks.push(chunk) }
394
+ const input = Buffer.concat(chunks).toString('utf8').trim()
395
+ const language = input ? (JSON.parse(input) as { language?: unknown }).language : 'en'
396
+ if (language !== 'en' && language !== 'es') { send(400, { error: 'Invalid speech language' }); return }
397
+ const snapshot = await this.snapshot(binding.bindingId, speechMatch[1])
398
+ if (snapshot.status !== 'completed') { send(409, { error: 'Speech requires a completed reply' }); return }
399
+ const text = snapshot.messages.at(-1)?.text?.trim()
400
+ if (!text || text.length > 8000) { send(400, { error: 'Speech requires a reply of 1–8000 characters' }); return }
401
+ if (!this.options.speech) { send(503, { error: 'Speech is not configured' }); return }
402
+ let audio: { buffer: Buffer; mimeType: string }
403
+ try { audio = await this.options.speech(text, language) }
404
+ catch (error) {
405
+ if (error instanceof SpeechCreditsDepletedError) { send(503, { error: 'Speech credits are depleted', code: 'speech_credits_depleted' }); return }
406
+ send(502, { error: 'Speech generation failed' }); return
407
+ }
408
+ // A binding can be revoked while the provider is generating audio.
409
+ const currentBinding = await this.bindings.authenticate(request.headers.authorization!.slice(7))
410
+ if (currentBinding.bindingId !== binding.bindingId) throw new Error('Application authority revoked')
411
+ await this.snapshot(binding.bindingId, speechMatch[1])
412
+ response.writeHead(200, { 'content-type': audio.mimeType, 'cache-control': 'no-store' })
413
+ response.end(audio.buffer); return
414
+ }
388
415
  const match = path.match(/^\/v1\/runs\/(r_(?:app|schedule)_[a-f0-9]{64})(\/cancel)?$/)
389
416
  if (match && ((!match[2] && request.method === 'GET') || (match[2] && request.method === 'POST'))) {
390
417
  await this.snapshot(binding.bindingId, match[1])
package/src/audio.ts CHANGED
@@ -7,6 +7,10 @@ export type AudioOptions = {
7
7
  voice?: string
8
8
  }
9
9
 
10
+ export class SpeechCreditsDepletedError extends Error {
11
+ constructor() { super('Gemini speech credits are depleted') }
12
+ }
13
+
10
14
  export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer => {
11
15
  const header = Buffer.alloc(44)
12
16
  const byteRate = sampleRate * channels * 2
@@ -135,7 +139,7 @@ export const transcribeAudio = async (
135
139
 
136
140
  export const synthesizeSpeech = async (
137
141
  text: string,
138
- options: AudioOptions = {},
142
+ options: AudioOptions & { format?: 'wav' | 'ogg' } = {},
139
143
  ): Promise<{ buffer: Buffer; mimeType: string }> => {
140
144
  const trimmed = text.trim()
141
145
  if (!trimmed) throw new Error('Cannot synthesize speech for empty text')
@@ -143,7 +147,7 @@ export const synthesizeSpeech = async (
143
147
  const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
144
148
  if (geminiKey) {
145
149
  const url =
146
- 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent'
150
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.8-flash-lite-tts:generateContent'
147
151
  const voiceName = options.voice || 'Kore'
148
152
  const response = await fetch(url, {
149
153
  method: 'POST',
@@ -153,14 +157,15 @@ export const synthesizeSpeech = async (
153
157
  contents: [{ parts: [{ text: trimmed }] }],
154
158
  generationConfig: {
155
159
  responseModalities: ['AUDIO'],
156
- speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName } } },
160
+ responseFormat: { audio: { mimeType: 'AUDIO_L16', sampleRate: 24000 } },
161
+ speechConfig: { voiceConfig: { voice: voiceName } },
157
162
  },
158
163
  }),
159
164
  })
160
165
 
161
166
  if (!response.ok) {
162
- const err = await response.text().catch(() => '')
163
- throw new Error(`Gemini speech synthesis error ${response.status}: ${err}`)
167
+ if (response.status === 402) throw new SpeechCreditsDepletedError()
168
+ throw new Error(`Gemini speech synthesis error ${response.status}`)
164
169
  }
165
170
 
166
171
  const payload = (await response.json()) as any
@@ -173,6 +178,9 @@ export const synthesizeSpeech = async (
173
178
  throw new Error(`Gemini returned no audio data (${candidate?.finishReason || 'no candidate'})`)
174
179
 
175
180
  const rawPcm = Buffer.from(inline.data, 'base64')
181
+ if (!/^audio\/l16(?:;|$)/i.test(inline.mimeType) || !rawPcm.length || rawPcm.length % 2)
182
+ throw new Error('Gemini returned an unsupported speech format')
183
+ if (options.format === 'wav') return { buffer: pcmToWav(rawPcm), mimeType: 'audio/wav' }
176
184
  const encoded = await encodeOggOpus(rawPcm, 24000, 1)
177
185
  return {
178
186
  buffer: encoded,
package/src/config.ts CHANGED
@@ -25,6 +25,8 @@ export type Config = ControlConfig & {
25
25
  applicationPort?: number
26
26
  applicationHost?: string
27
27
  geminiApiKey?: string
28
+ speechVoiceEn?: string
29
+ speechVoiceEs?: string
28
30
  openaiApiKey?: string
29
31
  }
30
32
 
@@ -84,6 +86,8 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
84
86
  channelBackendUrl: env.EZ_CHANNEL_BACKEND_URL?.trim(),
85
87
  channelBackendToken: env.EZ_CHANNEL_BACKEND_TOKEN?.trim(),
86
88
  geminiApiKey: env.GEMINI_API_KEY?.trim(),
89
+ speechVoiceEn: env.EZ_SPEECH_VOICE_EN?.trim(),
90
+ speechVoiceEs: env.EZ_SPEECH_VOICE_ES?.trim(),
87
91
  openaiApiKey: env.OPENAI_API_KEY?.trim(),
88
92
  }
89
93
  }
package/src/index.ts CHANGED
@@ -103,6 +103,11 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
103
103
  const applicationChannel = new ApplicationChannel({
104
104
  controlDir: config.controlDir, workspace: config.workspace, initial: aiMenu.initial,
105
105
  aiControls: aiMenu,
106
+ speech: config.geminiApiKey ? (text, language) => synthesizeSpeech(text, {
107
+ geminiApiKey: config.geminiApiKey,
108
+ voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
109
+ format: 'wav',
110
+ }) : undefined,
106
111
  wake: () => { void drainSources().catch(error => console.error('Application queue unavailable', safeError(error))) },
107
112
  createTelegramPairing: async (bindingId, owner) => {
108
113
  const username = bot?.botInfo?.username
@@ -581,6 +586,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
581
586
  const { buffer } = await synthesizeSpeech(item.voiceText, {
582
587
  geminiApiKey: config.geminiApiKey,
583
588
  openaiApiKey: config.openaiApiKey,
589
+ voice: config.speechVoiceEn,
584
590
  })
585
591
  await paceSend()
586
592
  await authorizeChannelDelivery()
@@ -31,6 +31,9 @@ export function execute(command,args,options={}) {
31
31
  export async function textAtomic(file,text) {
32
32
  const tmp=file+'.update.tmp';await fs.writeFile(tmp,text,{mode:0o600});await fs.rename(tmp,file);
33
33
  }
34
+ export async function backupStateDirectory(source,destination) {
35
+ await fs.cp(source,destination,{recursive:true,filter:async entry=>!(await fs.lstat(entry)).isSocket()});
36
+ }
34
37
  export const pluginArgs = record => ['compose','--project-name',record.project,'--file',record.compose];
35
38
  export async function packageManager(root,run=execute) {
36
39
  const {packageManager:required}=await read(path.join(root,'package.json'));
@@ -116,7 +119,7 @@ export async function perform(home,job,hooks) {
116
119
  job.rollback={env:envValue(oldEnv,'EZ_RELAY_IMAGE',oldImage),packageRoot:config.packageRoot};await save();
117
120
  await run('docker',[...relayArgs(config),'stop','relay']);await hooks.stopHost();
118
121
  const backup=path.join(dir,'backup');await fs.mkdir(backup,{mode:0o700});
119
- for(const name of ['mind','control'])await fs.cp(path.join(config.deploymentDir,name),path.join(backup,name),{recursive:true});
122
+ for(const name of ['mind','control'])await backupStateDirectory(path.join(config.deploymentDir,name),path.join(backup,name));
120
123
  for(const name of ['docker.env','host-executor.json','agent.json','purpose.md','relay.env'])await fs.copyFile(path.join(config.deploymentDir,name),path.join(backup,name));
121
124
  let env=oldEnv.split(job.previousRoot+path.sep).join(root+path.sep);env=envValue(env,'EZ_RELAY_IMAGE',image);
122
125
  await textAtomic(envFile,env);
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { atomic, locked } from '../plugins/manager.mjs';
6
6
  import { callDeliverySocket } from '../delivery-socket-client.mjs';
7
- import { state, read, jobs, jobPath, check, missing, cleanupStaleBackups } from './control.mjs';
7
+ import { state, read, jobs, jobPath, check, prepare, submit, missing, cleanupStaleBackups } from './control.mjs';
8
8
  import { perform, environment } from './runtime.mjs';
9
9
 
10
10
  const reservedProviderKeys=new Set(['HOME','LANG','LC_ALL','LOGNAME','PATH','SHELL','TERM','TMPDIR','USER','CODEX_HOME','NODE_OPTIONS']);
@@ -28,6 +28,22 @@ export async function idle(control) {
28
28
  const status=await callDeliverySocket(path.join(control,'delivery.sock'),{op:'status'},2000).catch(()=>null);
29
29
  return !status||status.running===0;
30
30
  }
31
+ export async function queueAutomatic(home,available) {
32
+ const existing=await jobs(home);
33
+ if(existing.some(job=>['queued','applying','recovery-required'].includes(job.status)))return null;
34
+ for(const candidate of available) {
35
+ if(!candidate.newer||!candidate.policy?.automatic||!candidate.available)continue;
36
+ // A failed release needs a newer version or an explicit owner retry.
37
+ if(existing.some(job=>job.target===candidate.target&&job.version===candidate.available&&['failed','rolled-back'].includes(job.status)))continue;
38
+ try {
39
+ const job=await prepare(home,candidate.target,{release:candidate.available});
40
+ return await submit(home,job.id,true);
41
+ }catch {
42
+ console.error(`Automatic update of ${candidate.target} failed; inspect ez updates check/status.`);
43
+ }
44
+ }
45
+ return null;
46
+ }
31
47
  export async function supervise(deployment,signal,{discover=check}={}) {
32
48
  const host=await read(path.join(deployment,'host-executor.json'));
33
49
  if(host.agents.length!==1||!host.agents[0].toolsHome)throw Error('Initialize a single deployment plugin registry before starting the supervisor');
@@ -96,12 +112,16 @@ export async function supervise(deployment,signal,{discover=check}={}) {
96
112
  const latest=await read(path.join(jobPath(home,pending.id),'job.json'));
97
113
  if(latest.status==='queued'){latest.status='failed';latest.error=error.message;await atomic(path.join(jobPath(home,pending.id),'job.json'),latest);}else throw error;
98
114
  } finally {await fs.rm(pause,{force:true});}
99
- if(result?.status==='completed'&&pending.target==='main')return;
115
+ if(result?.status==='completed') {
116
+ if(pending.target==='main')return;
117
+ nextCheck=0;
118
+ }
100
119
  }
101
120
  if(Date.now()>=nextCheck) {
102
121
  nextCheck=Date.now()+6*60*60*1000;
103
122
  try {
104
123
  const results=await discover(home);await atomic(path.join(directory,'available.json'),results);
124
+ await queueAutomatic(home,results);
105
125
  } catch {
106
126
  // Discovery is optional; its failure must not terminate the host.
107
127
  // Do not expose registry response bodies or credentials in logs.
@@ -100,6 +100,58 @@ test('HTTP auth, idempotency, origin/context isolation, revocation and persisted
100
100
  assert.equal((await request('/v1/runs',secondToken,input)).status,401)
101
101
  })
102
102
 
103
+ test('speech renders only a completed bound reply and rechecks authority after generation', async t => {
104
+ const root = await mkdtemp(join(tmpdir(), 'ez-app-speech-'))
105
+ const owned = await owner(root), runs = new RunStore(root)
106
+ const firstToken = token(), secondToken = token()
107
+ const spoken: string[] = []
108
+ let revoke = false, reassign = false
109
+ const channel = new ApplicationChannel({
110
+ controlDir: root, initial: initialPreset('codex'), wake: () => {}, cancel: async () => {},
111
+ speech: async text => {
112
+ spoken.push(text)
113
+ if (revoke) await channel.bindings.register('first', null, owned)
114
+ if (reassign) {
115
+ await channel.bindings.register('first', token(), owned, false, true)
116
+ await channel.bindings.register('second', firstToken, owned, false, true)
117
+ }
118
+ return { buffer: Buffer.from('audio-fixture'), mimeType: 'audio/wav' }
119
+ },
120
+ })
121
+ t.after(async () => { await channel.stop(); await rm(root, { recursive: true, force: true }) })
122
+ const first = (await channel.bindings.register('first', firstToken, owned))!
123
+ await channel.bindings.register('second', secondToken, owned)
124
+ const run = await channel.submit(first.bindingId, { requestId: 'speech', scope: 'exercise', text: 'Explain this' })
125
+ const address = await channel.listen(0) as { port: number }
126
+ const request = (bearer: string) => fetch(`http://127.0.0.1:${address.port}/v1/runs/${run.id}/speech`, {
127
+ method: 'POST', headers: { Authorization: `Bearer ${bearer}` },
128
+ })
129
+ assert.equal((await request(secondToken)).status, 404)
130
+ assert.equal((await request(firstToken)).status, 409)
131
+ assert.deepEqual(spoken, [])
132
+ for (const text of ['Progress', 'Keep the movement controlled.']) {
133
+ const item = await runs.enqueueMessage(run.id, text)
134
+ await runs.claimOutbox(item.id)
135
+ await channel.deliver(run, item)
136
+ }
137
+ await runs.patch(run.id, { status: 'completed' })
138
+ const audio = await request(firstToken)
139
+ assert.equal(audio.status, 200)
140
+ assert.equal(audio.headers.get('content-type'), 'audio/wav')
141
+ assert.equal(audio.headers.get('cache-control'), 'no-store')
142
+ assert.equal(await audio.text(), 'audio-fixture')
143
+ assert.deepEqual(spoken, ['Keep the movement controlled.'])
144
+ reassign = true
145
+ const beforeReassign = spoken.length
146
+ assert.notEqual((await request(firstToken)).status, 200)
147
+ assert.equal(spoken.length, beforeReassign + 1)
148
+ reassign = false
149
+ await channel.bindings.register('second', secondToken, owned, false, true)
150
+ await channel.bindings.register('first', firstToken, owned, false, true)
151
+ revoke = true
152
+ assert.notEqual((await request(firstToken)).status, 200)
153
+ })
154
+
103
155
  test('owner replacement invalidates binding and client secrets never enter native environment',async t=>{
104
156
  const root=await mkdtemp(join(tmpdir(),'ez-app-owner-'))
105
157
  t.after(()=>rm(root,{recursive:true,force:true}))
@@ -1,6 +1,11 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech } from '../src/audio.js'
3
+ import { pcmToWav, encodeOggOpus, transcribeAudio, synthesizeSpeech, SpeechCreditsDepletedError } from '../src/audio.js'
4
+
5
+ test('speech reports depleted credits without exposing provider details', async (t) => {
6
+ t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 402 }))
7
+ await assert.rejects(synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key' }), SpeechCreditsDepletedError)
8
+ })
4
9
 
5
10
  test('speech provider failure stays a failure, never a text or WAV success', async (t) => {
6
11
  t.mock.method(
@@ -13,9 +18,15 @@ test('speech provider failure stays a failure, never a text or WAV success', asy
13
18
  })
14
19
 
15
20
  test('speech finds audio beyond the first response part and keeps credentials out of URLs', async (t) => {
21
+ let calls = 0
16
22
  t.mock.method(globalThis, 'fetch', async (url: string, options: RequestInit) => {
23
+ calls += 1
24
+ assert.match(url, /gemini-3\.8-flash-lite-tts:generateContent$/)
17
25
  assert.equal(url.includes('fixture-key'), false)
18
26
  assert.equal(new Headers(options.headers).get('x-goog-api-key'), 'fixture-key')
27
+ const config = JSON.parse(String(options.body)).generationConfig
28
+ assert.deepEqual(config.responseFormat, { audio: { mimeType: 'AUDIO_L16', sampleRate: 24000 } })
29
+ assert.deepEqual(config.speechConfig.voiceConfig, { voice: calls === 3 ? 'voice_r3yhwvxvihxg' : 'Kore' })
19
30
  return new Response(
20
31
  JSON.stringify({
21
32
  candidates: [
@@ -39,6 +50,11 @@ test('speech finds audio beyond the first response part and keeps credentials ou
39
50
  const speech = await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key' })
40
51
  assert.equal(speech.mimeType, 'audio/ogg')
41
52
  assert.equal(speech.buffer.subarray(0, 4).toString(), 'OggS')
53
+ const wav = await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key', format: 'wav' })
54
+ assert.equal(wav.mimeType, 'audio/wav')
55
+ assert.equal(wav.buffer.subarray(0, 4).toString(), 'RIFF')
56
+ assert.equal(wav.buffer.length, 24044)
57
+ await synthesizeSpeech('Fixture', { geminiApiKey: 'fixture-key', voice: 'voice_r3yhwvxvihxg', format: 'wav' })
42
58
  })
43
59
 
44
60
  test('pcmToWav generates a valid 44-byte WAV header', () => {
@@ -5,16 +5,30 @@ import path from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { gzipSync } from 'node:zlib';
7
7
  import { spawn, execFile } from 'node:child_process';
8
+ import { createServer } from 'node:net';
8
9
  import { promisify } from 'node:util';
9
10
  import { extract, digest, version, newer, compatible } from '../src/updates/artifact.mjs';
10
11
  import { prepare, submit, command, read, jobPath, eligibility, jobs, cleanupStaleBackups, reviewedPluginDeploymentMigration } from '../src/updates/control.mjs';
11
- import { perform, environment, packageManager } from '../src/updates/runtime.mjs';
12
+ import { perform, environment, packageManager, backupStateDirectory } from '../src/updates/runtime.mjs';
12
13
  import { atomic, snapshot, compose, prepareCommand } from '../src/plugins/manager.mjs';
13
14
  import { bindUpdates } from '../src/updates/binding.mjs';
14
15
  import { status as runtimeStatus } from '../src/updates/status.mjs';
15
- import { providerEnvironment } from '../src/updates/supervisor.mjs';
16
+ import { providerEnvironment, queueAutomatic } from '../src/updates/supervisor.mjs';
16
17
  const exec=promisify(execFile);
17
18
 
19
+ test('main upgrade backup omits a live plugin socket and keeps private files',async t=>{
20
+ const root=await fs.mkdtemp('/tmp/ez-upgrade-socket-');
21
+ t.after(()=>fs.rm(root,{recursive:true,force:true}));
22
+ const source=path.join(root,'control'),destination=path.join(root,'backup');
23
+ await fs.mkdir(source);await fs.writeFile(path.join(source,'state.json'),'private state');
24
+ const socket=path.join(source,'plugin-broker.sock'),server=createServer();
25
+ await new Promise((resolve,reject)=>server.once('error',reject).listen(socket,resolve));
26
+ t.after(()=>new Promise(resolve=>server.close(resolve)));
27
+ await backupStateDirectory(source,destination);
28
+ assert.equal(await fs.readFile(path.join(destination,'state.json'),'utf8'),'private state');
29
+ assert.deepEqual(await fs.readdir(destination),['state.json']);
30
+ });
31
+
18
32
  test('supervisor forwards only provider keys declared by the agent installation',()=>{
19
33
  const host={agents:[{codexProviders:[{envKey:'OPENROUTER_API_KEY'}]}]};
20
34
  assert.deepEqual(providerEnvironment(host,{OPENROUTER_API_KEY:'provider-secret',TELEGRAM_BOT_TOKEN:'relay-secret'}),{OPENROUTER_API_KEY:'provider-secret'});
@@ -428,6 +442,29 @@ test('npm candidates verify exact version and integrity; automatic policy is enf
428
442
  pkg.version='0.1.2';await assert.rejects(prepare(f.home,'main',{release:'0.1.1'}),/version mismatch/);
429
443
  });
430
444
 
445
+ test('supervisor queues an eligible release once and preserves manual and failed-version gates',async t=>{
446
+ const f=await fixture(t),data=await fs.readFile(await f.pack());
447
+ const {createHash}=await import('node:crypto');
448
+ const pkg={name:'@ez-test/example',version:'0.1.1',dist:{tarball:'https://registry.npmjs.org/@ez-test/example/-/example-0.1.1.tgz',integrity:'sha512-'+createHash('sha512').update(data).digest('base64')}};
449
+ const original=globalThis.fetch;
450
+ globalThis.fetch=async url=>new Response(String(url).endsWith('.tgz')?data:JSON.stringify(String(url).endsWith('/0.1.1')?pkg:{name:pkg.name,'dist-tags':{latest:pkg.version},versions:{[pkg.version]:pkg}}));
451
+ t.after(()=>globalThis.fetch=original);
452
+ await atomic(path.join(f.home,'updates/supervisor.json'),{at:Date.now()});
453
+ await command(f.home,['policy','main','manual']);
454
+ assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
455
+ assert.deepEqual(await jobs(f.home),[]);
456
+ await command(f.home,['policy','main','beta']);
457
+ const queued=await queueAutomatic(f.home,await command(f.home,['check']));
458
+ assert.equal(queued.status,'queued');assert.equal(queued.automatic,true);
459
+ assert.equal(queued.origin.type,'npm');assert.equal(queued.sha256,digest(data));
460
+ assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
461
+ assert.equal((await jobs(f.home)).length,1);
462
+ queued.status='failed';await atomic(path.join(jobPath(f.home,queued.id),'job.json'),queued);
463
+ assert.equal(await queueAutomatic(f.home,await command(f.home,['check'])),null);
464
+ assert.equal((await jobs(f.home)).length,1);
465
+ await assert.rejects(fs.readFile(path.join(f.agent.controlDir,'update-attention.json')),error=>error.code==='ENOENT');
466
+ });
467
+
431
468
  test('update discovery follows latest while preserving legacy beta and stable-only policies',async t=>{
432
469
  const {registryCandidate}=await import('../src/updates/artifact.mjs');
433
470
  const name='@ez-test/example',pkg=v=>({name,version:v});