/** * Hermes Chat Server * * Express + SSE server that wraps Claude CLI (via ClaudeCliProtocol from aipcrm) * and exposes Agent Phone tools to Hermes. * * POST /api/hermes/chat — SSE stream, same event schema as aipcrm master-chat * GET /api/hermes/status — session list + device reachability * DEL /api/hermes/sessions/:id */ const express = require('express'); const cors = require('cors'); const path = require('path'); const fs = require('fs'); const { randomUUID } = require('crypto'); const { EventEmitter } = require('events'); const app = express(); app.use(cors()); app.use(express.json({ limit: '4mb' })); app.use(express.static(path.join(__dirname, '../public'))); // ─── Paths ─────────────────────────────────────────────────────────────────── // Reuse aipcrm's battle-tested ClaudeCliProtocol directly const AIPCRM = path.resolve('/Users/yibo/Documents/aipcrm'); const { ClaudeCliProtocol } = require(path.join(AIPCRM, 'packages/acp-core/protocol/ClaudeCliProtocol')); const { createPhoneTools } = require('./tools/phone-tools'); const PORT = process.env.PORT || 3020; const WORK_DIR = path.resolve(__dirname, '..'); // ─── Tool Registry (self-contained copy) ───────────────────────────────────── class ToolRegistry { constructor() { this.tools = new Map(); } register(tool) { this.tools.set(tool.name, tool); } registerAll(tools) { tools.forEach(t => this.register(t)); } async execute(name, params) { const tool = this.tools.get(name); if (!tool) return { error: `Unknown tool: ${name}`, status: 'error' }; try { const result = await tool.execute(params); return { result, status: 'success' }; } catch (err) { return { error: err.message, status: 'error' }; } } generateToolPrompt() { const schemas = Array.from(this.tools.values()).map(t => ({ name: t.name, description: t.description, parameters: t.parameters || {}, })); if (!schemas.length) return ''; const descs = schemas.map(s => { const props = Object.entries(s.parameters.properties || {}).map(([k, v]) => { const req = (s.parameters.required || []).includes(k); return ` - ${k} (${v.type}${req ? ', 必需' : ', 可选'}): ${v.description || ''}`; }).join('\n'); return `### ${s.name}\n${s.description}\n\n**参数:**\n${props || ' (无参数)'}`; }).join('\n\n---\n\n'); return `## 可用工具\n\n通过以下格式调用工具:\n\n\`\`\`\n[TOOL_CALL: tool_name]\n{\n "param1": "value1"\n}\n[/TOOL_CALL]\n\`\`\`\n\n${descs}`; } static parseToolCalls(output) { const regex = /\[TOOL_CALL:\s*([^\]]+)\]\s*([\s\S]*?)\[\/TOOL_CALL\]/g; const calls = []; let m; while ((m = regex.exec(output)) !== null) { const toolName = m[1].trim(); let params = {}; try { params = JSON.parse(m[2].trim()); } catch { params = { task: m[2].trim() }; } calls.push({ tool: toolName, params }); } return calls; } } // ─── Hermes Session ─────────────────────────────────────────────────────────── const SKILL_MD = fs.existsSync(path.join(__dirname, 'skill.md')) ? fs.readFileSync(path.join(__dirname, 'skill.md'), 'utf-8') : ''; class HermesSession extends EventEmitter { constructor() { super(); this.protocol = null; this.toolRegistry = new ToolRegistry(); this._initialized = false; this._busy = false; this._queue = []; this._messageCount = 0; } async init() { if (this._initialized) return; this.toolRegistry.registerAll(createPhoneTools()); this.protocol = new ClaudeCliProtocol( { name: 'hermes' }, { systemPrompt: SKILL_MD, effort: process.env.CLAUDE_EFFORT || 'medium' } ); await this.protocol.connect(null, WORK_DIR); await this.protocol.newSession(); this._initialized = true; console.log('[HermesSession] 初始化完成'); } async handleMessage(message, options = {}) { if (this._busy) { return new Promise(resolve => this._queue.push({ message, options, resolve })); } return this._execute(message, options); } async _execute(message, options = {}) { if (!this._initialized) await this.init(); this._busy = true; const { onData, onToolCall, maxToolRounds = 8 } = options; try { let prompt; if (this._messageCount === 0) { const toolPrompt = this.toolRegistry.generateToolPrompt(); prompt = `${toolPrompt}\n\n---\n\n## 用户消息\n\n${message}\n\n---\n\n**行为约束:** 需要调用工具时直接输出 [TOOL_CALL:] 块,不要先解释。`; } else { prompt = `## 用户后续消息\n\n${message}`; } this._messageCount++; let allOutput = ''; let toolCallHistory = []; for (let round = 0; round < maxToolRounds; round++) { // Collect streaming output for this round let roundOutput = ''; const textChunks = []; await new Promise((resolve, reject) => { const onUpdate = ({ update }) => { if (update?.sessionUpdate === 'agent_message_chunk' && update.content?.text) { const chunk = update.content.text; textChunks.push(chunk); roundOutput += chunk; if (onData) onData(chunk); } }; const onEvent = ({ method, params }) => { if (method === 'session/result') { this.protocol.off('session_update', onUpdate); this.protocol.off('acp_event', onEvent); resolve(params); } }; const onErr = (err) => { this.protocol.off('session_update', onUpdate); this.protocol.off('acp_event', onEvent); reject(err); }; this.protocol.once('error', onErr); this.protocol.on('session_update', onUpdate); this.protocol.on('acp_event', onEvent); this.protocol.sendPrompt(prompt).catch(onErr); }); allOutput += roundOutput + '\n'; const toolCalls = ToolRegistry.parseToolCalls(roundOutput); if (!toolCalls.length) { return { type: 'reply', output: roundOutput, toolCalls: toolCallHistory, rounds: round + 1 }; } // Execute tools and build result prompt const toolResults = []; for (const tc of toolCalls) { if (onToolCall) onToolCall(tc); const result = await this.toolRegistry.execute(tc.tool, tc.params); toolResults.push({ ...tc, result }); toolCallHistory.push({ ...tc, result }); } const resultBlocks = toolResults.map(tr => { const r = typeof tr.result === 'object' ? JSON.stringify(tr.result, null, 2) : String(tr.result); return `### 工具 ${tr.tool} 结果\n\`\`\`json\n${r}\n\`\`\``; }).join('\n\n'); prompt = `## 工具执行结果\n\n${resultBlocks}\n\n---\n\n请根据结果决定下一步:还有后续步骤则继续调用工具;全部完成则给用户最终回复。`; } return { type: 'max_rounds', output: allOutput, toolCalls: toolCallHistory, rounds: maxToolRounds }; } finally { this._busy = false; this._drainQueue(); } } _drainQueue() { if (!this._queue.length || this._busy) return; const next = this._queue.shift(); this._execute(next.message, next.options).then(next.resolve).catch(e => { next.resolve({ type: 'error', output: e.message, toolCalls: [], rounds: 0 }); }); } async shutdown() { this._queue = []; if (this.protocol) this.protocol.disconnect(); } } // ─── Session pool ───────────────────────────────────────────────────────────── const sessions = new Map(); const SESSION_IDLE_MS = 15 * 60 * 1000; function getOrCreateSession(sessionId) { let entry = sessions.get(sessionId); if (entry) { entry.lastActive = Date.now(); clearTimeout(entry.timer); entry.timer = null; return entry.session; } const session = new HermesSession(); entry = { session, lastActive: Date.now(), timer: null }; sessions.set(sessionId, entry); console.log(`[HermesChat] 新建 session: ${sessionId}`); return session; } function resetTimer(sessionId) { const entry = sessions.get(sessionId); if (!entry) return; clearTimeout(entry.timer); entry.timer = setTimeout(async () => { console.log(`[HermesChat] Session 超时: ${sessionId}`); await entry.session.shutdown().catch(() => {}); sessions.delete(sessionId); }, SESSION_IDLE_MS); } // ─── SSE helper ─────────────────────────────────────────────────────────────── function setupSSE(res) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }); return (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } // ─── Routes ─────────────────────────────────────────────────────────────────── app.post('/api/hermes/chat', async (req, res) => { const { message, sessionId: reqId } = req.body; if (!message) return res.status(400).json({ error: 'message is required' }); const sessionId = reqId || `hermes-${Date.now()}-${randomUUID().slice(0, 6)}`; const sendSSE = setupSSE(res); const session = getOrCreateSession(sessionId); sendSSE('session', { sessionId }); try { sendSSE('progress', { step: 'initializing', message: '🤖 Hermes 启动中…' }); const result = await session.handleMessage(message, { onData: (chunk) => { if (chunk?.trim()) sendSSE('agent_output', { agent: 'hermes', message: chunk, streamType: 'agent_output' }); }, onToolCall: (tc) => { sendSSE('tool_call', { tool: tc.tool, params: tc.params, step: tc.tool, message: `调用: ${tc.tool}` }); }, }); sendSSE('result', { sessionId, type: result.type, output: result.output, toolCalls: (result.toolCalls || []).map(tc => ({ tool: tc.tool, params: tc.params, status: tc.result?.status })), rounds: result.rounds, }); } catch (err) { console.error('[HermesChat] 错误:', err); sendSSE('error', { message: err.message }); } finally { resetTimer(sessionId); res.end(); } }); app.get('/api/hermes/sessions', (req, res) => { const list = []; for (const [id, e] of sessions) { list.push({ sessionId: id, lastActive: new Date(e.lastActive).toISOString(), age: Date.now() - e.lastActive }); } res.json(list); }); app.delete('/api/hermes/sessions/:id', async (req, res) => { const entry = sessions.get(req.params.id); if (!entry) return res.status(404).json({ error: 'Session not found' }); clearTimeout(entry.timer); await entry.session.shutdown().catch(() => {}); sessions.delete(req.params.id); res.json({ success: true }); }); app.get('/api/hermes/status', (req, res) => { res.json({ sessions: sessions.size, phoneUrl: process.env.PHONE_MCP_URL || 'http://localhost:8766', eventUrl: process.env.PHONE_EVENT_URL || 'http://localhost:8765', }); }); // ─── Start ──────────────────────────────────────────────────────────────────── app.listen(PORT, () => { console.log(`\n🤖 Hermes Chat 已启动`); console.log(` UI: http://localhost:${PORT}`); console.log(` API: http://localhost:${PORT}/api/hermes/chat`); console.log(` Phone: ${process.env.PHONE_MCP_URL || 'http://localhost:8766 (adb forward)'}`); console.log(` Events: ${process.env.PHONE_EVENT_URL || 'http://localhost:8765 (adb forward)'}\n`); });