diff --git a/memory-bank/systemPatterns.md b/memory-bank/systemPatterns.md index b2fdf05..c2ee3b7 100644 --- a/memory-bank/systemPatterns.md +++ b/memory-bank/systemPatterns.md @@ -49,6 +49,28 @@ Inbound and outbound messages are passed through a typed `AsyncQueue`. The qu ## Logging Pattern Use `console.error` / `console.warn` / `console.info` / `console.debug` — no external logger. Color via `picocolors` in CLI output only. +## CLI Command Pattern +Each command is in its own file with a registration function: +```ts +// src/cli/agent.ts +export function agentCommand(program: Command, config: Config, workspace: string): void { + program.command('agent') + .description('...') + .option('-m, --message ', 'Single message to process') + .action(async (opts) => { /* ... */ }) +} + +// src/cli/commands.ts (bootstrap) +export function createCli(): Command { + const program = new Command('nanobot')... + const config = loadConfig(opts.config); + const workspace = resolveWorkspacePath(config.agent.workspacePath); + gatewayCommand(program, config, workspace); + agentCommand(program, config, workspace); + return program; +} +``` + ## File Layout ``` src/ @@ -67,8 +89,12 @@ src/ tools/base.ts + filesystem.ts + shell.ts + web.ts + message.ts + spawn.ts + cron.ts channels/ base.ts + mattermost.ts + manager.ts - cli/commands.ts -index.ts + cli/ + types.ts # CommandHandler type + commands.ts # Bootstrap - loads config, registers commands + agent.ts # agentCommand() - interactive/single-shot mode + gateway.ts # gatewayCommand() - full runtime with Mattermost + index.ts templates/ (SOUL.md, AGENTS.md, USER.md, TOOLS.md, HEARTBEAT.md, memory/MEMORY.md) skills/ (copied from Python repo) ``` diff --git a/src/cli/agent.ts b/src/cli/agent.ts new file mode 100644 index 0000000..a920d69 --- /dev/null +++ b/src/cli/agent.ts @@ -0,0 +1,92 @@ +import { mkdirSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { Command } from 'commander'; +import pc from 'picocolors'; +import { AgentLoop } from '../agent/loop.ts'; +import { MessageBus } from '../bus/queue.ts'; +import type { Config } from '../config/types.ts'; +import { makeProvider } from '../provider/index.ts'; + +export function agentCommand(program: Command, config: Config, workspace: string): void { + mkdirSync(workspace, { recursive: true }); + + program + .command('agent') + .description('Run the agent interactively or send a single message.') + .option('-c, --config ', 'Path to config.json') + .option('-m, --message ', 'Single message to process (non-interactive)') + .option('-w, --workspace ', 'Workspace path override') + .option('-M, --model ', 'Model override') + .action( + async (opts: { config?: string; message?: string; workspace?: string; model?: string }) => { + const model = opts.model ?? config.agent.model; + const provider = makeProvider( + config.providers, + model, + config.agent.maxTokens, + config.agent.temperature, + ); + const bus = new MessageBus(); + + const agentLoop = new AgentLoop({ + bus, + provider, + workspace, + model, + maxIterations: config.agent.maxToolIterations, + contextWindowTokens: config.agent.contextWindowTokens, + braveApiKey: config.tools.web.braveApiKey, + webProxy: config.tools.web.proxy, + execConfig: config.tools.exec, + restrictToWorkspace: config.tools.restrictToWorkspace, + }); + + // Single-shot mode + if (opts.message) { + const result = await agentLoop.processDirect(opts.message); + console.log(result); + return; + } + + // Interactive mode + console.info(pc.green('nanobot interactive mode. Type your message, Ctrl+C to exit.')); + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + const promptUser = () => { + rl.question(pc.cyan('You: '), async (input) => { + const text = input.trim(); + if (!text) { + promptUser(); + return; + } + + const onProgress = async (content: string, opts?: { toolHint?: boolean }) => { + if (opts?.toolHint) { + process.stdout.write(pc.dim(` [${content}]\n`)); + } else { + process.stdout.write(pc.dim(` ${content}\n`)); + } + }; + + const result = await agentLoop.processDirect( + text, + 'cli:interactive', + 'cli', + 'interactive', + onProgress, + ); + console.log(pc.bold('Bot:'), result); + promptUser(); + }); + }; + + rl.on('close', () => { + agentLoop.stop(); + process.exit(0); + }); + + promptUser(); + }, + ); +} diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 93564d1..7e325ee 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -1,178 +1,21 @@ import { mkdirSync } from 'node:fs'; -import { createInterface } from 'node:readline'; import { Command } from 'commander'; -import pc from 'picocolors'; -import { AgentLoop } from '../agent/loop.ts'; -import { MessageBus } from '../bus/queue.ts'; -import { MattermostChannel } from '../channels/mattermost.ts'; -import { ChannelManager } from '../channels/manager.ts'; import { loadConfig, resolveWorkspacePath } from '../config/loader.ts'; -import { CronService } from '../cron/service.ts'; -import { HeartbeatService } from '../heartbeat/service.ts'; -import { makeProvider } from '../provider/index.ts'; +import { agentCommand } from './agent.ts'; +import { gatewayCommand } from './gateway.ts'; export function createCli(): Command { - const program = new Command('nanobot').description('nanobot — personal AI assistant').version('1.0.0'); + const program = new Command('nanobot') + .description('nanobot — personal AI assistant') + .version('1.0.0'); - // --------------------------------------------------------------------------- - // gateway — full runtime: Mattermost + cron + heartbeat - // --------------------------------------------------------------------------- - program - .command('gateway') - .description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.') - .option('-c, --config ', 'Path to config.json') - .action(async (opts: { config?: string }) => { - const config = loadConfig(opts.config); - const workspace = resolveWorkspacePath(config.agent.workspacePath); - mkdirSync(workspace, { recursive: true }); + const globalOpts = program.opts(); + const config = loadConfig(globalOpts.config); + const workspace = resolveWorkspacePath(config.agent.workspacePath); + mkdirSync(workspace, { recursive: true }); - const provider = makeProvider(config.providers, config.agent.model, config.agent.maxTokens, config.agent.temperature); - const bus = new MessageBus(); - const channelManager = new ChannelManager(bus); - - // Cron service - const cronService = new CronService(workspace, async (job) => { - bus.publishInbound({ - channel: 'system', - senderId: 'cron', - chatId: `cli:cron_${job.id}`, - content: job.payload.message || `Cron job "${job.name}" triggered.`, - metadata: { cronJobId: job.id }, - }); - }); - - const agentLoop = new AgentLoop({ - bus, - provider, - workspace, - model: config.agent.model, - maxIterations: config.agent.maxToolIterations, - contextWindowTokens: config.agent.contextWindowTokens, - braveApiKey: config.tools.web.braveApiKey, - webProxy: config.tools.web.proxy, - execConfig: config.tools.exec, - cronService, - restrictToWorkspace: config.tools.restrictToWorkspace, - sendProgress: config.channels.sendProgress, - sendToolHints: config.channels.sendToolHints, - }); - - // Mattermost - if (config.channels.mattermost) { - const mm = new MattermostChannel(bus, config.channels.mattermost); - channelManager.register(mm); - } else { - console.warn(pc.yellow('[gateway] No Mattermost config found. Running without channels.')); - } - - // Heartbeat - let heartbeat: HeartbeatService | null = null; - if (config.heartbeat.enabled) { - heartbeat = new HeartbeatService({ - workspace, - provider, - model: config.agent.model, - intervalMinutes: config.heartbeat.intervalMinutes, - onExecute: async (tasks) => { - const content = tasks.length > 0 ? `Heartbeat tasks:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join('\n')}` : 'Heartbeat tick — check for anything to do.'; - return agentLoop.processDirect(content, 'system:heartbeat', 'system', 'heartbeat'); - }, - onNotify: async (_result) => { - // Result already delivered via processDirect / message tool - }, - }); - } - - // Graceful shutdown - const shutdown = () => { - console.info('\n[gateway] Shutting down...'); - agentLoop.stop(); - channelManager.stopAll(); - heartbeat?.stop(); - cronService.stop(); - process.exit(0); - }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - console.info(pc.green('[gateway] Starting...')); - cronService.start(); - heartbeat?.start(); - - await Promise.all([agentLoop.run(), channelManager.startAll()]); - }); - - // --------------------------------------------------------------------------- - // agent — interactive CLI or single-shot mode - // --------------------------------------------------------------------------- - program - .command('agent') - .description('Run the agent interactively or send a single message.') - .option('-c, --config ', 'Path to config.json') - .option('-m, --message ', 'Single message to process (non-interactive)') - .option('-w, --workspace ', 'Workspace path override') - .option('-M, --model ', 'Model override') - .action(async (opts: { config?: string; message?: string; workspace?: string; model?: string }) => { - const config = loadConfig(opts.config); - const workspaceRaw = opts.workspace ?? config.agent.workspacePath; - const workspace = resolveWorkspacePath(workspaceRaw); - mkdirSync(workspace, { recursive: true }); - - const model = opts.model ?? config.agent.model; - const provider = makeProvider(config.providers, model, config.agent.maxTokens, config.agent.temperature); - const bus = new MessageBus(); - - const agentLoop = new AgentLoop({ - bus, - provider, - workspace, - model, - maxIterations: config.agent.maxToolIterations, - contextWindowTokens: config.agent.contextWindowTokens, - braveApiKey: config.tools.web.braveApiKey, - webProxy: config.tools.web.proxy, - execConfig: config.tools.exec, - restrictToWorkspace: config.tools.restrictToWorkspace, - }); - - // Single-shot mode - if (opts.message) { - const result = await agentLoop.processDirect(opts.message); - console.log(result); - return; - } - - // Interactive mode - console.info(pc.green('nanobot interactive mode. Type your message, Ctrl+C to exit.')); - - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - const promptUser = () => { - rl.question(pc.cyan('You: '), async (input) => { - const text = input.trim(); - if (!text) { promptUser(); return; } - - const onProgress = async (content: string, opts?: { toolHint?: boolean }) => { - if (opts?.toolHint) { - process.stdout.write(pc.dim(` [${content}]\n`)); - } else { - process.stdout.write(pc.dim(` ${content}\n`)); - } - }; - - const result = await agentLoop.processDirect(text, 'cli:interactive', 'cli', 'interactive', onProgress); - console.log(pc.bold('Bot:'), result); - promptUser(); - }); - }; - - rl.on('close', () => { - agentLoop.stop(); - process.exit(0); - }); - - promptUser(); - }); + gatewayCommand(program, config, workspace); + agentCommand(program, config, workspace); return program; } diff --git a/src/cli/gateway.ts b/src/cli/gateway.ts new file mode 100644 index 0000000..76726b4 --- /dev/null +++ b/src/cli/gateway.ts @@ -0,0 +1,104 @@ +import { mkdirSync } from 'node:fs'; +import { Command } from 'commander'; +import pc from 'picocolors'; +import { AgentLoop } from '../agent/loop.ts'; +import { MessageBus } from '../bus/queue.ts'; +import { MattermostChannel } from '../channels/mattermost.ts'; +import { ChannelManager } from '../channels/manager.ts'; +import type { Config } from '../config/types.ts'; +import { CronService } from '../cron/service.ts'; +import { HeartbeatService } from '../heartbeat/service.ts'; +import { makeProvider } from '../provider/index.ts'; + +export function gatewayCommand(program: Command, config: Config, workspace: string): void { + mkdirSync(workspace, { recursive: true }); + + program + .command('gateway') + .description('Start the full gateway: Mattermost channel, agent loop, cron, and heartbeat.') + .option('-c, --config ', 'Path to config.json') + .action(async (_opts: { config?: string }) => { + const provider = makeProvider( + config.providers, + config.agent.model, + config.agent.maxTokens, + config.agent.temperature, + ); + const bus = new MessageBus(); + const channelManager = new ChannelManager(bus); + + // Cron service + const cronService = new CronService(workspace, async (job) => { + bus.publishInbound({ + channel: 'system', + senderId: 'cron', + chatId: `cli:cron_${job.id}`, + content: job.payload.message || `Cron job "${job.name}" triggered.`, + metadata: { cronJobId: job.id }, + }); + }); + + const agentLoop = new AgentLoop({ + bus, + provider, + workspace, + model: config.agent.model, + maxIterations: config.agent.maxToolIterations, + contextWindowTokens: config.agent.contextWindowTokens, + braveApiKey: config.tools.web.braveApiKey, + webProxy: config.tools.web.proxy, + execConfig: config.tools.exec, + cronService, + restrictToWorkspace: config.tools.restrictToWorkspace, + sendProgress: config.channels.sendProgress, + sendToolHints: config.channels.sendToolHints, + }); + + // Mattermost + if (config.channels.mattermost) { + const mm = new MattermostChannel(bus, config.channels.mattermost); + channelManager.register(mm); + } else { + console.warn(pc.yellow('[gateway] No Mattermost config found. Running without channels.')); + } + + // Heartbeat + let heartbeat: HeartbeatService | null = null; + if (config.heartbeat.enabled) { + heartbeat = new HeartbeatService({ + workspace, + provider, + model: config.agent.model, + intervalMinutes: config.heartbeat.intervalMinutes, + onExecute: async (tasks) => { + const content = + tasks.length > 0 + ? `Heartbeat tasks:\n${tasks.map((t, i) => `${i + 1}. ${t}`).join('\n')}` + : 'Heartbeat tick — check for anything to do.'; + return agentLoop.processDirect(content, 'system:heartbeat', 'system', 'heartbeat'); + }, + onNotify: async (_result) => { + // Result already delivered via processDirect / message tool + }, + }); + } + + // Graceful shutdown + const shutdown = () => { + console.info('\n[gateway] Shutting down...'); + agentLoop.stop(); + channelManager.stopAll(); + heartbeat?.stop(); + cronService.stop(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + console.info(pc.green('[gateway] Starting...')); + cronService.start(); + heartbeat?.start(); + + await Promise.all([agentLoop.run(), channelManager.startAll()]); + }); +} diff --git a/src/cli/types.ts b/src/cli/types.ts new file mode 100644 index 0000000..6db1cf2 --- /dev/null +++ b/src/cli/types.ts @@ -0,0 +1,4 @@ +import type { Command } from 'commander'; +import type { Config } from '../config/types.ts'; + +export type CommandHandler = (program: Command, config: Config, workspace: string) => void;