diff --git a/packages/cli/src/commands/ask.ts b/packages/cli/src/commands/ask.ts new file mode 100755 index 0000000..50f9853 --- /dev/null +++ b/packages/cli/src/commands/ask.ts @@ -0,0 +1,266 @@ +/** + * AskCommand - Direct AI task execution + * air ask "task description" → MainAgent → LLM → tools → result + * + * @module packages/cli/src/commands/ask + */ + +import { loadConfig } from '../bootstrap/loadConfig.js' +import { createRuntime } from '../bootstrap/createRuntime.js' +import { initCommand } from './init.js' +import { existsSync } from 'fs' +import { join } from 'path' +import { ProviderManager, createAnthropicAdapter, OpenAICompatibleAdapter } from '@aircoding/llm' +import { MainAgent } from '@aircoding/runtime' +import type { ProviderAdapter } from '@aircoding/contracts' + +export async function askCommand(prompt: string, opts?: { model?: string; maxTurns?: number }): Promise { + if (!prompt) { + console.log('Usage: air ask ""') + console.log('Example: air ask "create a C++ program that prints hello world"') + process.exit(1) + } + + const config = loadConfig() + const projectRoot = config.project_root || process.cwd() + + // Auto-init if not already initialized + if (!existsSync(join(projectRoot, '.air', 'shared', 'project.json'))) { + console.log('Project not initialized. Running air init first...\n') + await initCommand(projectRoot) + } + + const model = opts?.model || process.env.AIRCODING_MODEL || 'glm-5.1' + const maxTurns = opts?.maxTurns || 10 + + console.log('══════════════════════════════════════════════') + console.log(' AirCoding v1.0.0-alpha') + console.log(' Project:', projectRoot) + console.log(' Model:', model) + console.log('══════════════════════════════════════════════\n') + + // Create ProviderManager with available adapter + const provider = createProvider(model) + + // Create runtime + const runtime = await createRuntime(config) + await runtime.start() + + const app = runtime.app + + // Create MainAgent with regex classifier (中文 support added) + const agent = new MainAgent({ + session_id: 'ask-session', + project_id: 'ask-project', + classify_mode: 'regex', + provider_manager: provider as any, + classify_model: model + }) + + console.log('Task:', prompt) + console.log('') + + // Classify intent + const classification = await agent.handle_user_message(prompt) + console.log(`[${classification.action}] ${agent.state}`) + + // Execute based on action + if (classification.action === 'delegate') { + await execute_task(prompt, provider, app.tool_registry, app.context_assembler, model, projectRoot, maxTurns) + } else if (classification.action === 'answer') { + console.log('\n' + (classification.response || 'No response')) + } else { + console.log('Direct mode not yet supported for ask. Use implementation requests.') + } + + await runtime.shutdown() +} + +async function execute_task( + task: string, + provider: any, + toolRegistry: any, + contextAssembler: any, + model: string, + projectRoot: string, + maxTurns: number +): Promise { + const ctx = { + session_id: 'ask-session', + project_id: 'ask-project', + agent_id: 'ask-agent', + permission_template: 'main_direct' as const, + cwd: projectRoot + } + + const systemPrompt = `You are an AI coding assistant. Help the user by reading files, writing code, and running commands. + +Available tools: +- fs.read(path) — Read a file +- fs.write(path, content) — Write/create a file +- fs.edit(path, old_str, new_str) — Edit a file +- fs.list(path, depth?) — List directory contents +- fs.stat(path) — Get file info +- shell.run(command, timeout?) — Run a shell command +- git.status() — Show git status +- project.scan(root) — Scan project for source files +- cpp.detect() — Detect C++ project +- cpp.build(target?) — Build C++ project +- cpp.test(filter?) — Run C++ tests + +To use a tool, output EXACTLY: +\`\`\`json +{"tool": "fs.read", "args": {"path": "file.cpp"}} +\`\`\` + +When you are done, output: +DONE: ` + + const messages: any[] = [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: task } + ] + + let turn = 0 + const changedFiles: string[] = [] + + while (turn < maxTurns) { + turn++ + console.log(`─ Turn ${turn}/${maxTurns} ─`) + + // Call LLM + const result = await provider.complete_text(messages, { model, max_tokens: 4096 }) + let text = result.content || '' + + // Strip GLM reasoning tags if present + text = text.replace(/<\/?think>/g, '') + + // Show LLM response summary + const preview = text.slice(0, 100).replace(/\n/g, ' ') + console.log(` LLM: ${preview}...`) + + // Parse tool calls FIRST (before DONE check — LLM may emit both) + const tools = parseToolCalls(text) + + if (tools.length > 0) { + console.log(` Tools: ${tools.map(t => t.name).join(', ')}`) + + // Add assistant message + messages.push({ role: 'assistant', content: text }) + + // Execute each tool call + for (const tool of tools) { + try { + const toolResult = await toolRegistry.call( + { call_id: `ask-${Date.now()}`, name: tool.name, arguments: tool.args }, + ctx + ) + + const output = toolResult.status === 'ok' + ? JSON.stringify(toolResult.output).slice(0, 500) + : `Error: ${JSON.stringify(toolResult.error)}` + + console.log(` ✓ ${tool.name}: ${output.slice(0, 100)}`) + + if (tool.name === 'fs.write' || tool.name === 'fs.edit') { + changedFiles.push(String(tool.args.path || 'unknown')) + } + + messages.push({ + role: 'user', + content: `Tool ${tool.name} result: ${output}` + }) + } catch (e: any) { + messages.push({ + role: 'user', + content: `Tool ${tool.name} error: ${e.message}` + }) + } + } + + // After executing tools, check for DONE + if (text.includes('DONE:')) { + const summary = text.split('DONE:')[1]?.trim() || 'Task completed' + console.log(`\n✅ ${summary}`) + break + } + + // Ask LLM to continue with remaining work + messages.push({ + role: 'user', + content: 'Tools executed successfully. If the task is complete, respond with DONE: . Otherwise continue with more tool calls.' + }) + continue + } + + // No tool calls — LLM is just talking + // Check for DONE (no tools to execute) + if (text.includes('DONE:')) { + const summary = text.split('DONE:')[1]?.trim() || 'Task completed' + console.log(`\n✅ ${summary}`) + break + } + } + + if (turn >= maxTurns) { + console.log(`\n⚠️ Reached max ${maxTurns} turns. Task may be incomplete.`) + } + + if (changedFiles.length > 0) { + console.log(`\nChanged files: ${changedFiles.join(', ')}`) + } +} + +function parseToolCalls(text: string): Array<{ name: string; args: Record }> { + const calls: Array<{ name: string; args: Record }> = [] + + // Pattern 1: ```tool or ```json code blocks + const blockRe = /```(?:tool|json)\s*\n?([\s\S]*?)```/g + for (const match of text.matchAll(blockRe)) { + try { + const parsed = JSON.parse(match[1].trim()) + if (parsed.tool) calls.push({ name: parsed.tool, args: parsed.args || {} }) + } catch { /* skip */ } + } + + // Pattern 2: {"tool": "..."} inline JSON anywhere in text + const jsonRe = /\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{[^}]+\})\s*\}/g + for (const match of text.matchAll(jsonRe)) { + try { + const name = match[1] + const args = JSON.parse(match[2]) + if (!calls.some(c => c.name === name)) { + calls.push({ name, args }) + } + } catch { /* skip */ } + } + + return calls +} + +function createProvider(model: string): any { + const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY || '' + const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' + + if (!apiKey) { + console.log('⚠️ No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.') + console.log(' Export: export AIRCODING_API_KEY="your-key"') + process.exit(1) + } + + const adapter = new OpenAICompatibleAdapter({ + base_url: apiUrl, + api_key: apiKey, + model + }) + + // Wrap adapter in a simple provider interface + return { + adapters: new Map([['openai-compatible', adapter]]), + current_adapter: adapter, + current_model: model, + async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}) { + return (adapter as any).complete_text(messages, options) + } + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6d32ae0..c8f40c2 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -31,6 +31,7 @@ import { sessionCommand } from './commands/session.js' import { restoreCommand } from './commands/restore.js' import { e2eCommand } from './commands/e2e.js' import { releaseCommand } from './commands/release.js' +import { askCommand } from './commands/ask.js' export async function main(argv: string[]): Promise { const args = argv.slice(2) @@ -42,6 +43,13 @@ export async function main(argv: string[]): Promise { await runCommand(rest[0]) break + case 'ask': + await askCommand(rest.join(' '), { + model: rest.find(a => a.startsWith('--model='))?.split('=')[1], + maxTurns: rest.find(a => a.startsWith('--turns='))?.split('=')[1] ? parseInt(rest.find(a => a.startsWith('--turns='))!.split('=')[1]) : undefined + }) + break + case 'init': await initCommand(rest[0]) break @@ -107,6 +115,7 @@ AirCoding V1.0.0 Alpha Usage: air [args...] Commands: + ask "" Ask the AI to implement a task run [project] Start a session (spawns TUI) init Initialize a new AirCoding project doctor [--fix] Run diagnostic checks diff --git a/packages/runtime/src/agents/main/MainAgent.ts b/packages/runtime/src/agents/main/MainAgent.ts index 6bfe32f..eef40b6 100755 --- a/packages/runtime/src/agents/main/MainAgent.ts +++ b/packages/runtime/src/agents/main/MainAgent.ts @@ -134,20 +134,28 @@ export class MainAgent { } /** - * Regex-based intent classification (Alpha scope, 5 patterns). + * Regex-based intent classification (Alpha scope, supports EN + 中文). */ private classify_regex(message: string): string { const lower = message.toLowerCase() - if (/^(what|how|why|when|where|who|can you|could you|explain)/.test(lower)) { + // Questions + if (/^(what|how|why|when|where|who|can you|could you|explain|什么是|怎么|如何|为什么|什么意思)/.test(lower)) { return 'simple_question' } - if (/^(\/direct|\/done|implement|create|build|write|add|fix|change|update|remove|delete|refactor)/.test(lower)) { + // Implementation requests — English + if (/^(\/direct|\/done|implement|create|build|write|add|fix|change|update|remove|delete|refactor|make|generate)/.test(lower)) { return 'implementation_request' } - if (/^(run|execute|test|debug|check|inspect)/.test(lower)) { + // Implementation requests — 中文 + if (/创建|写|开发|实现|生成|建立|构建|编译|修改|删除|添加|增加|修复|重构|制作/.test(message)) { + return 'implementation_request' + } + + // Direct commands + if (/^(run|execute|test|debug|check|inspect|运行|执行|测试|调试|检查)/.test(lower)) { return 'direct_command' }