diff --git a/README.md b/README.md index 5157d59..33c2890 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,12 @@ npx wrangler deploy Visit `https://your-worker.workers.dev` and authenticate with your AEGIS_TOKEN. +Talk to the same deployment from a terminal: + +```bash +AEGIS_HOST=your-worker.workers.dev AEGIS_TOKEN=your-token npx @stackbilt/aegis-core --quick +``` + ## Use as a Dependency Install `@stackbilt/aegis-core` and compose your own agent: diff --git a/docs/configuration.md b/docs/configuration.md index 4d6c039..ceee430 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -176,10 +176,10 @@ new_sqlite_classes = ["ChatSession"] The route is protected by the same `AEGIS_TOKEN` as the HTTP API. Connect with `wss:///chat/ws?token=` and request the `aegis-chat` subprotocol. -Client frames. `eventId` is optional but recommended for reconnect/replay deduplication: +Client frames. `eventId` is optional but recommended for reconnect/replay deduplication. `executor` is optional and may be `workers_ai`, `gpt_oss`, `groq`, `claude`, `claude_opus`, or `composite`. ```json -{ "type": "message", "text": "What changed today?", "conversationId": "optional-uuid", "eventId": "optional-client-event-id" } +{ "type": "message", "text": "What changed today?", "conversationId": "optional-uuid", "eventId": "optional-client-event-id", "executor": "optional-executor" } ``` Server frames: diff --git a/web/cli/aegis.mjs b/web/cli/aegis.mjs new file mode 100644 index 0000000..32736da --- /dev/null +++ b/web/cli/aegis.mjs @@ -0,0 +1,356 @@ +#!/usr/bin/env node +// AEGIS CLI: zero-dependency terminal chat over /chat/ws. +// Requires Node >= 21 for the built-in WebSocket client. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import readline from 'node:readline'; +import { stdin, stdout } from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')); +const args = process.argv.slice(2); +const has = (flag) => args.includes(flag); +const argVal = (flag) => { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +}; + +const HELP = `AEGIS CLI + +Usage: + aegis [--host ] [--token ] [--exec ] [--conversation ] [--verbose] [--quick] + +Options: + --host AEGIS worker host or URL. Defaults to AEGIS_HOST or aegis.stackbilt.dev. + --token Bearer token. Defaults to AEGIS_TOKEN. + --exec Force an executor for this session. Use auto to let the kernel route. + --conversation Resume an existing conversation. + --verbose Show executor/classification/cost metadata after each response. + --quick Skip the startup executor picker. + --version Print version and exit. + --help Print this help and exit. + +REPL commands: + /new /exec [name] /verbose /clear /help /quit +`; + +if (has('--help') || has('-h')) { + console.log(HELP.trimEnd()); + process.exit(0); +} +if (has('--version') || has('-v')) { + console.log(pkg.version); + process.exit(0); +} +if (typeof WebSocket === 'undefined') { + console.error('AEGIS CLI requires Node >= 21 for built-in WebSocket support.'); + process.exit(1); +} + +let conversationId = argVal('--conversation') || null; +let executor = normalizeExecutor(argVal('--exec')); +let verbose = has('--verbose'); +const quick = has('--quick') || Boolean(argVal('--exec')); +const token = argVal('--token') || process.env.AEGIS_TOKEN; +const host = normalizeHost(argVal('--host') || process.env.AEGIS_HOST || 'aegis.stackbilt.dev'); + +if (!token) { + console.error('Missing AEGIS_TOKEN. Set AEGIS_TOKEN or pass --token .'); + process.exit(1); +} + +const c = { + reset: '\x1b[0m', + dim: '\x1b[2m', + bold: '\x1b[1m', + cyan: '\x1b[36m', + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', +}; +const paint = (color, s) => `${color}${s}${c.reset}`; + +const EXECUTORS = [ + { value: null, label: 'auto', hint: 'kernel routes automatically' }, + { value: 'workers_ai', label: 'workers_ai', hint: 'Cloudflare Workers AI' }, + { value: 'gpt_oss', label: 'gpt_oss', hint: 'tool-capable standard path' }, + { value: 'groq', label: 'groq', hint: 'fast simple responses' }, + { value: 'claude', label: 'claude', hint: 'reliability-critical work' }, + { value: 'claude_opus', label: 'claude_opus', hint: 'frontier tier' }, + { value: 'composite', label: 'composite', hint: 'multi-model synthesis' }, +]; + +const EXECUTOR_VALUES = new Set(EXECUTORS.map((e) => e.value).filter(Boolean)); + +function normalizeExecutor(value) { + if (!value || value === 'auto') return null; + return value; +} + +function normalizeHost(value) { + return value.replace(/^https?:\/\//, '').replace(/^wss?:\/\//, '').replace(/\/+$/, ''); +} + +function printBanner() { + console.log(''); + console.log(paint(c.cyan, 'AEGIS')); + console.log(paint(c.dim, 'persistent agent terminal')); + console.log(''); +} + +function select(title, options, initial = 0) { + return new Promise((resolve) => { + let idx = initial; + readline.emitKeypressEvents(stdin); + const wasRaw = Boolean(stdin.isRaw); + if (stdin.isTTY) stdin.setRawMode(true); + + const draw = (first = false) => { + if (!first) stdout.write(`\x1b[${options.length + 1}A`); + stdout.write(`${c.bold}?${c.reset} ${title}\x1b[K\n`); + options.forEach((o, i) => { + const on = i === idx; + const pointer = on ? paint(c.cyan, '>') : ' '; + const label = on ? paint(c.cyan, o.label) : o.label; + const hint = o.hint ? paint(c.dim, ` - ${o.hint}`) : ''; + stdout.write(`${pointer} ${label}${hint}\x1b[K\n`); + }); + }; + + const done = (value) => { + stdin.removeListener('keypress', onKey); + if (stdin.isTTY) stdin.setRawMode(wasRaw); + resolve(value); + }; + + const onKey = (_str, key) => { + if (!key) return; + if (key.name === 'up' || key.name === 'k') { + idx = (idx - 1 + options.length) % options.length; + draw(); + } else if (key.name === 'down' || key.name === 'j') { + idx = (idx + 1) % options.length; + draw(); + } else if (key.name === 'return') { + done(options[idx].value); + } else if (key.name === 'escape') { + done(options[initial].value); + } else if (key.ctrl && key.name === 'c') { + stdout.write('\n'); + process.exit(0); + } + }; + + draw(true); + stdin.on('keypress', onKey); + }); +} + +function makeSpinner(label) { + const frames = ['-', '\\', '|', '/']; + let i = 0; + let timer = null; + return { + start() { + if (timer) return; + timer = setInterval(() => { + stdout.write(`\r${paint(c.cyan, frames[i = (i + 1) % frames.length])} ${paint(c.dim, label)}\x1b[K`); + }, 90); + }, + stop() { + if (timer) clearInterval(timer); + timer = null; + stdout.write('\r\x1b[K'); + }, + }; +} + +const fmtCost = (n) => (typeof n === 'number' ? `$${n.toFixed(5)}` : `$${n ?? 0}`); +const think = makeSpinner('thinking'); +let rl = null; +let conn = null; + +function connect() { + const q = new URLSearchParams({ token }); + if (conversationId) q.set('conversationId', conversationId); + const ws = new WebSocket(`wss://${host}/chat/ws?${q.toString()}`, 'aegis-chat'); + let streaming = false; + let pending = false; + let firstDelta = false; + + ws.addEventListener('open', () => { + think.stop(); + console.log(`${paint(c.green, 'connected')} ${paint(c.dim, host)}`); + console.log(`${paint(c.dim, 'executor')} ${executor || 'auto'}`); + console.log(`${paint(c.dim, 'conversation')} ${conversationId ?? 'new on first message'}`); + console.log(paint(c.dim, '/new /exec [name] /verbose /clear /help /quit')); + startRepl(); + }); + + ws.addEventListener('message', (ev) => { + let frame; + try { + frame = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString()); + } catch { + return; + } + + switch (frame.type) { + case 'history': + if (Array.isArray(frame.messages) && frame.messages.length) { + console.log(paint(c.dim, `${frame.messages.length} prior messages loaded`)); + } + break; + case 'start': + if (frame.conversationId) conversationId = frame.conversationId; + streaming = true; + firstDelta = false; + break; + case 'delta': + if (!frame.text) break; + if (!firstDelta) { + think.stop(); + stdout.write(`${paint(c.cyan, 'aegis')} `); + firstDelta = true; + } + stdout.write(frame.text); + break; + case 'done': + if (!firstDelta) think.stop(); + streaming = false; + pending = false; + stdout.write('\n'); + if (verbose && frame.metadata) { + const md = frame.metadata; + console.log(paint(c.dim, `${md.executor} / ${md.classification} / ${fmtCost(md.cost)} / ${md.latencyMs ?? '?'}ms${md.grounded !== undefined ? ` / grounded=${md.grounded}` : ''}`)); + if (md.unverifiedClaims?.length) console.log(paint(c.yellow, `${md.unverifiedClaims.length} unverified claim(s)`)); + } + rl?.prompt(); + break; + case 'error': + think.stop(); + streaming = false; + pending = false; + console.log(`\n${paint(c.red, 'error:')} ${frame.error}`); + rl?.prompt(); + break; + } + }); + + ws.addEventListener('error', () => { + think.stop(); + console.log(paint(c.red, 'websocket error')); + }); + ws.addEventListener('close', (ev) => { + think.stop(); + console.log(paint(c.dim, `disconnected${ev.reason ? ` (${ev.reason})` : ''}`)); + process.exit(ev.code === 1000 ? 0 : 1); + }); + + return { + send(text) { + if (pending || streaming) { + console.log(paint(c.dim, 'still responding')); + return; + } + pending = true; + think.start(); + ws.send(JSON.stringify({ + type: 'message', + text, + conversationId, + eventId: crypto.randomUUID(), + ...(executor ? { executor } : {}), + })); + }, + close() { + ws.close(1000, 'bye'); + }, + }; +} + +function startRepl() { + rl = readline.createInterface({ input: stdin, output: stdout, prompt: `${paint(c.green, '>')} ` }); + rl.prompt(); + + rl.on('line', async (line) => { + const text = line.trim(); + if (!text) { + rl.prompt(); + return; + } + + if (text.startsWith('/')) { + const [cmd, arg] = text.slice(1).split(/\s+/); + switch (cmd) { + case 'new': + conversationId = null; + console.log(paint(c.dim, 'new conversation on next message')); + break; + case 'exec': + if (arg) { + const next = normalizeExecutor(arg); + if (next && !EXECUTOR_VALUES.has(next)) { + console.log(paint(c.dim, `unknown executor: ${arg}`)); + } else { + executor = next; + console.log(paint(c.dim, `executor -> ${executor || 'auto'}`)); + } + break; + } + if (!stdin.isTTY) { + console.log(paint(c.dim, `usage: /exec <${EXECUTORS.map((e) => e.label).join('|')}>`)); + break; + } + rl.removeAllListeners('line'); + rl.close(); + executor = await select('executor', EXECUTORS, Math.max(0, EXECUTORS.findIndex((e) => e.value === executor))); + console.log(paint(c.dim, `executor -> ${executor || 'auto'}`)); + startRepl(); + return; + case 'verbose': + verbose = !verbose; + console.log(paint(c.dim, `verbose ${verbose ? 'on' : 'off'}`)); + break; + case 'clear': + stdout.write('\x1b[2J\x1b[H'); + break; + case 'help': + console.log(paint(c.dim, '/new /exec [name] /verbose /clear /help /quit')); + break; + case 'quit': + case 'q': + case 'exit': + conn.close(); + return; + default: + console.log(paint(c.dim, `unknown command /${cmd}`)); + } + rl.prompt(); + return; + } + + conn.send(text); + }); + + rl.on('SIGINT', () => conn.close()); +} + +async function main() { + if (executor && !EXECUTOR_VALUES.has(executor)) { + console.error(`Unknown executor: ${executor}`); + process.exit(1); + } + printBanner(); + if (!executor && !quick && stdin.isTTY) { + executor = await select('Launch executor', EXECUTORS, 0); + } + think.start(); + conn = connect(); +} + +main().catch((err) => { + console.error(`aegis: ${err.message}`); + process.exit(1); +}); diff --git a/web/package.json b/web/package.json index deaa59b..3e24366 100755 --- a/web/package.json +++ b/web/package.json @@ -14,6 +14,9 @@ "type": "module", "main": "src/exports.ts", "types": "src/exports.ts", + "bin": { + "aegis": "./cli/aegis.mjs" + }, "exports": { ".": "./src/exports.ts", "./core": "./src/core.ts", @@ -103,6 +106,7 @@ "wrangler": "^4.76.0" }, "files": [ + "cli/**/*.mjs", "src/**/*.ts", "schema.sql" ], diff --git a/web/src/durable-objects/chat-session.ts b/web/src/durable-objects/chat-session.ts index de74a12..5670468 100644 --- a/web/src/durable-objects/chat-session.ts +++ b/web/src/durable-objects/chat-session.ts @@ -1,15 +1,25 @@ import type { Env, MessageMetadata } from '../types.js'; import { buildEdgeEnv } from '../edge-env.js'; import { createIntent, dispatchStream } from '../kernel/dispatch.js'; -import type { DispatchResult } from '../kernel/types.js'; +import type { DispatchResult, Executor } from '../kernel/types.js'; import { isValidConversationId, verifyConversationOwnership } from './chat-session-auth.js'; import { z } from 'zod'; +const ExecutorSchema = z.enum([ + 'claude', + 'groq', + 'workers_ai', + 'claude_opus', + 'gpt_oss', + 'composite', +]); + const MessageFrameSchema = z.object({ type: z.literal('message'), text: z.string().trim().min(1), conversationId: z.string().optional(), eventId: z.string().trim().min(1).optional(), + executor: ExecutorSchema.optional(), }).passthrough(); type StoredMessage = { @@ -91,7 +101,9 @@ export class ChatSession implements DurableObject { try { const edgeEnv = buildEdgeEnv(this.env); - const intent = createIntent(conversationId, text); + const intent = createIntent(conversationId, text, { + forcedExecutor: parsed.data.executor as Executor | undefined, + }); const result = await dispatchStream(intent, edgeEnv, (delta) => { this.send(ws, { type: 'delta', text: delta }); }); diff --git a/web/src/kernel/dispatch.ts b/web/src/kernel/dispatch.ts index 97695d4..e203656 100755 --- a/web/src/kernel/dispatch.ts +++ b/web/src/kernel/dispatch.ts @@ -172,6 +172,14 @@ interface AugmentedContext { async function augmentIntent(intent: KernelIntent, env: EdgeEnv): Promise { // 1. Route const { plan, nearMiss, reclassified } = await route(intent, env.db, env.groqApiKey, env.groqModel, env.groqBaseUrl, env.ai, env.tarotscriptFetcher); + if (intent.forcedExecutor) { + plan.executor = intent.forcedExecutor; + plan.reasoning = `Forced by channel frame: ${intent.forcedExecutor}`; + plan.costCeiling = intent.forcedExecutor === 'direct' ? 'free' + : intent.forcedExecutor === 'workers_ai' || intent.forcedExecutor === 'gpt_oss' || intent.forcedExecutor === 'groq' + ? 'cheap' + : 'expensive'; + } const classification = intent.classified ?? 'unknown'; const procKey = procedureKey(classification, intent.complexity); const existingProcedure = await getProcedure(env.db, procKey); diff --git a/web/src/kernel/types.ts b/web/src/kernel/types.ts index 78924d6..3d5b171 100755 --- a/web/src/kernel/types.ts +++ b/web/src/kernel/types.ts @@ -25,6 +25,7 @@ export interface KernelIntent { timestamp: number; costCeiling: 'free' | 'cheap' | 'expensive'; classifierSource?: 'classify-cast' | 'workers-ai' | 'groq'; + forcedExecutor?: Executor; } // ─── Memory Types ──────────────────────────────────────────── diff --git a/web/tests/chat-session.test.ts b/web/tests/chat-session.test.ts index d002162..b8be594 100644 --- a/web/tests/chat-session.test.ts +++ b/web/tests/chat-session.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; vi.mock('../src/kernel/dispatch.js', () => ({ - createIntent: vi.fn((conversationId: string, text: string) => ({ conversationId, text })), + createIntent: vi.fn((conversationId: string, text: string, overrides?: unknown) => ({ conversationId, text, overrides })), dispatchStream: vi.fn(), })); @@ -10,7 +10,7 @@ vi.mock('../src/edge-env.js', () => ({ })); import { ChatSession } from '../src/durable-objects/chat-session.js'; -import { dispatchStream } from '../src/kernel/dispatch.js'; +import { createIntent, dispatchStream } from '../src/kernel/dispatch.js'; import type { Env } from '../src/types.js'; const CONVERSATION_ID = '018f9e54-7f61-4e01-8a04-7b54c23b2e10'; @@ -89,6 +89,7 @@ describe('ChatSession Durable Object', () => { text: 'What changed?', conversationId: CONVERSATION_ID, eventId: 'evt-1', + executor: 'workers_ai', })); const frames = sentFrames(ws); @@ -104,6 +105,9 @@ describe('ChatSession Durable Object', () => { query.bindings.includes('operator'), )).toBe(true); expect(dispatchStream).toHaveBeenCalledTimes(1); + expect(createIntent).toHaveBeenCalledWith(CONVERSATION_ID, 'What changed?', { + forcedExecutor: 'workers_ai', + }); }); it('rejects foreign conversations before dispatching', async () => { diff --git a/web/tests/cli.test.ts b/web/tests/cli.test.ts new file mode 100644 index 0000000..2b2d48a --- /dev/null +++ b/web/tests/cli.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; + +describe('aegis CLI', () => { + it('prints help without requiring a token', () => { + const out = execFileSync(process.execPath, ['cli/aegis.mjs', '--help'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + expect(out).toContain('AEGIS CLI'); + expect(out).toContain('--host '); + expect(out).toContain('/new /exec [name]'); + }); + + it('prints package version without requiring a token', () => { + const out = execFileSync(process.execPath, ['cli/aegis.mjs', '--version'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/); + }); + + it('fails clearly when no token is configured', () => { + const result = spawnSync(process.execPath, ['cli/aegis.mjs', '--quick'], { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env, AEGIS_TOKEN: '' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Missing AEGIS_TOKEN'); + }); +});