@jc_stack/ez-agents 0.1.0-beta.12
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/.dockerignore +24 -0
- package/.env.example +26 -0
- package/AGENTS.md +84 -0
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +73 -0
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/SECURITY.md +26 -0
- package/THIRD_PARTY_NOTICES.md +14 -0
- package/bin/ezenciel-agents +2 -0
- package/bin/ezenciel-agents-ai +2 -0
- package/bin/ezenciel-agents-ai.mjs +5 -0
- package/bin/ezenciel-agents-approval +2 -0
- package/bin/ezenciel-agents-approval.mjs +16 -0
- package/bin/ezenciel-agents-create +12 -0
- package/bin/ezenciel-agents-docker +6 -0
- package/bin/ezenciel-agents-host +5 -0
- package/bin/ezenciel-agents-install +2 -0
- package/bin/ezenciel-agents-message +2 -0
- package/bin/ezenciel-agents-message.mjs +16 -0
- package/bin/ezenciel-agents-owner +2 -0
- package/bin/ezenciel-agents-owner.mjs +18 -0
- package/bin/ezenciel-agents-react +2 -0
- package/bin/ezenciel-agents-react.mjs +16 -0
- package/bin/ezenciel-agents-setup.mjs +18 -0
- package/bin/ezenciel-agents-source +2 -0
- package/bin/ezenciel-agents-source.mjs +16 -0
- package/bin/ezenciel-agents-tools.mjs +3 -0
- package/bin/ezenciel-agents.mjs +37 -0
- package/compose.whatsapp.yaml +12 -0
- package/compose.yaml +40 -0
- package/default-plugins.json +1 -0
- package/docker/entrypoint.sh +15 -0
- package/docker/healthcheck.mjs +8 -0
- package/docker/plugin-smoke.mjs +48 -0
- package/docker/pnpm-lock.yaml +415 -0
- package/docker/recovery.ts +11 -0
- package/docker/run.ts +52 -0
- package/docker/smoke.mjs +47 -0
- package/docker/status-smoke.mjs +30 -0
- package/docker/upgrade-smoke.mjs +58 -0
- package/docs/architecture/ai-selection.md +37 -0
- package/docs/architecture/authority-boundaries.md +14 -0
- package/docs/architecture/event-sources.md +34 -0
- package/docs/architecture/telegram-intake.md +29 -0
- package/docs/development-and-testing.md +18 -0
- package/docs/docker-runtime.md +118 -0
- package/docs/host-service.md +80 -0
- package/docs/plugin-contributions.md +34 -0
- package/docs/plugins.md +181 -0
- package/docs/releasing.md +71 -0
- package/docs/setup.md +234 -0
- package/docs/upgrades.md +193 -0
- package/package.json +106 -0
- package/scripts/assert-local-registry.mjs +22 -0
- package/scripts/release-check.mjs +14 -0
- package/scripts/smoke.ts +102 -0
- package/src/agent-install.ts +96 -0
- package/src/ai-cli.ts +22 -0
- package/src/ai.ts +88 -0
- package/src/approval-cli.ts +59 -0
- package/src/approval.ts +119 -0
- package/src/audio.ts +184 -0
- package/src/client-defaults.ts +101 -0
- package/src/config.ts +48 -0
- package/src/control-state.ts +350 -0
- package/src/desktop-bridge.ts +284 -0
- package/src/event-sources.ts +112 -0
- package/src/executor.ts +335 -0
- package/src/files.ts +75 -0
- package/src/format.ts +57 -0
- package/src/host-executor-client.ts +46 -0
- package/src/host-executor-protocol.ts +2 -0
- package/src/host-executor.ts +129 -0
- package/src/identity.ts +17 -0
- package/src/inbox.ts +171 -0
- package/src/index.ts +812 -0
- package/src/install-config.ts +56 -0
- package/src/install-tools.mjs +98 -0
- package/src/menu.ts +123 -0
- package/src/message-send.ts +57 -0
- package/src/message.ts +68 -0
- package/src/owner-args.ts +4 -0
- package/src/owner.ts +30 -0
- package/src/plugins/manager.mjs +272 -0
- package/src/react.ts +33 -0
- package/src/reaction.ts +32 -0
- package/src/read-request.ts +72 -0
- package/src/reply.ts +13 -0
- package/src/runs.ts +445 -0
- package/src/service.ts +28 -0
- package/src/setup.ts +180 -0
- package/src/software-status.ts +23 -0
- package/src/source-cli.ts +18 -0
- package/src/update-attention.ts +18 -0
- package/src/updates/artifact.mjs +83 -0
- package/src/updates/binding.mjs +27 -0
- package/src/updates/control.mjs +131 -0
- package/src/updates/launch.mjs +13 -0
- package/src/updates/runtime.mjs +140 -0
- package/src/updates/status.mjs +49 -0
- package/src/updates/supervisor.mjs +102 -0
- package/src/version.ts +4 -0
- package/src/workspace.ts +32 -0
- package/templates/agent/AGENTS.md +49 -0
- package/templates/agent/SOUL.md +11 -0
- package/templates/agent/TOOLS.md +46 -0
- package/templates/agent/USER.md +5 -0
- package/templates/updates.md +45 -0
- package/test/agent-install.test.ts +48 -0
- package/test/ai-cli.test.ts +28 -0
- package/test/ai.test.ts +105 -0
- package/test/approval.test.ts +40 -0
- package/test/audio.test.ts +77 -0
- package/test/client-defaults.test.ts +64 -0
- package/test/codex-context.test.ts +33 -0
- package/test/config.test.ts +32 -0
- package/test/control-state.test.ts +58 -0
- package/test/desktop-bridge.test.ts +159 -0
- package/test/docker-runtime.test.ts +23 -0
- package/test/event-sources.test.ts +113 -0
- package/test/executor.test.ts +135 -0
- package/test/files.test.ts +50 -0
- package/test/format.test.ts +41 -0
- package/test/host-executor.test.ts +149 -0
- package/test/inbox-burst.test.ts +66 -0
- package/test/inbox.test.ts +102 -0
- package/test/install-config.test.ts +75 -0
- package/test/install-tools.test.mjs +59 -0
- package/test/intake-relay.test.ts +337 -0
- package/test/owner-help.test.mjs +10 -0
- package/test/plugin-manager.test.mjs +157 -0
- package/test/publish-guard.test.ts +21 -0
- package/test/reaction.test.ts +122 -0
- package/test/read-request.test.ts +134 -0
- package/test/relay.test.ts +254 -0
- package/test/release-entrypoints.test.mjs +26 -0
- package/test/runs.test.ts +116 -0
- package/test/security.test.ts +85 -0
- package/test/setup.test.ts +68 -0
- package/test/software-status.test.ts +31 -0
- package/test/update-attention.test.ts +21 -0
- package/test/updates.test.mjs +282 -0
- package/test/upgrade-pause.test.ts +70 -0
- package/test/workspace.test.ts +80 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { loadControlConfig } from './config.js'
|
|
2
|
+
import { ApprovalStore } from './approval.js'
|
|
3
|
+
import { RunStore } from './runs.js'
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--')
|
|
6
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
7
|
+
console.log('Usage: ezenciel-agents-approval --prompt "..." --action-id "..." [--reply-to <id>]')
|
|
8
|
+
console.log(' or: ezenciel-agents-approval --check <action-id>')
|
|
9
|
+
process.exit(0)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let prompt: string | undefined
|
|
13
|
+
let actionId: string | undefined
|
|
14
|
+
let checkId: string | undefined
|
|
15
|
+
let replyTo: number | undefined
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < args.length; i++) {
|
|
18
|
+
if (args[i] === '--prompt' && args[i + 1]) {
|
|
19
|
+
prompt = args[++i]
|
|
20
|
+
} else if (args[i] === '--action-id' && args[i + 1]) {
|
|
21
|
+
actionId = args[++i]
|
|
22
|
+
} else if (args[i] === '--check' && args[i + 1]) {
|
|
23
|
+
checkId = args[++i]
|
|
24
|
+
} else if (args[i] === '--reply-to' && args[i + 1]) {
|
|
25
|
+
replyTo = parseInt(args[++i], 10)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const config = loadControlConfig()
|
|
30
|
+
const approvalStore = new ApprovalStore(config.controlDir)
|
|
31
|
+
|
|
32
|
+
if (checkId) {
|
|
33
|
+
const record = await approvalStore.getDecision(checkId)
|
|
34
|
+
console.log(JSON.stringify({
|
|
35
|
+
ok: true,
|
|
36
|
+
actionId: checkId,
|
|
37
|
+
decision: record?.decision || 'pending',
|
|
38
|
+
decidedAt: record?.decidedAt,
|
|
39
|
+
decidedBy: record?.decidedBy,
|
|
40
|
+
}))
|
|
41
|
+
process.exit(0)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!prompt || !actionId) {
|
|
45
|
+
console.error('Usage: ezenciel-agents-approval --prompt "..." --action-id "..." [--reply-to <id>]')
|
|
46
|
+
console.error(' or: ezenciel-agents-approval --check <action-id>')
|
|
47
|
+
process.exit(1)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const runId = process.env.EZ_RUN_ID?.trim()
|
|
51
|
+
if (!runId) {
|
|
52
|
+
console.error('EZ_RUN_ID is required to request approval')
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const runStore = new RunStore(config.controlDir)
|
|
57
|
+
await approvalStore.requestApproval(actionId, prompt, runId)
|
|
58
|
+
const item = await runStore.enqueueApproval(runId, prompt, actionId, { replyToMessageId: replyTo })
|
|
59
|
+
console.log(JSON.stringify({ ok: true, run: runId, actionId, outbox_id: item.id, status: 'requested' }))
|
package/src/approval.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { assertId } from './identity.js'
|
|
4
|
+
|
|
5
|
+
export type ApprovalDecision = 'pending' | 'approved' | 'denied'
|
|
6
|
+
|
|
7
|
+
export type ApprovalRecord = {
|
|
8
|
+
version: 1
|
|
9
|
+
actionId: string
|
|
10
|
+
runId?: string
|
|
11
|
+
prompt: string
|
|
12
|
+
decision: ApprovalDecision
|
|
13
|
+
createdAt: string
|
|
14
|
+
decidedAt?: string
|
|
15
|
+
decidedBy?: number
|
|
16
|
+
decisionUpdateId?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isApprovalRecord = (value: unknown): value is ApprovalRecord => {
|
|
20
|
+
if (!value || typeof value !== 'object') return false
|
|
21
|
+
const candidate = value as Partial<ApprovalRecord>
|
|
22
|
+
return (
|
|
23
|
+
candidate.version === 1 &&
|
|
24
|
+
typeof candidate.actionId === 'string' &&
|
|
25
|
+
/^[a-zA-Z0-9_-]{1,40}$/.test(candidate.actionId) &&
|
|
26
|
+
typeof candidate.prompt === 'string' &&
|
|
27
|
+
['pending', 'approved', 'denied'].includes(candidate.decision ?? '') &&
|
|
28
|
+
typeof candidate.createdAt === 'string' &&
|
|
29
|
+
Number.isFinite(Date.parse(candidate.createdAt))
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ApprovalStore {
|
|
34
|
+
private readonly approvalsDir: string
|
|
35
|
+
|
|
36
|
+
constructor(controlDir: string) {
|
|
37
|
+
this.approvalsDir = path.join(controlDir, 'approvals')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private async ensure(): Promise<void> {
|
|
41
|
+
await mkdir(this.approvalsDir, { recursive: true, mode: 0o700 })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private filePath(actionId: string): string {
|
|
45
|
+
const safe = assertId(actionId)
|
|
46
|
+
if (safe.length > 40) throw new Error('Approval identifier must be at most 40 characters')
|
|
47
|
+
return path.join(this.approvalsDir, `${safe}.json`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async requestApproval(actionId: string, prompt: string, runId?: string): Promise<ApprovalRecord> {
|
|
51
|
+
if (!prompt.trim()) throw new Error('Approval prompt is empty')
|
|
52
|
+
if (runId) assertId(runId)
|
|
53
|
+
await this.ensure()
|
|
54
|
+
const existing = await this.getDecision(actionId)
|
|
55
|
+
if (existing) {
|
|
56
|
+
if (existing.runId !== runId || existing.prompt !== prompt)
|
|
57
|
+
throw new Error('Approval ID already belongs to another request')
|
|
58
|
+
return existing
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const record: ApprovalRecord = {
|
|
62
|
+
version: 1,
|
|
63
|
+
actionId,
|
|
64
|
+
runId,
|
|
65
|
+
prompt,
|
|
66
|
+
decision: 'pending',
|
|
67
|
+
createdAt: new Date().toISOString(),
|
|
68
|
+
}
|
|
69
|
+
const file = this.filePath(actionId)
|
|
70
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
71
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
|
|
72
|
+
await rename(temporary, file)
|
|
73
|
+
return record
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async recordDecision(
|
|
77
|
+
actionId: string,
|
|
78
|
+
decision: 'approved' | 'denied',
|
|
79
|
+
decidedBy: number,
|
|
80
|
+
decisionUpdateId?: number,
|
|
81
|
+
): Promise<ApprovalRecord> {
|
|
82
|
+
await this.ensure()
|
|
83
|
+
const existing = await this.getDecision(actionId)
|
|
84
|
+
if (!existing) throw new Error('Unknown approval request')
|
|
85
|
+
if (
|
|
86
|
+
decisionUpdateId !== undefined &&
|
|
87
|
+
existing.decisionUpdateId === decisionUpdateId &&
|
|
88
|
+
existing.decidedBy === decidedBy &&
|
|
89
|
+
existing.decision === decision
|
|
90
|
+
)
|
|
91
|
+
return existing
|
|
92
|
+
if (existing.decision !== 'pending') throw new Error('Approval already decided')
|
|
93
|
+
if (Date.now() - Date.parse(existing.createdAt) > 900_000) throw new Error('Approval expired')
|
|
94
|
+
const record: ApprovalRecord = {
|
|
95
|
+
...existing,
|
|
96
|
+
decision,
|
|
97
|
+
decidedAt: new Date().toISOString(),
|
|
98
|
+
decidedBy,
|
|
99
|
+
decisionUpdateId,
|
|
100
|
+
}
|
|
101
|
+
const file = this.filePath(actionId)
|
|
102
|
+
const temporary = `${file}.${process.pid}.tmp`
|
|
103
|
+
await writeFile(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 })
|
|
104
|
+
await rename(temporary, file)
|
|
105
|
+
return record
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async getDecision(actionId: string): Promise<ApprovalRecord | null> {
|
|
109
|
+
try {
|
|
110
|
+
const parsed: unknown = JSON.parse(await readFile(this.filePath(actionId), 'utf8'))
|
|
111
|
+
if (!isApprovalRecord(parsed) || parsed.actionId !== actionId)
|
|
112
|
+
throw new Error('Invalid approval record shape')
|
|
113
|
+
return parsed
|
|
114
|
+
} catch (error: unknown) {
|
|
115
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
|
116
|
+
throw error
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/audio.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { readRequest } from './read-request.js'
|
|
3
|
+
|
|
4
|
+
export type AudioOptions = {
|
|
5
|
+
geminiApiKey?: string
|
|
6
|
+
openaiApiKey?: string
|
|
7
|
+
voice?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const pcmToWav = (pcm: Buffer, sampleRate = 24000, channels = 1): Buffer => {
|
|
11
|
+
const header = Buffer.alloc(44)
|
|
12
|
+
const byteRate = sampleRate * channels * 2
|
|
13
|
+
const blockAlign = channels * 2
|
|
14
|
+
header.write('RIFF', 0)
|
|
15
|
+
header.writeUInt32LE(36 + pcm.length, 4)
|
|
16
|
+
header.write('WAVE', 8)
|
|
17
|
+
header.write('fmt ', 12)
|
|
18
|
+
header.writeUInt32LE(16, 16)
|
|
19
|
+
header.writeUInt16LE(1, 20)
|
|
20
|
+
header.writeUInt16LE(channels, 22)
|
|
21
|
+
header.writeUInt32LE(sampleRate, 24)
|
|
22
|
+
header.writeUInt32LE(byteRate, 28)
|
|
23
|
+
header.writeUInt16LE(blockAlign, 32)
|
|
24
|
+
header.writeUInt16LE(16, 34)
|
|
25
|
+
header.write('data', 36)
|
|
26
|
+
header.writeUInt32LE(pcm.length, 40)
|
|
27
|
+
return Buffer.concat([header, pcm])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const encodeOggOpus = (pcm: Buffer, sampleRate = 24000, channels = 1): Promise<Buffer> => {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const child = spawn(
|
|
33
|
+
'ffmpeg',
|
|
34
|
+
[
|
|
35
|
+
'-f',
|
|
36
|
+
's16le',
|
|
37
|
+
'-ar',
|
|
38
|
+
String(sampleRate),
|
|
39
|
+
'-ac',
|
|
40
|
+
String(channels),
|
|
41
|
+
'-i',
|
|
42
|
+
'-',
|
|
43
|
+
'-c:a',
|
|
44
|
+
'libopus',
|
|
45
|
+
'-b:a',
|
|
46
|
+
'32k',
|
|
47
|
+
'-f',
|
|
48
|
+
'ogg',
|
|
49
|
+
'-',
|
|
50
|
+
],
|
|
51
|
+
{ stdio: ['pipe', 'pipe', 'pipe'] },
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const output: Buffer[] = []
|
|
55
|
+
let error = ''
|
|
56
|
+
|
|
57
|
+
child.stdout.on('data', (chunk) => output.push(chunk))
|
|
58
|
+
child.stderr.on('data', (chunk) => {
|
|
59
|
+
error = (error + chunk.toString()).slice(-2048)
|
|
60
|
+
})
|
|
61
|
+
child.stdin.on('error', reject)
|
|
62
|
+
|
|
63
|
+
child.on('error', reject)
|
|
64
|
+
|
|
65
|
+
child.on('close', (code) => {
|
|
66
|
+
if (code === 0 && output.length > 0) {
|
|
67
|
+
resolve(Buffer.concat(output))
|
|
68
|
+
} else {
|
|
69
|
+
reject(new Error(`Voice encoding failed (${code}): ${error}`))
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
child.stdin.end(pcm)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const transcribeAudio = async (
|
|
78
|
+
audioBytes: Buffer,
|
|
79
|
+
mimeType: string,
|
|
80
|
+
options: AudioOptions = {},
|
|
81
|
+
): Promise<string> => {
|
|
82
|
+
const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
|
|
83
|
+
if (geminiKey) {
|
|
84
|
+
const url = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3.6-flash:generateContent'
|
|
85
|
+
const payload = await readRequest(
|
|
86
|
+
'Gemini transcription',
|
|
87
|
+
url,
|
|
88
|
+
{
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'content-type': 'application/json', 'x-goog-api-key': geminiKey },
|
|
91
|
+
signal: AbortSignal.timeout(60_000),
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
contents: [
|
|
94
|
+
{
|
|
95
|
+
parts: [
|
|
96
|
+
{
|
|
97
|
+
text: 'Transcribe this audio faithfully. Return only the transcript, preserving its language.',
|
|
98
|
+
},
|
|
99
|
+
{ inlineData: { mimeType: mimeType || 'audio/ogg', data: audioBytes.toString('base64') } },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
(response) => response.json(),
|
|
106
|
+
)
|
|
107
|
+
const transcript = payload?.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
|
|
108
|
+
if (!transcript) throw new Error('Gemini returned an empty transcript')
|
|
109
|
+
return transcript
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const openaiKey = options.openaiApiKey?.trim() || process.env.OPENAI_API_KEY?.trim()
|
|
113
|
+
if (openaiKey) {
|
|
114
|
+
const form = new FormData()
|
|
115
|
+
form.set('model', 'whisper-1')
|
|
116
|
+
form.set('response_format', 'text')
|
|
117
|
+
form.set('file', new Blob([new Uint8Array(audioBytes)], { type: mimeType || 'audio/ogg' }), 'audio.ogg')
|
|
118
|
+
|
|
119
|
+
const transcript = await readRequest(
|
|
120
|
+
'OpenAI transcription',
|
|
121
|
+
'https://api.openai.com/v1/audio/transcriptions',
|
|
122
|
+
{
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { authorization: `Bearer ${openaiKey}` },
|
|
125
|
+
body: form,
|
|
126
|
+
},
|
|
127
|
+
(response) => response.text(),
|
|
128
|
+
)
|
|
129
|
+
if (!transcript.trim()) throw new Error('OpenAI returned an empty transcript')
|
|
130
|
+
return transcript.trim()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
throw new Error('No audio transcription API key configured (set GEMINI_API_KEY or OPENAI_API_KEY)')
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const synthesizeSpeech = async (
|
|
137
|
+
text: string,
|
|
138
|
+
options: AudioOptions = {},
|
|
139
|
+
): Promise<{ buffer: Buffer; mimeType: string }> => {
|
|
140
|
+
const trimmed = text.trim()
|
|
141
|
+
if (!trimmed) throw new Error('Cannot synthesize speech for empty text')
|
|
142
|
+
|
|
143
|
+
const geminiKey = (options.geminiApiKey ?? process.env.GEMINI_API_KEY)?.trim()
|
|
144
|
+
if (geminiKey) {
|
|
145
|
+
const url =
|
|
146
|
+
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-tts:generateContent'
|
|
147
|
+
const voiceName = options.voice || 'Kore'
|
|
148
|
+
const response = await fetch(url, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'content-type': 'application/json', 'x-goog-api-key': geminiKey },
|
|
151
|
+
signal: AbortSignal.timeout(60_000),
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
contents: [{ parts: [{ text: trimmed }] }],
|
|
154
|
+
generationConfig: {
|
|
155
|
+
responseModalities: ['AUDIO'],
|
|
156
|
+
speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName } } },
|
|
157
|
+
},
|
|
158
|
+
}),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
if (!response.ok) {
|
|
162
|
+
const err = await response.text().catch(() => '')
|
|
163
|
+
throw new Error(`Gemini speech synthesis error ${response.status}: ${err}`)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const payload = (await response.json()) as any
|
|
167
|
+
const candidate = payload?.candidates?.[0]
|
|
168
|
+
const inline = candidate?.content?.parts?.find(
|
|
169
|
+
(part: { inlineData?: { data?: string; mimeType?: string } }) =>
|
|
170
|
+
part.inlineData?.mimeType?.startsWith('audio/') && part.inlineData?.data,
|
|
171
|
+
)?.inlineData
|
|
172
|
+
if (!inline?.data)
|
|
173
|
+
throw new Error(`Gemini returned no audio data (${candidate?.finishReason || 'no candidate'})`)
|
|
174
|
+
|
|
175
|
+
const rawPcm = Buffer.from(inline.data, 'base64')
|
|
176
|
+
const encoded = await encodeOggOpus(rawPcm, 24000, 1)
|
|
177
|
+
return {
|
|
178
|
+
buffer: encoded,
|
|
179
|
+
mimeType: 'audio/ogg',
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new Error('No speech synthesis API key configured (set GEMINI_API_KEY)')
|
|
184
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { createInterface } from 'node:readline'
|
|
4
|
+
import { readFile } from 'node:fs/promises'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { installed, type AiPreset } from './ai.js'
|
|
9
|
+
import { executorEnvironment } from './executor.js'
|
|
10
|
+
|
|
11
|
+
const record = (v: unknown): Record<string, unknown> =>
|
|
12
|
+
v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
|
|
13
|
+
const value = (v: unknown): string | undefined =>
|
|
14
|
+
typeof v === 'string' && /^[a-zA-Z0-9_./:-]{1,160}$/.test(v) ? v : undefined
|
|
15
|
+
const command = async (cli: string, args: string[], cwd: string): Promise<string> =>
|
|
16
|
+
(await promisify(execFile)(cli, args, { cwd, env: executorEnvironment(), timeout: 8000, maxBuffer: 2 * 1024 * 1024 })).stdout
|
|
17
|
+
|
|
18
|
+
// Native config/read resolves Codex's layers; do not reimplement TOML or start a turn.
|
|
19
|
+
export const codexDefaults = (cwd: string): Promise<Record<string, unknown>> => new Promise((resolve) => {
|
|
20
|
+
const child = spawn('codex', ['app-server'], { cwd, env: executorEnvironment(), stdio: ['pipe', 'pipe', 'ignore'] })
|
|
21
|
+
const lines = createInterface({ input: child.stdout })
|
|
22
|
+
let done = false
|
|
23
|
+
const finish = (config: Record<string, unknown> = {}) => {
|
|
24
|
+
if (done) return
|
|
25
|
+
done = true
|
|
26
|
+
clearTimeout(timer); lines.close(); child.stdin.end(); child.kill(); resolve(config)
|
|
27
|
+
}
|
|
28
|
+
const timer = setTimeout(() => finish(), 8000)
|
|
29
|
+
const send = (message: unknown) => child.stdin.write(`${JSON.stringify(message)}\n`)
|
|
30
|
+
child.on('error', () => finish())
|
|
31
|
+
child.stdin.on('error', () => finish())
|
|
32
|
+
child.on('close', () => finish())
|
|
33
|
+
lines.on('line', (line) => {
|
|
34
|
+
try {
|
|
35
|
+
const message = JSON.parse(line)
|
|
36
|
+
if (message.id === 0 && message.result) {
|
|
37
|
+
send({ method: 'initialized', params: {} })
|
|
38
|
+
send({ id: 1, method: 'config/read', params: { includeLayers: false, cwd } })
|
|
39
|
+
} else if (message.id === 1) {
|
|
40
|
+
const config = record(message.result?.config)
|
|
41
|
+
const managed = record(record(config.models).new_thread)
|
|
42
|
+
finish({ model: managed.model ?? config.model,
|
|
43
|
+
effort: managed.model_reasoning_effort ?? config.model_reasoning_effort })
|
|
44
|
+
}
|
|
45
|
+
} catch { finish() }
|
|
46
|
+
})
|
|
47
|
+
send({ id: 0, method: 'initialize', params: { clientInfo: { name: 'ez_defaults', version: '1' } } })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Only the two simple, documented Grok [models] strings are read. Unsupported
|
|
51
|
+
// syntax stays unspecified so the native client resolves it, rather than guessing.
|
|
52
|
+
export const grokSettings = (text: string): { model?: string; effort?: string } => {
|
|
53
|
+
const section = (text.split(/^\[models\][ \t]*\r?$/m)[1] || '').split(/^\[/m)[0]
|
|
54
|
+
const get = (key: string) => value(section.match(new RegExp(`^\\s*${key}\\s*=\\s*["']([^"'\\r\\n]+)["']\\s*(?:#.*)?$`, 'm'))?.[1])
|
|
55
|
+
return { model: get('default'), effort: get('default_reasoning_effort') }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const discoverDefaults = async (cwd: string, options: {
|
|
59
|
+
home?: string; available?: typeof installed; run?: typeof command; codex?: typeof codexDefaults
|
|
60
|
+
} = {}): Promise<AiPreset[]> => {
|
|
61
|
+
const home = options.home ?? homedir()
|
|
62
|
+
const available = options.available ?? installed
|
|
63
|
+
const run = options.run ?? command
|
|
64
|
+
const text = async (file: string) => { try { return await readFile(file, 'utf8') } catch { return '' } }
|
|
65
|
+
const json = async (file: string) => { try { return record(JSON.parse(await text(file))) } catch { return {} } }
|
|
66
|
+
const clients = ['grok', 'codex', 'claude', 'opencode']
|
|
67
|
+
const results = await Promise.all(clients.map(async (cli): Promise<AiPreset | undefined> => {
|
|
68
|
+
if (!(await available(cli))) return
|
|
69
|
+
let model: string | undefined, effort: string | undefined
|
|
70
|
+
try {
|
|
71
|
+
if (cli === 'grok') {
|
|
72
|
+
const settings = grokSettings(await text(join(home, '.grok/config.toml')))
|
|
73
|
+
model = settings.model ?? value((await run(cli, ['models'], cwd)).match(/^Default model:\s*(\S+)/m)?.[1])
|
|
74
|
+
effort = settings.effort
|
|
75
|
+
} else if (cli === 'codex') {
|
|
76
|
+
const config = await (options.codex ?? codexDefaults)(cwd)
|
|
77
|
+
model = value(config.model); effort = value(config.effort)
|
|
78
|
+
} else if (cli === 'claude') {
|
|
79
|
+
// Match Claude's documented user -> project -> local settings precedence.
|
|
80
|
+
const settings = Object.assign({}, await json(join(home, '.claude/settings.json')),
|
|
81
|
+
await json(join(cwd, '.claude/settings.json')), await json(join(cwd, '.claude/settings.local.json')))
|
|
82
|
+
model = value(settings.model); effort = value(settings.effortLevel)
|
|
83
|
+
} else if (cli === 'opencode') {
|
|
84
|
+
const config = record(JSON.parse(await run(cli, ['debug', 'config'], cwd)))
|
|
85
|
+
model = value(config.model)
|
|
86
|
+
}
|
|
87
|
+
} catch { /* Unavailable metadata: expose client default, never invent values. */ }
|
|
88
|
+
const id = 'detected_' + createHash('sha256').update(JSON.stringify([cli, model, effort])).digest('hex').slice(0, 12)
|
|
89
|
+
return { id, cli, model, effort,
|
|
90
|
+
name: `${cli} · ${model || 'client default'}${effort ? ` · ${effort}` : ''}`.slice(0, 80) }
|
|
91
|
+
}))
|
|
92
|
+
const discovered = results.filter((p): p is AiPreset => Boolean(p))
|
|
93
|
+
if (await available('codex-gui')) {
|
|
94
|
+
const source = discovered.find((preset) => preset.cli === 'codex')
|
|
95
|
+
const model = source?.model, effort = source?.effort
|
|
96
|
+
const id = 'detected_' + createHash('sha256').update(JSON.stringify(['codex-gui', model, effort])).digest('hex').slice(0, 12)
|
|
97
|
+
discovered.push({ id, cli: 'codex-gui', model, effort,
|
|
98
|
+
name: `codex-gui · ${model || 'desktop'}${effort ? ` · ${effort}` : ''}`.slice(0, 80) })
|
|
99
|
+
}
|
|
100
|
+
return discovered
|
|
101
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
|
|
4
|
+
export type ControlConfig = {
|
|
5
|
+
controlDir: string
|
|
6
|
+
pairingTtlMs: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type Config = ControlConfig & {
|
|
10
|
+
telegramBotToken: string
|
|
11
|
+
workspace: string
|
|
12
|
+
executorTimeoutMs: number
|
|
13
|
+
executorCli: string
|
|
14
|
+
geminiApiKey?: string
|
|
15
|
+
openaiApiKey?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const positiveInteger = (value: string | undefined, name: string, fallback: number): number => {
|
|
19
|
+
if (!value) return fallback
|
|
20
|
+
const parsed = Number(value)
|
|
21
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
22
|
+
throw new Error(`${name} must be a positive integer`)
|
|
23
|
+
}
|
|
24
|
+
return parsed
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const loadControlConfig = (env: NodeJS.ProcessEnv = process.env): ControlConfig => {
|
|
28
|
+
const stateHome = env.XDG_STATE_HOME?.trim() || path.join(homedir(), '.local', 'state')
|
|
29
|
+
return {
|
|
30
|
+
controlDir: path.resolve(env.EZ_CONTROL_DIR?.trim() || path.join(stateHome, 'ezenciel-agents')),
|
|
31
|
+
pairingTtlMs: positiveInteger(env.EZ_PAIRING_TTL_SECONDS, 'EZ_PAIRING_TTL_SECONDS', 900) * 1_000,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => {
|
|
36
|
+
const telegramBotToken = env.TELEGRAM_BOT_TOKEN?.trim()
|
|
37
|
+
if (!telegramBotToken) throw new Error('TELEGRAM_BOT_TOKEN is required')
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...loadControlConfig(env),
|
|
41
|
+
telegramBotToken,
|
|
42
|
+
workspace: path.resolve(env.EZ_AGENT_WORKSPACE?.trim() || './agent'),
|
|
43
|
+
executorTimeoutMs: positiveInteger(env.EZ_EXECUTOR_TIMEOUT_SECONDS, 'EZ_EXECUTOR_TIMEOUT_SECONDS', 300) * 1_000,
|
|
44
|
+
executorCli: env.EZ_EXECUTOR_CLI?.trim() || 'agy',
|
|
45
|
+
geminiApiKey: env.GEMINI_API_KEY?.trim(),
|
|
46
|
+
openaiApiKey: env.OPENAI_API_KEY?.trim(),
|
|
47
|
+
}
|
|
48
|
+
}
|