@jc_stack/ez-agents 0.1.0-beta.38 → 0.1.0-beta.39
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 +2 -0
- package/CHANGELOG.md +9 -0
- package/docs/application-channel.md +18 -0
- package/docs/host-service.md +1 -1
- package/package.json +1 -1
- package/src/application-channel.ts +27 -0
- package/src/audio.ts +13 -5
- package/src/config.ts +4 -0
- package/src/index.ts +6 -0
- package/src/updates/runtime.mjs +4 -1
- package/test/application-channel.test.ts +52 -0
- package/test/audio.test.ts +17 -1
- package/test/updates.test.mjs +15 -1
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,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.39
|
|
4
|
+
|
|
5
|
+
- Render completed, binding-scoped application replies as WAV speech with
|
|
6
|
+
configurable English and Spanish voices, while retaining Ogg/Opus for Telegram.
|
|
7
|
+
Recheck the original application binding after synthesis and report depleted
|
|
8
|
+
speech credits without provider details.
|
|
9
|
+
- Preserve live delivery sockets during upgrade backups and restart Linux host
|
|
10
|
+
services after a successful core replacement.
|
|
11
|
+
|
|
3
12
|
## 0.1.0-beta.38
|
|
4
13
|
|
|
5
14
|
- 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
|
package/docs/host-service.md
CHANGED
|
@@ -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=
|
|
27
|
+
Restart=always
|
|
28
28
|
RestartSec=5
|
|
29
29
|
|
|
30
30
|
[Install]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jc_stack/ez-agents",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.39",
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
throw new Error(`Gemini speech synthesis error ${response.status}
|
|
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()
|
package/src/updates/runtime.mjs
CHANGED
|
@@ -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
|
|
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);
|
|
@@ -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}))
|
package/test/audio.test.ts
CHANGED
|
@@ -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', () => {
|
package/test/updates.test.mjs
CHANGED
|
@@ -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
16
|
import { providerEnvironment } 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'});
|