@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,56 @@
|
|
|
1
|
+
import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { parseEnv } from 'node:util'
|
|
5
|
+
import { initializeWorkspace } from './workspace.js'
|
|
6
|
+
import { resolveExecutor } from './executor.js'
|
|
7
|
+
|
|
8
|
+
const inside = (parent: string, child: string) => {
|
|
9
|
+
const relative = path.relative(parent, child)
|
|
10
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Configuration only: no provider calls, owner approval, service start, or AI turn.
|
|
14
|
+
export const configureInstallation = async (directory: string, cli: string, token?: string) => {
|
|
15
|
+
let executor: string
|
|
16
|
+
try { executor = resolveExecutor(cli).name }
|
|
17
|
+
catch { throw new Error('Unsupported executor. See ezenciel-agents-setup status.') }
|
|
18
|
+
if (token !== undefined && !/^\d{5,}:[A-Za-z0-9_-]{20,}$/.test(token))
|
|
19
|
+
throw new Error('Invalid Telegram bot token. Supply the BotFather token through stdin.')
|
|
20
|
+
const root = path.resolve(directory)
|
|
21
|
+
const envFile = path.join(root, '.env')
|
|
22
|
+
let original = ''
|
|
23
|
+
try {
|
|
24
|
+
if (!(await lstat(envFile)).isFile()) throw new Error('.env must be a regular file')
|
|
25
|
+
original = await readFile(envFile, 'utf8')
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
28
|
+
}
|
|
29
|
+
const values = parseEnv(original)
|
|
30
|
+
const workspace = path.resolve(root, values.EZ_AGENT_WORKSPACE?.trim() || 'agent')
|
|
31
|
+
const controlDir = path.resolve(root, values.EZ_CONTROL_DIR?.trim() || 'control')
|
|
32
|
+
if (inside(workspace, envFile) || inside(workspace, controlDir) || inside(controlDir, workspace))
|
|
33
|
+
throw new Error('Keep the mind, control directory, and .env separate.')
|
|
34
|
+
const configured = {
|
|
35
|
+
...values,
|
|
36
|
+
EZ_AGENT_WORKSPACE: workspace,
|
|
37
|
+
EZ_CONTROL_DIR: controlDir,
|
|
38
|
+
EZ_EXECUTOR_CLI: executor,
|
|
39
|
+
TELEGRAM_BOT_TOKEN: token ?? values.TELEGRAM_BOT_TOKEN ?? '',
|
|
40
|
+
}
|
|
41
|
+
// Literal dotenv values; do not shell-source this file. Preserve unknown keys.
|
|
42
|
+
const content = Object.entries(configured).map(([key, value]) => {
|
|
43
|
+
const quote = ['"', "'"].find(q => !value.includes(q))
|
|
44
|
+
if (!quote) throw new Error('Existing .env has a value requiring manual quoting; it was not changed.')
|
|
45
|
+
return `${key}=${quote}${value}${quote}\n`
|
|
46
|
+
}).join('')
|
|
47
|
+
const created = await initializeWorkspace(workspace)
|
|
48
|
+
const temporary = `${envFile}.${randomUUID()}.tmp`
|
|
49
|
+
try {
|
|
50
|
+
await writeFile(temporary, content, { mode: 0o600, flag: 'wx' })
|
|
51
|
+
await rename(temporary, envFile)
|
|
52
|
+
} finally { await rm(temporary, { force: true }) }
|
|
53
|
+
return { workspace, controlDir, envFile, executor, created,
|
|
54
|
+
tokenConfigured: Boolean(configured.TELEGRAM_BOT_TOKEN),
|
|
55
|
+
next: configured.TELEGRAM_BOT_TOKEN ? 'verify executor tools, start relay, and pair owner' : 'connect Telegram with the owner\u2019s BotFather token' }
|
|
56
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const root=fileURLToPath(new URL('../',import.meta.url));
|
|
9
|
+
const read=async file=>JSON.parse(await fs.readFile(file,'utf8'));
|
|
10
|
+
const absent=e=>{if(e.code!=='ENOENT')throw e;return null;};
|
|
11
|
+
export const hostEnvironment=()=>Object.fromEntries(['HOME','PATH','LANG','LC_ALL','TMPDIR','DOCKER_HOST','DOCKER_CONTEXT','DOCKER_CONFIG','BUILDX_CONFIG'].filter(k=>process.env[k]!==undefined).map(k=>[k,process.env[k]]));
|
|
12
|
+
async function atomic(file,value) {
|
|
13
|
+
const tmp=file+'.'+randomUUID()+'.tmp';await fs.writeFile(tmp,JSON.stringify(value)+'\n',{mode:0o600,flag:'wx'});await fs.rename(tmp,file);
|
|
14
|
+
}
|
|
15
|
+
export function run(command,args,{cwd,log,timeout=15000}={}) {
|
|
16
|
+
return new Promise((resolve,reject)=>{
|
|
17
|
+
const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='';
|
|
18
|
+
for(const stream of [child.stdout,child.stderr])stream?.on('data',b=>output=(output+b).slice(-200000));
|
|
19
|
+
const timer=setTimeout(()=>child.kill('SIGTERM'),timeout);
|
|
20
|
+
child.once('error',e=>{clearTimeout(timer);reject(e);});
|
|
21
|
+
child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve(output.trim());});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export const defaultHome=()=>path.join(process.env.XDG_DATA_HOME||path.join(homedir(),'.local/share'),'ez');
|
|
25
|
+
export async function executable(value) {
|
|
26
|
+
if(!value||value.includes('\0'))throw Error('Supply the selected executor name or absolute path');
|
|
27
|
+
const candidates=path.isAbsolute(value)?[value]:value.includes('/')?[]:(process.env.PATH||'').split(path.delimiter).map(p=>path.join(p,value));
|
|
28
|
+
for(const candidate of candidates)try{await fs.access(candidate,fs.constants.X_OK);if((await fs.stat(candidate)).isFile())return path.resolve(candidate);}catch(e){if(!['ENOENT','EACCES'].includes(e.code))throw e;}
|
|
29
|
+
throw Error('Selected executor is not executable');
|
|
30
|
+
}
|
|
31
|
+
export async function preflight({home=defaultHome(),executor},invoke=run) {
|
|
32
|
+
const checks=[];
|
|
33
|
+
const check=async(name,fn)=>{try{checks.push({name,ok:true,...await fn()});}catch(e){checks.push({name,ok:false,error:e.message});}};
|
|
34
|
+
await check('node',async()=>{if(Number(process.versions.node.split('.')[0])<22)throw Error('Provision Node 22+ on the host');return {version:process.versions.node};});
|
|
35
|
+
const pkg=await read(path.join(root,'package.json'));
|
|
36
|
+
await check('pnpm',async()=>{const expected=pkg.packageManager.split('@').at(-1),actual=await invoke('pnpm',['--version']);if(actual!==expected)throw Error(`Provision pnpm ${expected}; found ${actual}`);return {version:actual};});
|
|
37
|
+
await check('docker',async()=>({version:await invoke('docker',['version','--format','{{.Server.Version}}'])}));
|
|
38
|
+
await check('compose',async()=>({version:await invoke('docker',['compose','version','--short'])}));
|
|
39
|
+
await check('disk',async()=>{let parent=path.resolve(home);while(!(await fs.stat(parent).catch(absent))){const next=path.dirname(parent);if(next===parent)throw Error('No existing installation parent');parent=next;}const s=await fs.statfs(parent),freeBytes=s.bavail*s.bsize;return {path:parent,freeBytes,note:'Host filesystem only; inspect Docker storage separately. Required space depends on image cache and plugins.'};});
|
|
40
|
+
await check('executor',async()=>{const binary=await executable(executor);return {path:binary,command:path.basename(binary),version:await invoke(binary,['--version']),sandboxVerified:false,note:'Validate identity and a harmless tool call under the actual service environment before registering the supported executor key.'};});
|
|
41
|
+
return {ok:checks.every(c=>c.ok),home:path.resolve(home),packages:path.join(path.resolve(home),'packages'),agents:path.join(path.resolve(home),'agents'),checks};
|
|
42
|
+
}
|
|
43
|
+
export async function installationStatus(deployment) {
|
|
44
|
+
if(!path.isAbsolute(deployment||''))throw Error('Supply --deployment with an absolute path');
|
|
45
|
+
const control=path.join(deployment,'control');
|
|
46
|
+
const exists=async f=>Boolean(await fs.stat(path.join(deployment,f)).catch(absent));
|
|
47
|
+
const configured=(await Promise.all(['agent.json','host-executor.json','docker.env','relay.env'].map(exists))).every(Boolean);
|
|
48
|
+
const owner=(await read(path.join(control,'control-state.json')).catch(absent))?.owner;
|
|
49
|
+
const paired=Boolean(owner&&Number.isSafeInteger(owner.telegramUserId)&&owner.telegramUserId>0&&Number.isSafeInteger(owner.telegramChatId)&&owner.telegramChatId>0&&Number.isFinite(Date.parse(owner.pairedAt)));
|
|
50
|
+
const relay=await read(path.join(control,'heartbeat.json')).catch(absent),host=await read(path.join(control,'host-executor/heartbeat.json')).catch(absent);
|
|
51
|
+
const fresh=(h,ms)=>Boolean(h&&Number.isFinite(h.at)&&h.at<=Date.now()+1000&&Date.now()-h.at<ms);
|
|
52
|
+
const runtimeReady=Boolean(relay?.polling&&fresh(relay,20000)&&fresh(host,15000));
|
|
53
|
+
let reply=null;
|
|
54
|
+
if(paired)for(const name of await fs.readdir(path.join(control,'outbox')).catch(e=>{if(e.code==='ENOENT')return [];throw e;})) {
|
|
55
|
+
if(!name.endsWith('.sent.json'))continue;
|
|
56
|
+
const item=await read(path.join(control,'outbox',name));
|
|
57
|
+
if(!/^tg_\d+$/.test(item.runId||'')||item.chatId!==owner.telegramChatId||(item.type&&item.type!=='message')||!Array.isArray(item.receipt?.messageIds)||!item.receipt.messageIds.length||!item.receipt.messageIds.every(n=>Number.isSafeInteger(n)&&n>0))continue;
|
|
58
|
+
const delivered=Date.parse(item.receipt.deliveredAt);if(!Number.isFinite(delivered)||delivered<Date.parse(owner.pairedAt)||delivered>Date.now())continue;
|
|
59
|
+
const r=await read(path.join(control,'runs',item.runId+'.json')).catch(absent);
|
|
60
|
+
if(r?.status==='completed'&&!r.external&&r.chatId===owner.telegramChatId&&r.telegramUserId===owner.telegramUserId&&(!reply||delivered>Date.parse(reply.deliveredAt)))reply={runId:item.runId,messageIds:item.receipt.messageIds,deliveredAt:item.receipt.deliveredAt};
|
|
61
|
+
}
|
|
62
|
+
return {deployment,configured,runtimeReady,ownerPaired:paired,telegramReplyVerified:Boolean(reply),reply,
|
|
63
|
+
stage:!configured?'not-configured':!runtimeReady?'runtime-offline':!paired?'awaiting-owner':!reply?'awaiting-telegram-reply':'ready-for-telegram-plugin-request',
|
|
64
|
+
note:'Read-only: a saved receipt is historical delivery evidence, not a fresh live probe or proof of reboot persistence. Request plugins through the working Telegram conversation.'};
|
|
65
|
+
}
|
|
66
|
+
async function fingerprintOf(source,invoke) {
|
|
67
|
+
const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
|
|
68
|
+
const hash=createHash('sha256');
|
|
69
|
+
for(const f of listing[0].files){const file=path.resolve(source,f.path);if(!file.startsWith(source+path.sep))throw Error('Invalid package path');const data=await fs.readFile(file);hash.update(JSON.stringify([f.path,f.mode,data.length]));hash.update(data);}
|
|
70
|
+
return hash.digest('hex');
|
|
71
|
+
}
|
|
72
|
+
export async function build({home=defaultHome(),source=root},invoke=run) {
|
|
73
|
+
source=await fs.realpath(source);
|
|
74
|
+
const fingerprint=await fingerprintOf(source,invoke),image='ezenciel-agents:install-'+fingerprint.slice(0,24);
|
|
75
|
+
const dir=path.join(path.resolve(home),'builds',fingerprint);await fs.mkdir(dir,{recursive:true,mode:0o700});
|
|
76
|
+
const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
|
|
77
|
+
let handle;
|
|
78
|
+
try{handle=await fs.open(lock,'wx',0o600);}catch(e){if(e.code!=='EEXIST')throw e;return {state:'busy-or-interrupted',image,log,note:'An existing build owns this artifact. Inspect its status/log and owning process; do not start another build or remove a live lock.'};}
|
|
79
|
+
try {
|
|
80
|
+
await handle.writeFile(JSON.stringify({pid:process.pid,source,image}));
|
|
81
|
+
const prior=await read(receipt).catch(absent);
|
|
82
|
+
if(prior?.state==='completed')try{if(await invoke('docker',['image','inspect','--format','{{.Id}}',image])===prior.imageId)return {...prior,reused:true};}catch{/* Image was removed; rebuild under the same exclusive lock. */}
|
|
83
|
+
await atomic(receipt,{state:'building',pid:process.pid,source,image,log});
|
|
84
|
+
console.error(JSON.stringify({state:'building',image,log}));
|
|
85
|
+
const output=await fs.open(log,'a',0o600);
|
|
86
|
+
try{await invoke('docker',['build','--progress','plain','--target','runtime','-t',image,source],{log:output.fd,timeout:3600000});}finally{await output.close();}
|
|
87
|
+
if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
|
|
88
|
+
const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log};await atomic(receipt,result);return result;
|
|
89
|
+
}catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message});throw e;}
|
|
90
|
+
finally{await handle.close();await fs.rm(lock);}
|
|
91
|
+
}
|
|
92
|
+
export async function main(args) {
|
|
93
|
+
const [action,...rest]=args;const options={};
|
|
94
|
+
if(!action||action==='--help'){console.log(JSON.stringify({commands:['preflight --executor <name-or-absolute-path> [--home PATH]','build [--home PATH]','status --deployment PATH'],note:'Host prerequisites and main-agent diagnostics only. Initialize an empty registry; verify Telegram before asking the installed agent to add plugins.'}));return;}
|
|
95
|
+
for(let i=0;i<rest.length;i+=2){if(!['--home','--executor','--deployment'].includes(rest[i])||!rest[i+1]||Object.hasOwn(options,rest[i].slice(2)))throw Error('Invalid arguments');options[rest[i].slice(2)]=rest[i+1];}
|
|
96
|
+
const allowed={preflight:['home','executor'],build:['home'],status:['deployment']};if(!allowed[action]||Object.keys(options).some(k=>!allowed[action].includes(k)))throw Error('Invalid action/options');
|
|
97
|
+
const result=action==='preflight'?await preflight(options):action==='build'?await build(options):await installationStatus(options.deployment);console.log(JSON.stringify(result));if(result.ok===false)process.exitCode=1;
|
|
98
|
+
}
|
package/src/menu.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { randomBytes } from 'node:crypto'
|
|
4
|
+
import { InlineKeyboard, type Context } from 'grammy'
|
|
5
|
+
import { ControlStore } from './control-state.js'
|
|
6
|
+
import { initialPreset, presetLabel, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
7
|
+
import { discoverDefaults } from './client-defaults.js'
|
|
8
|
+
|
|
9
|
+
export const mainCommands = [
|
|
10
|
+
{ command: 'new', description: 'New conversation' },
|
|
11
|
+
{ command: 'ai', description: 'Choose AI' },
|
|
12
|
+
{ command: 'status', description: 'Work status' },
|
|
13
|
+
{ command: 'settings', description: 'Settings' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
export const mainKeyboard = () => new InlineKeyboard()
|
|
17
|
+
.text('New conversation', 'menu:new').text('Choose AI', 'menu:ai').row()
|
|
18
|
+
.text('Work status', 'menu:status').text('Settings', 'menu:settings')
|
|
19
|
+
|
|
20
|
+
// Short-lived opaque button IDs: no model names or executable arguments from callbacks.
|
|
21
|
+
// These are operational settings, not a second conversational/agent loop.
|
|
22
|
+
export const createAiMenu = (control: ControlStore, cli: string, catalog = readModels, workspace = process.cwd()) => {
|
|
23
|
+
const initial = initialPreset(cli)
|
|
24
|
+
const host = process.env.EZ_EXECUTOR_TRANSPORT === 'host'
|
|
25
|
+
if (host) catalog = async () => JSON.parse(await readFile(path.join(process.env.EZ_CONTROL_DIR!, 'host-executor/models.json'),'utf8'))
|
|
26
|
+
const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace))
|
|
27
|
+
const validate = async (preset: AiPreset) => {
|
|
28
|
+
if (preset.id === initial.id) return
|
|
29
|
+
if (preset.id.startsWith('detected_')) {
|
|
30
|
+
const detected = await discoverDefaults(workspace)
|
|
31
|
+
if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
|
|
32
|
+
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : undefined)
|
|
33
|
+
}
|
|
34
|
+
const buttons = new Map<string, { expires: number; action: (ctx: Context) => Promise<void> }>()
|
|
35
|
+
const button = (keyboard: InlineKeyboard, label: string, action: (ctx: Context) => Promise<void>) => {
|
|
36
|
+
for (const [id, b] of buttons) if (b.expires < Date.now()) buttons.delete(id)
|
|
37
|
+
if (buttons.size >= 300) buttons.delete(buttons.keys().next().value!)
|
|
38
|
+
const id = randomBytes(8).toString('hex')
|
|
39
|
+
buttons.set(id, { expires: Date.now() + 15 * 60_000, action })
|
|
40
|
+
keyboard.text(label.slice(0, 64), `ai:${id}`).row()
|
|
41
|
+
}
|
|
42
|
+
const choose = async (ctx: Context, preset: AiPreset) => {
|
|
43
|
+
await validate(preset)
|
|
44
|
+
const session = await control.getActiveSession()
|
|
45
|
+
const state = await control.aiState(initial)
|
|
46
|
+
const current = state.presets.find((p) => p.id === state.selectedId)!
|
|
47
|
+
const fresh = current.cli !== preset.cli || Boolean(session && !session.cli)
|
|
48
|
+
await control.selectPreset(preset.id, session?.sessionId ?? null, fresh)
|
|
49
|
+
await ctx.reply(`${preset.name}\n${presetLabel(preset)}\n${fresh
|
|
50
|
+
? 'CLI changed: fresh conversation. Files kept; queued work unchanged.'
|
|
51
|
+
: 'Selected for this conversation. Queued work unchanged.'}`)
|
|
52
|
+
}
|
|
53
|
+
const list = async (ctx: Context, settings = false) => {
|
|
54
|
+
const state = await control.aiState(initial)
|
|
55
|
+
const keyboard = new InlineKeyboard()
|
|
56
|
+
for (const preset of state.presets) button(keyboard,
|
|
57
|
+
`${preset.id === (settings ? state.defaultId : state.selectedId) ? '✓ ' : ''}${preset.name}`,
|
|
58
|
+
async (next) => {
|
|
59
|
+
if (settings) {
|
|
60
|
+
await validate(preset)
|
|
61
|
+
await control.defaultPreset(preset.id)
|
|
62
|
+
await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
|
|
63
|
+
} else await choose(next, preset)
|
|
64
|
+
})
|
|
65
|
+
button(keyboard, 'Add AI…', (next) => available(next))
|
|
66
|
+
if (settings) button(keyboard, 'Refresh available AIs', async (next) => {
|
|
67
|
+
await refresh()
|
|
68
|
+
await list(next, true)
|
|
69
|
+
})
|
|
70
|
+
await ctx.reply(settings ? 'Default for new conversations\nChoose a saved AI. Current work will not change.'
|
|
71
|
+
: 'Choose AI\nChanging CLI starts a fresh conversation; files stay.', { reply_markup: keyboard })
|
|
72
|
+
}
|
|
73
|
+
const available = async (ctx: Context, page = 0) => {
|
|
74
|
+
const models = await catalog()
|
|
75
|
+
const keyboard = new InlineKeyboard()
|
|
76
|
+
for (const model of models.slice(page * 8, page * 8 + 8)) {
|
|
77
|
+
button(keyboard, `${model.cli} · ${model.name}`, async (next) => {
|
|
78
|
+
if (!model.efforts.length) return save(next, model)
|
|
79
|
+
const efforts = new InlineKeyboard()
|
|
80
|
+
for (const effort of model.efforts) button(efforts, effort, (last) => save(last, model, effort))
|
|
81
|
+
await next.reply(`${model.name} — effort`, { reply_markup: efforts })
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
if (page > 0) button(keyboard, 'Previous', (next) => available(next, page - 1))
|
|
85
|
+
if (models.length > (page + 1) * 8) button(keyboard, 'Next', (next) => available(next, page + 1))
|
|
86
|
+
await ctx.reply(models.length
|
|
87
|
+
? 'Installed client choices. Grok/Codex use their local model catalog; other clients use their own default. Adding saves the choice; it does not switch AI.'
|
|
88
|
+
: 'No client catalog available. Open the installed CLI once, then try again.', { reply_markup: keyboard })
|
|
89
|
+
}
|
|
90
|
+
const save = async (ctx: Context, model: ModelChoice, effort?: string) => {
|
|
91
|
+
const state = await control.aiState(initial)
|
|
92
|
+
const existing = state.presets.find((p) => p.cli === model.cli && p.model === model.model && p.effort === effort)
|
|
93
|
+
const preset: AiPreset = existing ?? { id: randomBytes(8).toString('hex'),
|
|
94
|
+
name: `${model.name}${effort ? ` · ${effort}` : ''}`.slice(0, 80), cli: model.cli, model: model.model, effort }
|
|
95
|
+
await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : undefined)
|
|
96
|
+
await control.savePreset(preset)
|
|
97
|
+
const keyboard = new InlineKeyboard()
|
|
98
|
+
button(keyboard, 'Use now', (next) => choose(next, preset))
|
|
99
|
+
button(keyboard, 'Make default', async (next) => {
|
|
100
|
+
await control.defaultPreset(preset.id)
|
|
101
|
+
await next.reply(`Default: ${preset.name}. Applies to new conversations only.`)
|
|
102
|
+
})
|
|
103
|
+
await ctx.reply(`Saved: ${preset.name}\n${presetLabel(preset)}`, { reply_markup: keyboard })
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
initial,
|
|
107
|
+
refresh,
|
|
108
|
+
list,
|
|
109
|
+
async handle(ctx: Context): Promise<boolean> {
|
|
110
|
+
const data = ctx.callbackQuery?.data
|
|
111
|
+
if (!data?.startsWith('ai:')) return false
|
|
112
|
+
const entry = buttons.get(data.slice(3))
|
|
113
|
+
await ctx.answerCallbackQuery().catch(() => {})
|
|
114
|
+
if (!entry || entry.expires < Date.now()) await ctx.reply('Menu expired. Open /ai or /settings again.')
|
|
115
|
+
else {
|
|
116
|
+
buttons.delete(data.slice(3))
|
|
117
|
+
try { await entry.action(ctx) }
|
|
118
|
+
catch (error) { await ctx.reply(error instanceof Error ? error.message : 'AI selection failed.') }
|
|
119
|
+
}
|
|
120
|
+
return true
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { RunStore } from './runs.js'
|
|
2
|
+
|
|
3
|
+
export type MessageCliArgs = {
|
|
4
|
+
textFile?: string
|
|
5
|
+
text?: string
|
|
6
|
+
replyTo?: number
|
|
7
|
+
document?: string
|
|
8
|
+
voice?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const parseMessageArgs = (argv: string[]): MessageCliArgs => {
|
|
12
|
+
const args = argv.filter((arg) => arg !== '--')
|
|
13
|
+
let textFile: string | undefined
|
|
14
|
+
let text: string | undefined
|
|
15
|
+
let replyTo: number | undefined
|
|
16
|
+
let document: string | undefined
|
|
17
|
+
let voice: string | undefined
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
if (args[i] === '--text-file' && args[i + 1]) {
|
|
21
|
+
textFile = args[++i]
|
|
22
|
+
} else if (args[i] === '--text' && args[i + 1]) {
|
|
23
|
+
text = args[++i]
|
|
24
|
+
} else if (args[i] === '--reply-to' && args[i + 1]) {
|
|
25
|
+
const parsed = parseInt(args[++i], 10)
|
|
26
|
+
if (!Number.isNaN(parsed)) replyTo = parsed
|
|
27
|
+
} else if ((args[i] === '--document' || args[i] === '--file') && args[i + 1]) {
|
|
28
|
+
document = args[++i]
|
|
29
|
+
} else if (args[i] === '--voice' && args[i + 1]) {
|
|
30
|
+
voice = args[++i]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const result: MessageCliArgs = { textFile }
|
|
34
|
+
if (text !== undefined) result.text = text
|
|
35
|
+
if (replyTo !== undefined) result.replyTo = replyTo
|
|
36
|
+
if (document !== undefined) result.document = document
|
|
37
|
+
if (voice !== undefined) result.voice = voice
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const sendRunText = async (store: RunStore, runId: string, text: string, options?: { replyTo?: number }) => {
|
|
42
|
+
const trimmed = text.trim()
|
|
43
|
+
if (!trimmed) throw new Error('Message text is empty')
|
|
44
|
+
return store.enqueueMessage(runId, trimmed, { replyToMessageId: options?.replyTo })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const sendRunDocument = async (store: RunStore, runId: string, filePath: string, caption?: string, options?: { replyTo?: number }) => {
|
|
48
|
+
const trimmed = filePath.trim()
|
|
49
|
+
if (!trimmed) throw new Error('Document path is empty')
|
|
50
|
+
return store.enqueueDocument(runId, trimmed, { caption: caption?.trim(), replyToMessageId: options?.replyTo })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const sendRunVoice = async (store: RunStore, runId: string, voiceText: string, options?: { replyTo?: number }) => {
|
|
54
|
+
const trimmed = voiceText.trim()
|
|
55
|
+
if (!trimmed) throw new Error('Voice text is empty')
|
|
56
|
+
return store.enqueueVoice(runId, trimmed, { replyToMessageId: options?.replyTo })
|
|
57
|
+
}
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { loadControlConfig } from './config.js'
|
|
3
|
+
import { parseMessageArgs, sendRunDocument, sendRunText, sendRunVoice } from './message-send.js'
|
|
4
|
+
import { RunStore } from './runs.js'
|
|
5
|
+
|
|
6
|
+
const rawArgs = process.argv.slice(2)
|
|
7
|
+
if (rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
8
|
+
console.log(
|
|
9
|
+
'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
|
|
10
|
+
)
|
|
11
|
+
process.exit(0)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const runId = process.env.EZ_RUN_ID?.trim()
|
|
15
|
+
const args = parseMessageArgs(rawArgs)
|
|
16
|
+
if (!runId) {
|
|
17
|
+
console.error('EZ_RUN_ID is required')
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const store = new RunStore(loadControlConfig().controlDir)
|
|
22
|
+
|
|
23
|
+
let textContent = args.text?.trim()
|
|
24
|
+
if (args.textFile) {
|
|
25
|
+
try {
|
|
26
|
+
textContent = (await readFile(args.textFile, 'utf8')).trim()
|
|
27
|
+
} catch (err: any) {
|
|
28
|
+
console.error(`Failed to read text file ${args.textFile}: ${err.message}`)
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let item
|
|
34
|
+
if (args.document) {
|
|
35
|
+
item = await sendRunDocument(store, runId, args.document, textContent, { replyTo: args.replyTo })
|
|
36
|
+
} else if (args.voice) {
|
|
37
|
+
item = await sendRunVoice(store, runId, args.voice, { replyTo: args.replyTo })
|
|
38
|
+
} else if (textContent) {
|
|
39
|
+
item = await sendRunText(store, runId, textContent, { replyTo: args.replyTo })
|
|
40
|
+
} else {
|
|
41
|
+
console.error(
|
|
42
|
+
'Usage: ezenciel-agents-message [--text-file <path> | --text <text>] [--document <path>] [--voice <text>] [--reply-to <id>]',
|
|
43
|
+
)
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const receipt = await store.waitForDelivery(item.id)
|
|
49
|
+
console.log(
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
ok: true,
|
|
52
|
+
status: 'delivered',
|
|
53
|
+
run: runId,
|
|
54
|
+
outbox_id: item.id,
|
|
55
|
+
type: item.type,
|
|
56
|
+
receipt,
|
|
57
|
+
}),
|
|
58
|
+
)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(
|
|
61
|
+
JSON.stringify({
|
|
62
|
+
ok: false,
|
|
63
|
+
outbox_id: item.id,
|
|
64
|
+
error: error instanceof Error ? error.message : 'Delivery failed',
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
process.exitCode = 1
|
|
68
|
+
}
|
package/src/owner.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { loadControlConfig } from './config.js'
|
|
2
|
+
import { ControlStore } from './control-state.js'
|
|
3
|
+
import { parseOwnerArgs } from './owner-args.js'
|
|
4
|
+
|
|
5
|
+
const help = 'Usage: ezenciel-agents-owner status | approve <telegram-user-id> | revoke'
|
|
6
|
+
if (process.argv.slice(2).some(arg => arg === '--help' || arg === '-h')) {
|
|
7
|
+
console.log(help)
|
|
8
|
+
process.exit(0)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { command, value } = parseOwnerArgs(process.argv.slice(2))
|
|
12
|
+
const config = loadControlConfig()
|
|
13
|
+
const store = new ControlStore(config.controlDir, config.pairingTtlMs)
|
|
14
|
+
|
|
15
|
+
const usage = (): never => {
|
|
16
|
+
console.error(help)
|
|
17
|
+
process.exit(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (command === 'status' && !value) {
|
|
21
|
+
const state = await store.status()
|
|
22
|
+
console.log(JSON.stringify({ owner: state.owner, pending: state.pending, control_dir: config.controlDir }, null, 2))
|
|
23
|
+
} else if (command === 'approve' && value) {
|
|
24
|
+
const owner = await store.approveOwner(Number(value))
|
|
25
|
+
console.log(`Paired Telegram owner ${owner.telegramUserId}.`)
|
|
26
|
+
} else if (command === 'revoke' && !value) {
|
|
27
|
+
console.log((await store.revokeOwner()) ? 'Owner pairing revoked.' : 'No owner pairing existed.')
|
|
28
|
+
} else {
|
|
29
|
+
usage()
|
|
30
|
+
}
|