#!/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 { AsyncLocalStorage } from 'node:async_hooks'; 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'; const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1'; function debugLog(message) { if (DEBUG) process.stderr.write(`${message}\n`); } const toolContextStorage = new AsyncLocalStorage(); function isWriteMnoteTool(toolName) { return !['mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch'].includes(toolName); } function stableIdPart(value, fallback) { const text = String(value || fallback || '') .trim() .replace(/[^a-zA-Z0-9_.:-]+/g, '_') .slice(0, 80); return text || String(fallback || 'unknown'); } function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) { const { actorId, sessionId, runId, toolCallId, traceId, idempotencyKey, dryRun, workspaceId, ...args } = rawArgs || {}; const effectiveSessionId = sessionId || context.sessionId || context.acpSessionId || `reasonix_acp_session_${randomUUID()}`; const effectiveRunId = runId || context.runId || effectiveSessionId; const effectiveToolCallId = toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`; const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`; const writeTool = isWriteMnoteTool(toolName); const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false; const effectiveIdempotencyKey = idempotencyKey || `idem_${stableIdPart(toolName, 'tool')}_${stableIdPart(effectiveRunId, 'run')}_${stableIdPart(effectiveToolCallId, 'call')}`; return { toolName, args, workspaceId: workspaceId || context.workspaceId || 'default', documentId: args.documentId || context.documentId, actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp', sessionId: effectiveSessionId, runId: effectiveRunId, toolCallId: effectiveToolCallId, traceId: effectiveTraceId, dryRun: effectiveDryRun, idempotencyKey: effectiveIdempotencyKey, }; } if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') { const payload = buildMnoteToolPayload( 'mnote.doc.markdown_edit', { workspaceId: 'ws_demo', documentId: 'doc_1', operations: [{ search: '旧', replace: '新' }], }, { actorId: 'user_1', sessionId: 'sess_1', runId: 'run_1', traceId: 'trace_1', }, ); const required = [ 'toolName', 'workspaceId', 'documentId', 'actorId', 'sessionId', 'runId', 'toolCallId', 'traceId', 'dryRun', 'idempotencyKey', ]; for (const key of required) { if (payload[key] === undefined || payload[key] === null || payload[key] === '') { throw new Error(`selftest missing ${key}`); } } if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) { throw new Error('selftest expected identity fields outside args'); } process.stderr.write('[reasonix-acp-mnote] selftest ok\n'); process.exit(0); } // 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。 function loadApiKey() { if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY; const jsonConfigPath = join(homedir(), '.reasonix', 'config.json'); if (existsSync(jsonConfigPath)) { try { const raw = readFileSync(jsonConfigPath, 'utf-8'); const parsed = JSON.parse(raw); if (typeof parsed?.apiKey === 'string' && parsed.apiKey.trim()) { return parsed.apiKey.trim(); } } catch { // 配置损坏时继续尝试旧 YAML,不让启动阶段吞掉更明确的错误。 } } const configPath = join(homedir(), '.reasonix', 'config.yaml'); if (existsSync(configPath)) { const raw = readFileSync(configPath, 'utf-8'); // 简单提取 YAML key,避免为 wrapper 额外引入 yaml parser。 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 apiKey in ~/.reasonix/config.json\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.*', ]; const REASONIX_TOOL_TO_MNOTE_TOOL = { mnote_doc_fetch: 'mnote.doc.fetch', mnote_doc_markdown_edit: 'mnote.doc.markdown_edit', }; async function callMnoteTool(toolName, args) { const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`; const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {}); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-mnote-actor-id': payload.actorId, 'x-mnote-workspace-id': payload.workspaceId, }, body: JSON.stringify(payload), }); 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: '读取当前 mnote 文档的 markdown 内容。返回文档标题和正文。', parameters: { type: 'object', properties: { documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' }, format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' }, }, }, readOnly: true, fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.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'], }, fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.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: true }, 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(); const toolContext = { acpSessionId: params.sessionId, sessionId: params.mnoteSessionId || params.sessionId, runId: params.runId || params.mnoteRunId || params.sessionId, actorId: params.actorId || params.mnoteActorId, traceId: params.traceId || params.mnoteTraceId, workspaceId: params.workspaceId, documentId: params.documentId, }; let stopReason = 'end_turn'; let hasAssistantOutput = false; let hasToolCall = false; let nextToolCallSeq = 1; const announcedToolKeys = new Set(); const preparingToolCallIds = []; const inflightToolCallIds = []; function nextToolCallId() { return `tc_${nextToolCallSeq++}`; } function toolKeyFor(ev) { if (ev.toolCallIndex !== undefined && ev.toolCallIndex !== null) { return `${ev.turn ?? 0}:${ev.toolCallIndex}`; } return `${ev.turn ?? 0}:${ev.toolName || 'tool'}:${nextToolCallSeq}`; } function parseToolArgs(raw) { if (!raw) return undefined; try { return JSON.parse(raw); } catch { return raw; } } try { await toolContextStorage.run(toolContext, async () => { for await (const ev of session.loop.step(text)) { if (session.aborter?.signal.aborted) { stopReason = 'cancelled'; break; } debugLog(`[reasonix-acp] loop event role=${ev.role}`); // Reasonix 的 LoopEvent 使用 role 字段;这里按官方 Eventizer 的核心语义映射到 ACP。 switch (ev.role) { case 'assistant_delta': { if (ev.content) { hasAssistantOutput = true; emitTextDelta(session.id, ev.content); } if (ev.reasoningDelta) { emitThoughtDelta(session.id, ev.reasoningDelta); } break; } case 'assistant_final': { if (ev.content && !hasAssistantOutput) { hasAssistantOutput = true; emitTextDelta(session.id, ev.content); } else if (ev.content) { hasAssistantOutput = true; } break; } case 'done': { if (ev.content && !hasAssistantOutput) { hasAssistantOutput = true; emitTextDelta(session.id, ev.content); } break; } case 'tool_call_delta': { hasToolCall = true; const key = toolKeyFor(ev); if (announcedToolKeys.has(key)) break; announcedToolKeys.add(key); const toolCallId = nextToolCallId(); preparingToolCallIds.push(toolCallId); emitToolCall( session.id, toolCallId, ev.toolName || 'tool', 'other', 'pending', undefined, ); break; } case 'tool_start': { hasToolCall = true; const toolCallId = preparingToolCallIds.shift() || ev.callId || nextToolCallId(); inflightToolCallIds.push(toolCallId); emitToolCall( session.id, toolCallId, ev.toolName || 'tool', 'other', 'in_progress', parseToolArgs(ev.toolArgs), ); break; } case 'tool': { hasToolCall = true; const resultText = String(ev.content || '').slice(0, 8000); emitToolResult( session.id, inflightToolCallIds.shift() || ev.callId || nextToolCallId(), resultText.includes('"error"') ? 'failed' : 'completed', resultText, ); break; } case 'error': { const message = ev.error || ev.content || 'Reasonix loop error'; emitTextDelta(session.id, `\n\n[error] ${message}`); stopReason = 'error'; break; } case 'warning': case 'status': { if (ev.content) emitThoughtDelta(session.id, ev.content); break; } } // Usage info from stats if (ev.stats) { emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0); } } }); } catch (err) { const message = err.message || String(err); const stack = err.stack || ''; process.stderr.write(`[reasonix-acp] prompt error: ${message}\n${stack}\n`); 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 (!hasAssistantOutput && !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`);