#!/usr/bin/env node /** * Reasonix ACP wrapper for mnote-web. * * Spawned by mnote-web's AcpRuntimeManager as: * node scripts/reasonix-acp-wrapper.mjs * * Implements the ACP (Agent Client Protocol) JSON-RPC 2.0 server over stdio. * This is a self-contained NDJSON implementation — no dependency on AcpServer * (which is a Reasonix internal module not shipped in the npm package). * * Registers mnote tools as HTTP calls to mnote-web's tool endpoints. * * Reference: DeepSeek-Reasonix-main/src/cli/commands/acp.ts * * Environment: * DEEPSEEK_API_KEY — DeepSeek API key (required) * MNOTE_WEB_URL — mnote-web base URL (default: http://127.0.0.1:3000) */ import { createInterface } from 'node:readline'; import { stdin, stdout } from 'node:process'; import { randomUUID } from 'node:crypto'; import { readFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000'; // Load DeepSeek API key — same logic as Reasonix's loadApiKey(): // 1. DEEPSEEK_API_KEY env var // 2. ~/.reasonix/config.yaml (yaml: api_key or apiKey) function loadApiKey() { if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY; const configPath = join(homedir(), '.reasonix', 'config.yaml'); if (existsSync(configPath)) { const raw = readFileSync(configPath, 'utf-8'); // Simple YAML key extraction (no yaml parser dependency needed) const match = raw.match(/^\s*(?:api_key|apiKey)\s*:\s*['"]?(.+?)['"]?\s*$/m); if (match) return match[1].trim(); } return null; } const DEEPSEEK_API_KEY = loadApiKey(); if (!DEEPSEEK_API_KEY) { process.stderr.write('FATAL: DEEPSEEK_API_KEY is required\n'); process.stderr.write('Set env var DEEPSEEK_API_KEY or put api_key in ~/.reasonix/config.yaml\n'); process.exit(1); } process.env.DEEPSEEK_API_KEY = DEEPSEEK_API_KEY; // ── Dynamic import of reasonix public API ──────────── let CacheFirstLoop, DeepSeekClient, ToolRegistry, ImmutablePrefix; try { const r = await import('reasonix'); CacheFirstLoop = r.CacheFirstLoop; DeepSeekClient = r.DeepSeekClient; ToolRegistry = r.ToolRegistry; ImmutablePrefix = r.ImmutablePrefix; } catch (e) { process.stderr.write(`FATAL: reasonix not found — run "npm install reasonix"\n`); process.stderr.write(`Error: ${e}\n`); process.exit(1); } // ── Simple NDJSON JSON-RPC 2.0 Server ─────────────── // Reference: Reasonix src/acp/server.ts (conceptual) let nextId = 1; const pendingReqs = new Map(); // id → { resolve, reject } const requestHandlers = new Map(); // method → handler const notificationHandlers = new Map(); // method → handler function sendMessage(msg) { const line = JSON.stringify(msg) + '\n'; stdout.write(line); } function sendResponse(id, result) { sendMessage({ jsonrpc: '2.0', id, result }); } function sendError(id, code, message) { sendMessage({ jsonrpc: '2.0', id, error: { code, message } }); } function sendNotification(method, params) { sendMessage({ jsonrpc: '2.0', method, params }); } function onRequest(method, handler) { requestHandlers.set(method, handler); } function onNotification(method, handler) { notificationHandlers.set(method, handler); } // Start reading NDJSON from stdin const rl = createInterface({ input: stdin, terminal: false }); rl.on('line', async (raw) => { const trimmed = raw.trim(); if (!trimmed) return; let msg; try { msg = JSON.parse(trimmed); } catch { sendError(null, -32700, 'Parse error'); return; } const hasId = msg.id !== undefined && msg.id !== null; const hasMethod = typeof msg.method === 'string' && msg.method.length > 0; if (hasId && hasMethod) { // Incoming request from client const handler = requestHandlers.get(msg.method); if (!handler) { sendError(msg.id, -32601, `Method not found: ${msg.method}`); return; } try { const result = await handler(msg.params); sendResponse(msg.id, result); } catch (err) { sendError(msg.id, err.code || -32603, err.message || String(err)); } } else if (hasId) { // Response to our outgoing request const pending = pendingReqs.get(msg.id); if (pending) { pendingReqs.delete(msg.id); if (msg.error) { pending.reject(new Error(msg.error.message)); } else { pending.resolve(msg.result); } } } else if (hasMethod) { // Notification from client const handler = notificationHandlers.get(msg.method); if (handler) handler(msg.params); } }); // ── Helper: send ACP session/update (camelCase per protocol spec) ── function emitSessionUpdate(sessionId, update) { sendNotification('session/update', { sessionId, update }); } function emitTextDelta(sessionId, text) { emitSessionUpdate(sessionId, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text }, }); } function emitThoughtDelta(sessionId, text) { emitSessionUpdate(sessionId, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text }, }); } function emitToolCall(sessionId, toolCallId, title, kind, status, rawInput) { emitSessionUpdate(sessionId, { sessionUpdate: 'tool_call', toolCallId, title, kind, status, rawInput, }); } function emitToolResult(sessionId, toolCallId, status, text) { emitSessionUpdate(sessionId, { sessionUpdate: 'tool_call_update', toolCallId, status, content: text ? [{ type: 'content', content: { type: 'text', text } }] : undefined, }); } function emitUsage(sessionId, used, size) { emitSessionUpdate(sessionId, { sessionUpdate: 'usage_update', used, size, }); } // ── MNOTE Tool Implementations ─────────────────────── // Calls mnote-web's Rust tool endpoints via HTTP. // Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs const MNOTE_TOOL_NAMES = [ 'mnote.doc.fetch', 'mnote.doc.markdown_edit', 'mnote.block.*', 'mnote.page.*', ]; async function callMnoteTool(toolName, args) { const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ toolName, args, workspaceId: args.workspaceId || 'default', }), }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(`mnote tool ${toolName} failed: HTTP ${response.status} ${text}`); } return response.json(); } // ── Register Tools ─────────────────────────────────── const tools = new ToolRegistry(); tools.register({ name: 'mnote.doc.fetch', description: '读取当前文档的 markdown 内容。返回文档标题和正文。', parameters: { type: 'object', properties: { documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' }, format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' }, }, }, call: async (args) => callMnoteTool('mnote.doc.fetch', args), parallelSafe: false, }); tools.register({ name: 'mnote.doc.markdown_edit', description: '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。', parameters: { type: 'object', properties: { documentId: { type: 'string', description: '文档 ID' }, operations: { type: 'array', items: { type: 'object', properties: { search: { type: 'string', description: '要搜索的文本片段' }, replace: { type: 'string', description: '替换后的文本' }, }, required: ['search', 'replace'], }, description: '搜索替换操作列表', }, }, required: ['documentId', 'operations'], }, call: async (args) => callMnoteTool('mnote.doc.markdown_edit', args), parallelSafe: false, }); // ── Session Store ──────────────────────────────────── const sessions = new Map(); // ── ACP: initialize ────────────────────────────────── onRequest('initialize', (params) => { return { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false }, mcpCapabilities: { http: false, sse: false }, }, agentInfo: { name: 'reasonix-mnote', title: 'Reasonix MNote Agent', version: '0.1.0' }, authMethods: [], }; }); // ── ACP: session/new ───────────────────────────────── // Creates a CacheFirstLoop for the session. onRequest('session/new', async (params) => { const sessionId = randomUUID(); const client = new DeepSeekClient({ apiKey: DEEPSEEK_API_KEY }); const systemPrompt = [ 'You are a helpful AI assistant for editing Markdown documents.', 'Use mnote.doc.fetch to read the current document.', 'Use mnote.doc.markdown_edit to apply precise search/replace edits.', 'Always use mnote.doc.fetch first to understand the document content before editing.', ].join('\n'); const loop = new CacheFirstLoop({ client, tools, prefix: new ImmutablePrefix({ system: systemPrompt, toolSpecs: tools.specs() }), }); sessions.set(sessionId, { id: sessionId, loop, client, aborter: null }); return { sessionId }; }); // ── ACP: session/prompt ───────────────────────────── // Runs loop.step() and emits ACP session/update events. onRequest('session/prompt', async (params) => { if (!params?.sessionId) { throw Object.assign(new Error('Missing sessionId'), { code: -32602 }); } const session = sessions.get(params.sessionId); if (!session) { throw Object.assign(new Error(`Unknown session: ${params.sessionId}`), { code: -32602 }); } const blocks = params.prompt || []; const text = blocks .map((b) => (b.type === 'text' ? b.text : b.resource?.text || '')) .filter(Boolean) .join('\n\n') .trim(); if (!text) { throw Object.assign(new Error('Empty prompt'), { code: -32602 }); } session.aborter = new AbortController(); let stopReason = 'end_turn'; let hasModelOutput = false; let hasToolCall = false; try { for await (const ev of session.loop.step(text)) { if (session.aborter?.signal.aborted) { stopReason = 'cancelled'; break; } switch (ev.type) { case 'model_delta': { hasModelOutput = true; if (ev.text) emitTextDelta(session.id, ev.text); break; } case 'model_thinking': { hasModelOutput = true; if (ev.text) emitThoughtDelta(session.id, ev.text); break; } case 'tool_call_delta': { hasToolCall = true; const call = ev.toolCall || {}; emitToolCall( session.id, call.id || `tc_${Date.now()}`, call.name || ev.name || 'tool', 'other', 'pending', call.args || ev.args, ); break; } case 'tool_start': { const call = ev.toolCall || {}; emitToolCall( session.id, call.id || `tc_${Date.now()}`, call.name || ev.name || 'tool', 'other', 'in_progress', call.args || ev.args, ); break; } case 'tool_result': { const resultText = typeof ev.result === 'string' ? ev.result.slice(0, 8000) : JSON.stringify(ev.result).slice(0, 8000); emitToolResult( session.id, ev.toolCallId || `tc_${Date.now()}`, ev.error ? 'failed' : 'completed', resultText, ); break; } case 'usage': { emitUsage(session.id, ev.inputTokens || 0, ev.cacheHitTokens || 0); break; } } } } catch (err) { const message = err.message || String(err); emitTextDelta(session.id, `\n\n[error] ${message}`); stopReason = 'error'; } finally { // If no model output or tool calls were produced, it means the LLM backend failed. // This can happen when the API key is invalid, base URL is wrong, or model is unavailable. if (!hasModelOutput && !hasToolCall && stopReason !== 'error') { stopReason = 'error'; emitTextDelta(session.id, '\n\n[error] AI 模型未返回输出。可能原因:API Key 无效、模型不可用、或网络连接失败。请检查 DEEPSEEK_API_KEY 是否正确设置(以 sk- 开头)。'); } session.aborter = null; } return { stopReason }; }); // ── ACP: session/cancel (notification) ────────────── onNotification('session/cancel', (params) => { const session = params?.sessionId ? sessions.get(params.sessionId) : undefined; if (session?.aborter) { session.aborter.abort(); } }); // ── Start ──────────────────────────────────────────── process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);