UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

776 lines (772 loc) • 33.2 kB
import { parseArgs } from 'node:util'; import { callOpenApi, listApis, loadOpenApi } from '../api.js'; import { autoSubmitFormHtml, createBrowserAssertion, serveOneShotPage } from '../browser.js'; import { PoppyAgent } from '../company.js'; import { ConversationClient, newMessageId, poppyProtocol } from '../conversations.js'; import { discover } from '../discovery.js'; import { PoppyError, ResourceAuthError } from '../errors.js'; import { AgentIdentity } from '../identity.js'; import { startIdentityServer, waitForRelayedCallback } from '../identity_server.js'; import { generateEs256Key } from '../keys.js'; import { ask, DEFAULT_MODEL } from '../llm.js'; import { connectMcp } from '../mcp.js'; import { openInBrowser } from '../open_url.js'; import { completeDirectSignIn, deviceSignIn, eligibleSignInTypes, mediatedSignIn, startDirectSignIn, submitMediatedCode, } from '../signin.js'; import { StateStore } from '../store.js'; import { isLoopbackHost, requireSecureUrl } from '../util.js'; import { Prompter } from './prompt.js'; const HELP = `poppy-agent — reference Personal Agent for the Personal Agent Protocol (Poppy) EXPERIMENTAL. Implements spec Draft 0.1 (https://personalagentprotocol.org/docs/spec). The spec is still in development; this client tracks it and WILL change, possibly in breaking ways, as the spec evolves. Usage: poppy-agent <command> [args] [options] Identity (4.1) init --base-url <https-url> [--name <client_name>] [--logo-uri <url>] [--rotate-keys] identity print client metadata + JWKS serve-identity [--base-url <url>] [--port <n>] [--host <addr>] serve agent.json, jwks.json, logo, /oauth/callback Discovery (3) discover <domain> Sessions & sign-in (4) session <domain> [--new] [--scope <s>] [--resource <url>] signin <domain> [--method direct|device|mediated] [--scope "<scopes>"] [--no-open] status [domain] disconnect <domain> revoke the Account Token (RFC 7009) APIs (6) apis <domain> list APIs (and OpenAPI operations) api <domain> <METHOD> <path> [--data <json>] [--api <n>] [--scope <s>] mcp tools <domain> [--api <n>] mcp call <domain> <tool> [<json-args>] [--api <n>] Conversations (7) chat <domain> [--conversation <id>] [--as agent|human] [--no-stream] Web browsing (5) open <domain> <return_to> [--print] form-POST a poppy-browser+jwt assertion LLM mode ask <domain> "<task>" [--model <id>] [--max-steps <n>] [--yes] needs OPENROUTER_API_KEY (default model ${DEFAULT_MODEL}) Global options --state <file> state file (default $POPPY_AGENT_STATE or ~/.config/poppy-agent/state.json), mode 0600 --profile <name> local user to act for (default "default"); each gets its own User IDs --client-assertion-aud token_endpoint|issuer aud of private_key_jwt (default token_endpoint) --insecure-dev NON-SPEC: allow http:// URLs (client_id, discovery, endpoints). Local testing only. Also: POPPY_AGENT_INSECURE_DEV=1 `; const out = (s = '') => process.stdout.write(`${s}\n`); const err = (s) => process.stderr.write(`${s}\n`); const dim = (s) => (process.stdout.isTTY ? `\x1b[2m${s}\x1b[0m` : s); const bold = (s) => (process.stdout.isTTY ? `\x1b[1m${s}\x1b[0m` : s); function insecureBanner() { const red = (s) => (process.stderr.isTTY ? `\x1b[41;97;1m${s}\x1b[0m` : s); err(red('!!! --insecure-dev: NON-SPEC MODE !!!')); err(red(' http:// is accepted for client_id, poppy.json, issuer, endpoints and redirects.')); err(red(' The Poppy spec requires HTTPS everywhere. NEVER use this against a real Company.')); } function loadIdentity(ctx) { const id = ctx.store.data.identity; if (!id) throw new PoppyError('no_identity', 'no agent identity yet: run `poppy-agent init --base-url <url>`'); return new AgentIdentity(id, { insecureDev: ctx.insecureDev }); } function makeAgent(ctx) { const aud = ctx.values['client-assertion-aud']; return new PoppyAgent({ store: ctx.store, identity: loadIdentity(ctx), profile: ctx.profile, insecureDev: ctx.insecureDev, log: (l) => err(dim(`· ${l}`)), tokenClient: { clientAssertionAudience: aud === 'issuer' ? 'issuer' : 'token_endpoint' }, }); } async function company(ctx, domain) { if (!domain) throw new PoppyError('usage', 'missing <domain>'); return makeAgent(ctx).company(domain); } function str(v) { return typeof v === 'string' ? v : undefined; } export async function runCli(argv) { const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, strict: false, options: { help: { type: 'boolean', short: 'h' }, state: { type: 'string' }, profile: { type: 'string' }, 'insecure-dev': { type: 'boolean' }, 'base-url': { type: 'string' }, name: { type: 'string' }, 'logo-uri': { type: 'string' }, 'rotate-keys': { type: 'boolean' }, port: { type: 'string' }, host: { type: 'string' }, new: { type: 'boolean' }, scope: { type: 'string' }, resource: { type: 'string' }, method: { type: 'string' }, 'no-open': { type: 'boolean' }, data: { type: 'string' }, api: { type: 'string' }, conversation: { type: 'string' }, as: { type: 'string' }, 'no-stream': { type: 'boolean' }, print: { type: 'boolean' }, model: { type: 'string' }, 'max-steps': { type: 'string' }, yes: { type: 'boolean' }, 'client-assertion-aud': { type: 'string' }, }, }); const [command, ...args] = positionals; if (!command || values.help) { out(HELP); return command || values.help ? 0 : 1; } const insecureDev = values['insecure-dev'] === true || process.env.POPPY_AGENT_INSECURE_DEV === '1'; if (insecureDev) insecureBanner(); const ctx = { store: await StateStore.open(str(values.state)), insecureDev, profile: str(values.profile) ?? 'default', values: values, prompter: new Prompter(), }; try { return await dispatch(ctx, command, args); } catch (e) { if (e instanceof ResourceAuthError && e.code === 'insufficient_scope') { err(`error: insufficient_scope — the Company needs "${e.scope ?? '?'}". Run: poppy-agent signin <domain> --scope "${e.scope ?? ''}"`); } else if (e instanceof ResourceAuthError && e.code === 'sign_in_required') { err('error: sign_in_required — run: poppy-agent signin <domain>'); } else { err(`error: ${e.message}`); } return 1; } finally { ctx.prompter.close(); } } async function dispatch(ctx, command, args) { const v = ctx.values; switch (command) { case 'init': return cmdInit(ctx); case 'identity': { const id = loadIdentity(ctx); out(JSON.stringify({ metadata: id.metadata(), jwks: id.jwks() }, null, 2)); return 0; } case 'serve-identity': return cmdServeIdentity(ctx); case 'discover': { if (!args[0]) throw new PoppyError('usage', 'discover <domain>'); const d = await discover(args[0], { insecureDev: ctx.insecureDev }); out(JSON.stringify({ domain: d.domain, poppy: d.document, metadata: d.metadata }, null, 2)); return 0; } case 'session': { const c = await company(ctx, args[0]); if (v.new) await c.newSession(); const t = await c.token({ scope: str(v.scope), resource: str(v.resource) }); out(JSON.stringify({ user_id: c.userId, session_id: t.sessionId, signed_in: t.signedIn, scope: t.scope, token_type: t.tokenType, expires_at: new Date(t.expiresAt).toISOString(), account_token: c.accountToken ? { scope: c.accountToken.scope, via: c.accountToken.via } : null, }, null, 2)); return 0; } case 'signin': { const c = await company(ctx, args[0]); const r = await signIn(ctx, c, str(v.method), str(v.scope)); return r ? 0 : 1; } case 'status': return cmdStatus(ctx, args[0]); case 'disconnect': { const c = await company(ctx, args[0]); const revoked = await c.signOut(); out(revoked ? 'Account Token revoked; Sessions continue signed out.' : 'No Account Token for this Company.'); return 0; } case 'apis': { const c = await company(ctx, args[0]); for (const a of listApis(c)) { out(`${bold(`[${a.type}]`)} ${a.url}${a.resource ? ` (resource ${a.resource})` : ''} — ${a.description ?? ''}`); if (a.type === 'openapi') { try { const doc = await loadOpenApi(c, a); for (const [path, ops] of Object.entries(doc.paths ?? {})) { for (const [m, op] of Object.entries(ops ?? {})) { if (!/^(get|post|put|patch|delete)$/i.test(m)) continue; out(` ${m.toUpperCase().padEnd(6)} ${path}${op?.summary ? dim(` ${op.summary}`) : ''}`); } } } catch (e) { out(dim(` (could not load: ${e.message})`)); } } } return 0; } case 'api': { const [domain, method, path] = args; if (!domain || !method || !path) throw new PoppyError('usage', 'api <domain> <METHOD> <path> [--data json]'); const c = await company(ctx, domain); const r = await callOpenApi(c, { method, path, body: str(v.data) !== undefined ? JSON.parse(str(v.data)) : undefined, apiIndex: v.api ? Number(v.api) : undefined, scope: str(v.scope), }); err(dim(`HTTP ${r.status}`)); out(typeof r.body === 'string' ? r.body : JSON.stringify(r.body, null, 2)); return r.status < 400 ? 0 : 1; } case 'mcp': return cmdMcp(ctx, args); case 'chat': return cmdChat(ctx, args[0]); case 'open': return cmdOpen(ctx, args[0], args[1]); case 'ask': return cmdAsk(ctx, args[0], args.slice(1).join(' ')); default: err(`unknown command: ${command}\n`); out(HELP); return 1; } } /* --------------------------------------------------------------------------- identity */ async function cmdInit(ctx) { const v = ctx.values; const existing = ctx.store.data.identity; const baseUrl = str(v['base-url']) ?? existing?.baseUrl; if (!baseUrl) throw new PoppyError('usage', 'init --base-url <https-url>'); requireSecureUrl(baseUrl, 'base URL', { insecureDev: ctx.insecureDev }); const keys = !existing || v['rotate-keys'] ? [await generateEs256Key(), ...(existing?.keys ?? []).slice(0, 1)] : existing.keys; ctx.store.data.identity = { baseUrl, clientName: str(v.name) ?? existing?.clientName ?? 'Poppy Reference Agent', logoUri: str(v['logo-uri']) ?? existing?.logoUri, keys, }; await ctx.store.save(); const id = loadIdentity(ctx); out(`client_id: ${id.clientId}`); out(`jwks_uri: ${id.jwksUri}`); out(`redirect: ${id.redirectUri}`); out(`state: ${ctx.store.path} (0600)`); return 0; } async function cmdServeIdentity(ctx) { const v = ctx.values; if (str(v['base-url']) || !ctx.store.data.identity) { const code = await cmdInit(ctx); if (code) return code; } const identity = loadIdentity(ctx); const base = new URL(identity.baseUrl); const local = isLoopbackHost(base.hostname); const port = v.port ? Number(v.port) : local && base.port ? Number(base.port) : 7777; const host = str(v.host) ?? (local && base.hostname === 'localhost' ? 'localhost' : '127.0.0.1'); const srv = await startIdentityServer(identity, { host, port, relayDir: ctx.store.dir, log: (l) => err(dim(l)), }); out(`Serving the agent identity on ${srv.url}`); out(` client_id ${identity.clientId}`); out(` jwks_uri ${identity.jwksUri}`); out(` redirect_uri ${identity.redirectUri}`); if (!local) out(`Expose ${srv.url} at ${identity.baseUrl} (e.g. cloudflared / tailscale funnel).`); out('Ctrl-C to stop.'); await new Promise((resolve) => { process.once('SIGINT', resolve); process.once('SIGTERM', resolve); }); await srv.close(); return 0; } /* --------------------------------------------------------------------------- sign-in */ function defaultScope(c, method) { const offered = c.document.auth?.[method]?.scopes ?? []; const broad = offered.filter((s) => s === 'poppy:read' || s === 'poppy:write'); return (broad.length ? broad : offered).join(' '); } async function signIn(ctx, c, method, scope) { const eligible = scope ? eligibleSignInTypes(c, scope) : ['direct', 'device', 'mediated'].filter((t) => c.document.auth?.[t]); const chosen = method ?? eligible[0]; if (!chosen) { err(scope ? `No sign-in type offered by this Company can grant "${scope}".` : 'This Company offers no sign-in.'); return undefined; } const wanted = scope ?? defaultScope(c, chosen); out(`Signing in with ${bold(chosen)} sign-in, scope "${wanted}"`); let result; if (chosen === 'direct') result = await directFlow(ctx, c, wanted); else if (chosen === 'device') { result = await deviceSignIn(c, wanted, { display: (i) => { out(`Open ${bold(i.verificationUriComplete ?? i.verificationUri)} and enter the code ${bold(i.userCode)}`); out(dim(`(expires in ${i.expiresIn}s; waiting…)`)); }, onPoll: (s, n) => s === 'slow_down' && err(dim(`slow_down: polling every ${n}s`)), }); } else result = await mediatedFlow(ctx, c, wanted); if (result) { out(`Signed in. Granted scope: "${result.grantedScope}" (session ${result.sessionId})`); if (result.missingScopes.length) out(`Note: the User did not grant ${result.missingScopes.join(', ')}`); } return result; } async function reachable(url) { try { const r = await fetch(url, { signal: AbortSignal.timeout(2000) }); return r.ok; } catch { return false; } } async function directFlow(ctx, c, scope) { const identity = c.agent.identity; const pending = await startDirectSignIn(c, scope); const ac = new AbortController(); const waits = [ waitForRelayedCallback(ctx.store.dir, pending.state, { signal: ac.signal }), ]; let inproc; if (!(await reachable(identity.clientId))) { const base = new URL(identity.baseUrl); if (isLoopbackHost(base.hostname)) { let resolveCb; waits.push(new Promise((r) => (resolveCb = r))); inproc = await startIdentityServer(identity, { host: base.hostname === 'localhost' ? 'localhost' : base.hostname, port: Number(base.port || 80), onCallback: (p) => p.get('state') === pending.state && resolveCb(p), }); err(dim(`serving the agent identity in-process at ${identity.baseUrl}`)); } else { err(`warning: ${identity.clientId} is not reachable. Run \`poppy-agent serve-identity\` behind your tunnel.`); } } out(`Open this URL to sign in:\n ${pending.url}`); if (!ctx.values['no-open']) openInBrowser(pending.url); if (ctx.prompter.interactive) { waits.push(ctx.prompter .ask('(waiting for the redirect… or paste the full redirect URL here)\n', ac.signal) .then((line) => new URL(line.trim()).searchParams)); } try { const params = await Promise.race(waits); return await completeDirectSignIn(c, pending, params); } finally { ac.abort(); for (const w of waits) w.catch(() => { }); await inproc?.close(); } } async function mediatedFlow(ctx, c, scope) { const mediated = c.document.auth.mediated; const credentials = {}; for (const f of mediated.fields) { credentials[f.name] = f.secret ? await ctx.prompter.askSecret(`${f.label}: `) : await ctx.prompter.ask(`${f.label}: `); } let outcome = await mediatedSignIn(c, scope, credentials); for (const k of Object.keys(credentials)) credentials[k] = ''; while (outcome.status === 'code_required') { const code = await ctx.prompter.ask(`One-time code${outcome.sentTo ? ` (${outcome.sentTo})` : ''}${outcome.expiresAt ? `, before ${outcome.expiresAt}` : ''}: `); outcome = await submitMediatedCode(c, scope, outcome.signInId, code.trim()); } if (outcome.status === 'complete') return outcome.result; err(`Mediated Sign-In ${outcome.status}.`); return undefined; } /* --------------------------------------------------------------------------- status */ async function cmdStatus(ctx, domain) { const companies = ctx.store.profile(ctx.profile).companies; const id = ctx.store.data.identity; out(`identity: ${id ? `${id.baseUrl}/agent.json` : '(none: run init)'} profile: ${ctx.profile}`); for (const s of Object.values(companies)) { if (domain && !s.domains.includes(domain.toLowerCase().replace(/^www\./, ''))) continue; const t = s.tokens.dpop; out(`${bold(s.issuer)} (${s.domains.join(', ')})`); out(` user_id ${s.userId} session ${s.sessionId ?? '-'} ${t ? `signed_in=${t.signedIn} scope="${t.scope}" expires ${new Date(t.expiresAt).toISOString()}` : ''}`); out(` account token: ${s.accountToken ? `yes (scope "${s.accountToken.scope}", via ${s.accountToken.via})` : 'no'}`); const convs = Object.entries(s.conversations).filter(([, c]) => !c.closed); if (convs.length) out(` open conversations: ${convs.map(([k]) => k).join(', ')}`); } return 0; } /* --------------------------------------------------------------------------- mcp */ async function cmdMcp(ctx, args) { const [sub, domain, tool, json] = args; if ((sub !== 'tools' && sub !== 'call') || !domain || (sub === 'call' && !tool)) { throw new PoppyError('usage', 'mcp tools <domain> | mcp call <domain> <tool> [json]'); } const c = await company(ctx, domain); const conn = await connectMcp(c, ctx.values.api ? Number(ctx.values.api) : undefined); try { if (sub === 'tools') { const { tools } = await conn.client.listTools(); for (const t of tools) { out(`${bold(t.name)}${t.annotations?.readOnlyHint ? dim(' (read-only)') : ''} — ${t.description ?? ''}`); out(dim(` input: ${JSON.stringify(t.inputSchema)}`)); } return 0; } const r = await conn.client.callTool({ name: tool, arguments: json ? JSON.parse(json) : {} }); out(JSON.stringify(r, null, 2)); return r.isError ? 1 : 0; } finally { await conn.close(); } } /* --------------------------------------------------------------------------- open */ async function cmdOpen(ctx, domain, returnTo) { if (!domain || !returnTo) throw new PoppyError('usage', 'open <domain> <return_to>'); const c = await company(ctx, domain); const { endpoint, assertion } = await createBrowserAssertion(c, returnTo); const page = await serveOneShotPage(autoSubmitFormHtml(endpoint, assertion)); err(dim("Spec 5: browser assertions MUST be posted only from a browser the Personal Agent controls, never from the User's own browser. Use a dedicated/automation browser profile.")); out(`Load this page (once, within 60s) in the agent-controlled browser:\n ${page.url}`); out(dim(`It form-POSTs the assertion to ${endpoint}, which sets the Session cookie and 303s to return_to.`)); if (!ctx.values.print) openInBrowser(page.url); await Promise.race([page.served, new Promise((r) => setTimeout(r, 60_000))]); page.close(); return 0; } /* --------------------------------------------------------------------------- ask */ async function cmdAsk(ctx, domain, task) { if (!domain || !task) throw new PoppyError('usage', 'ask <domain> "<task>"'); const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) throw new PoppyError('usage', 'set OPENROUTER_API_KEY'); const c = await company(ctx, domain); const r = await ask(c, task, { apiKey, model: str(ctx.values.model) ?? process.env.POPPY_AGENT_MODEL ?? DEFAULT_MODEL, maxSteps: ctx.values['max-steps'] ? Number(ctx.values['max-steps']) : 8, baseUrl: process.env.POPPY_AGENT_LLM_URL, allowWrites: ctx.values.yes === true, confirm: (q) => ctx.prompter.confirm(q), log: (l) => out(l), context: localContext(), }); out(); out(bold('Answer:')); out(r.answer ?? '(no answer)'); return r.answer === undefined ? 1 : 0; } /* --------------------------------------------------------------------------- chat */ function localContext() { const ro = Intl.DateTimeFormat().resolvedOptions(); return { locale: ro.locale, time_zone: ro.timeZone }; } const CHAT_HELP = `Type a message to send it. Commands: /data <json> send a message with a data part /context k=v ... send a context-only update (user_available=true|false, locale=…, time_zone=…) /as agent|human who wrote what you type (default agent; human = the User's own words, 7.8) /handoff ask for a person at the Company (7.9) /signin [method] [scope] sign in (e.g. after an authorization event) /direct [text] open a Direct Conversation (7.10); you then talk as the User /decline decline the Company's request to talk to the User /leave close the Direct Conversation /status show status/responder /close close the conversation (7.12) /quit leave (the conversation stays open)`; async function cmdChat(ctx, domain) { const c = await company(ctx, domain); if (!poppyProtocol(c)) throw new PoppyError('no_conversation_protocol', 'Company lists no `poppy` conversation protocol'); const conv = new ConversationClient(c); const stream = !ctx.values['no-stream']; let sender = ctx.values.as === 'human' ? 'human' : 'agent'; let mainId = str(ctx.values.conversation); let directId; let pendingAuth; let pendingUserRequest; let status = ''; const followers = new Map(); const provisional = new Map(); const rl = ctx.prompter.interface; const print = (line) => { if (process.stdout.isTTY) process.stdout.write('\r\x1b[2K'); out(line); if (process.stdout.isTTY) rl.prompt(true); }; const showProvisional = (id) => { if (!process.stdout.isTTY) return; const text = (provisional.get(id) ?? '').split('\n').pop() ?? ''; process.stdout.write(`\r\x1b[2K${dim(`… ${text.slice(-((process.stdout.columns || 80) - 4))}`)}`); }; const render = (convId, ev) => { const tag = directId && convId !== directId ? dim('[parent] ') : ''; switch (ev.type) { case 'message': { const m = ev.message; provisional.delete(m.id); if (m.role === 'user') return; // our own message, echoed back let line = `${tag}${bold(`company[${m.sender}]`)}: ${m.text ?? ''}`; if (m.data) line += `\n${dim(JSON.stringify(m.data, null, 2))}`; print(line); return; } case 'state': status = `${ev.status}/${ev.responder}`; print(dim(`${tag}[${status}]`)); return; case 'authorization': pendingAuth = ev; print(`${tag}${bold('Authorization needed')}: ${ev.error}${ev.scope ? ` (scope "${ev.scope}")` : ''}. Type /signin to sign in, then keep chatting.`); return; case 'user_requested': pendingUserRequest = ev; print(`${tag}${bold('The Company asks to talk with you directly')}: ${ev.reason ?? ''}\n /direct to accept, /decline to decline.`); return; case 'direct_opened': print(dim(`${tag}[direct conversation ${ev.conversation_id} opened]`)); return; case 'direct_closed': print(dim(`${tag}[direct conversation ${ev.conversation_id} closed]`)); if (directId === ev.conversation_id) directId = undefined; return; default: return; // MUST skip unknown event types (7.5) } }; const follow = (convId) => { if (followers.has(convId)) return; const ac = new AbortController(); followers.set(convId, ac); (async () => { try { for await (const item of conv.follow(convId, { signal: ac.signal, stream })) { if (item.kind === 'delta') { provisional.set(item.delta.message_id, (provisional.get(item.delta.message_id) ?? '') + item.delta.text); showProvisional(item.delta.message_id); } else if (item.kind === 'reconnected') { provisional.clear(); // 7.6: drop pieces of messages not received in full } else if (item.kind === 'history_gap') { print(dim('[part of this conversation is no longer available; reading from the start]')); } else render(convId, item.event); } } catch (e) { if (!ac.signal.aborted) print(`! event stream for ${convId} stopped: ${e.message}`); } finally { followers.delete(convId); } })(); }; const send = async (msg) => { const message = { id: newMessageId(), ...msg }; const target = directId ?? mainId; if (!target) { message.context = { ...localContext(), user_available: true, ...message.context }; const r = await conv.start(message); mainId = r.conversation_id; print(dim(`[conversation ${mainId} — ${r.status}/${r.responder}]`)); follow(mainId); } else { const r = await conv.send(target, message); if (r?.status) status = `${r.status}/${r.responder}`; } }; out(`Chatting with ${bold(c.document.organization.name)} as ${sender}. /help for commands.`); if (mainId) follow(mainId); rl.setPrompt('> '); for (;;) { let line; try { line = (await rl.question(directId ? 'you (direct)> ' : '> ')).trim(); } catch { break; } if (!line) continue; try { if (!line.startsWith('/')) { await send({ sender: directId ? 'human' : sender, text: line }); continue; } const [cmd, ...rest] = line.split(' '); const restText = rest.join(' ').trim(); if (cmd === '/quit' || cmd === '/exit') break; if (cmd === '/help') out(CHAT_HELP); else if (cmd === '/as') { sender = restText === 'human' ? 'human' : 'agent'; out(dim(`now sending as ${sender}`)); } else if (cmd === '/data') { await send({ sender: directId ? 'human' : sender, data: JSON.parse(restText) }); } else if (cmd === '/context') { const context = {}; for (const kv of rest) { const [k, val] = kv.split('='); if (!k) continue; context[k] = val === 'true' ? true : val === 'false' ? false : val; } await send({ sender: 'agent', context }); } else if (cmd === '/status') { if (!mainId) out('no conversation yet'); else { const page = await conv.read(directId ?? mainId, { cursor: c.state.conversations[directId ?? mainId]?.cursor || undefined, }); out(`${directId ?? mainId}: ${page.status}/${page.responder}`); } } else if (cmd === '/handoff') { if (!mainId) out('no conversation yet'); else { const r = await conv.handoff(mainId); out(dim(`[${r.status}/${r.responder}]`)); } } else if (cmd === '/signin') { const [method, ...scopeParts] = rest; const scope = scopeParts.join(' ') || pendingAuth?.scope || undefined; const m = method === 'direct' || method === 'device' || method === 'mediated' ? method : undefined; const r = await signIn(ctx, c, m, m ? scope : [method, ...scopeParts].filter(Boolean).join(' ') || scope); if (r) { pendingAuth = undefined; out(dim('Continue the conversation; the new Session Token is used from now on.')); } } else if (cmd === '/direct') { if (!mainId) out('no conversation yet'); else { const message = { id: newMessageId(), sender: 'human', text: restText || 'Hi, I am here.', context: { user_available: true }, }; const r = await conv.start(message, { parentConversationId: mainId }); directId = r.conversation_id; pendingUserRequest = undefined; print(dim(`[direct conversation ${directId} — you are now talking as the User]`)); follow(directId); } } else if (cmd === '/decline') { if (!mainId) out('no conversation yet'); else { await conv.send(mainId, { id: newMessageId(), sender: 'agent', text: pendingUserRequest ? 'The user declined to talk directly right now.' : 'The user is not available.', context: { user_available: false }, }); pendingUserRequest = undefined; } } else if (cmd === '/leave') { if (!directId) out('not in a direct conversation'); else { const id = directId; await conv.close(id); followers.get(id)?.abort(); directId = undefined; out(dim(`[direct conversation ${id} closed]`)); } } else if (cmd === '/close') { if (!mainId) break; const r = await conv.close(mainId); out(dim(`[${r.status}/${r.responder}]`)); break; } else out(`unknown command ${cmd}; /help`); } catch (e) { out(`! ${e.message}`); } } for (const ac of followers.values()) ac.abort(); return 0; } //# sourceMappingURL=main.js.map