#!/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 { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { execFileSync, spawn } from 'node:child_process'; const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000'; const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1'; const REASONIX_BIN = process.env.MNOTE_REASONIX_BIN || 'reasonix'; const REASONIX_ACP_BACKEND = (process.env.MNOTE_REASONIX_ACP_BACKEND || 'auto').trim().toLowerCase(); function debugLog(message) { if (DEBUG) process.stderr.write(`${message}\n`); } const toolContextStorage = new AsyncLocalStorage(); const MNOTE_UI_CITATION_QUEUE = []; const MNOTE_TOOL_NAMES = [ 'mnote.skill.read', 'mnote.context.snapshot', 'mnote.context.resolve_target', 'mnote.context.read_current_page', 'mnote.knowledge_rag.status', 'mnote.knowledge_rag.query', 'mnote.knowledge_rag.section_context', 'mnote.knowledge_rag.open_reference', ]; const REASONIX_TOOL_TO_MNOTE_TOOL = { mnote_skill_read: 'mnote.skill.read', mnote_context_snapshot: 'mnote.context.snapshot', mnote_context_resolve_target: 'mnote.context.resolve_target', mnote_context_read_current_page: 'mnote.context.read_current_page', mnote_knowledge_rag_status: 'mnote.knowledge_rag.status', mnote_knowledge_rag_query: 'mnote.knowledge_rag.query', mnote_knowledge_rag_section_context: 'mnote.knowledge_rag.section_context', mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference', }; function readRustManifestToolNames() { const scriptDir = dirname(fileURLToPath(import.meta.url)); const manifestPath = join(scriptDir, '..', 'rust', 'crates', 'mnote-web', 'src', 'hermes_tools', 'manifest.rs'); const source = readFileSync(manifestPath, 'utf8'); return Array.from(source.matchAll(/"name":\s*"([^"]+)"/g)).map((match) => match[1]); } function assertSameSortedSet(actual, expected, label) { const actualSorted = [...actual].sort(); const expectedSorted = [...expected].sort(); if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) { throw new Error(`${label} mismatch: actual=${JSON.stringify(actualSorted)} expected=${JSON.stringify(expectedSorted)}`); } } function parseReasonixMajorVersion(versionText) { const text = String(versionText || ''); const match = text.match(/\b(?:npm-v|v)?(\d+)(?:\.\d+){0,2}(?:[-+][\w.-]+)?\b/); if (!match) return null; const major = Number.parseInt(match[1], 10); return Number.isFinite(major) ? major : null; } function readReasonixVersionText() { try { return execFileSync(REASONIX_BIN, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } catch (error) { debugLog(`[reasonix-acp-mnote] unable to read ${REASONIX_BIN} --version: ${error instanceof Error ? error.message : String(error)}`); return ''; } } function shouldUseNativeReasonixAcp(versionText = readReasonixVersionText()) { if (REASONIX_ACP_BACKEND === 'legacy') return false; if (REASONIX_ACP_BACKEND === 'native') return true; const major = parseReasonixMajorVersion(versionText); return major !== null && major >= 1; } function nativeReasonixAcpArgs() { const args = ['acp']; const model = (process.env.REASONIX_MODEL || '').trim(); if (model) args.push('-model', model); const extraArgsText = (process.env.MNOTE_REASONIX_ACP_ARGS_JSON || '').trim(); if (extraArgsText) { let extraArgs; try { extraArgs = JSON.parse(extraArgsText); } catch (error) { throw new Error(`MNOTE_REASONIX_ACP_ARGS_JSON must be a JSON array of strings: ${error instanceof Error ? error.message : String(error)}`); } if (!Array.isArray(extraArgs) || extraArgs.some((value) => typeof value !== 'string')) { throw new Error('MNOTE_REASONIX_ACP_ARGS_JSON must be a JSON array of strings'); } args.push(...extraArgs); } return args; } function proxyNativeReasonixAcp(versionText = readReasonixVersionText()) { const args = nativeReasonixAcpArgs(); debugLog(`[reasonix-acp-mnote] delegating to native ${REASONIX_BIN} ${args.join(' ')} (${versionText || 'version unknown'})`); const child = spawn(REASONIX_BIN, args, { stdio: ['inherit', 'inherit', 'inherit'], env: process.env, }); for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { process.on(signal, () => { if (!child.killed) child.kill(signal); }); } child.on('error', (error) => { process.stderr.write(`FATAL: failed to spawn native Reasonix ACP (${REASONIX_BIN}): ${error.message}\n`); process.exit(1); }); child.on('exit', (code, signal) => { if (signal) { process.kill(process.pid, signal); return; } process.exit(code === null ? 1 : code); }); } function isWriteMnoteTool(toolName) { return ![ 'mnote.skill.read', 'mnote.context.snapshot', 'mnote.context.resolve_target', 'mnote.context.read_current_page', 'mnote.knowledge_rag.status', 'mnote.knowledge_rag.query', 'mnote.knowledge_rag.section_context', 'mnote.knowledge_rag.open_reference', 'mnote.doc.fetch', 'mnote.page.get', 'mnote.block.fetch', ].includes(toolName); } function toolResultStatusFromContent(content) { const text = String(content || ''); if (!text.trim()) return 'completed'; try { const payload = JSON.parse(text); if (payload && typeof payload === 'object') { if (payload.ok === false) return 'failed'; if (payload.error && payload.error !== null) return 'failed'; return 'completed'; } } catch { // 非 JSON 工具结果按普通文本处理,只识别明确错误前缀。 } return /^\s*(error|failed|exception)\b/i.test(text) ? 'failed' : 'completed'; } 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 stableJson(value) { try { return JSON.stringify(value || null, null, 2); } catch { return 'null'; } } function buildMnotePromptText(userPrompt, context = {}) { const capabilities = context.mnoteCapabilities || {}; const envelope = capabilities.agentRunEnvelope || null; if (!envelope || capabilities.attachMnoteCapabilities !== true) { return userPrompt; } const primaryPath = envelope.primaryTarget?.workspacePath?.relativePath || envelope.primaryTarget?.relativePath || ''; const allowedRoots = envelope.allowedRoots || capabilities.allowedRoots || []; const guidance = [ '', 'MNote has attached a local-first agent run envelope.', 'Use your native file tools to read and edit real files inside the current working directory and allowed roots.', 'Do not use MNote doc/page write tools for ordinary local Markdown edits.', primaryPath ? `Primary target relativePath: ${primaryPath}` : '', `Allowed roots: ${stableJson(allowedRoots)}`, `Agent run envelope: ${stableJson(envelope)}`, '', ].filter(Boolean).join('\n'); return `${guidance}\n\n${userPrompt}`; } function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) { const { actorId, sessionId, runId, toolCallId, traceId, idempotencyKey, dryRun, workspaceId, documentId, sourceKind, rootUri, profile, capabilityScope, ...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 capabilities = context.mnoteCapabilities || {}; if (!args.contextRefs && capabilities.contextRefs) args.contextRefs = capabilities.contextRefs; if (!args.agentId && capabilities.agentId) args.agentId = capabilities.agentId; if (!args.aiAccessScope && capabilities.aiAccessScope) args.aiAccessScope = capabilities.aiAccessScope; if (!args.allowedRoots && capabilities.allowedRoots) args.allowedRoots = capabilities.allowedRoots; 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 || capabilities.workspaceId || 'default', documentId: documentId || args.documentId || context.documentId || capabilities.documentId, sourceKind: sourceKind || args.sourceKind || context.sourceKind || capabilities.sourceKind, rootUri: rootUri || args.rootUri || context.rootUri || capabilities.rootUri, profile: profile || args.profile || context.profile || capabilities.profile, actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp', sessionId: effectiveSessionId, runId: effectiveRunId, toolCallId: effectiveToolCallId, traceId: effectiveTraceId, dryRun: effectiveDryRun, idempotencyKey: effectiveIdempotencyKey, capabilityScope: capabilityScope || args.capabilityScope || context.capabilityScope || capabilities.capabilityScope, }; } if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') { if (parseReasonixMajorVersion('reasonix 0.53.2') !== 0) { throw new Error('selftest expected legacy Reasonix 0.x version parsing'); } if (parseReasonixMajorVersion('reasonix npm-v1.4.0-rc.1') !== 1) { throw new Error('selftest expected native Reasonix 1.x version parsing'); } if (parseReasonixMajorVersion('unversioned') !== null) { throw new Error('selftest expected unversioned Reasonix output to be unknown'); } const payload = buildMnoteToolPayload( 'mnote.context.read_current_page', { workspaceId: 'ws_demo', documentId: 'doc_1', format: 'markdown', }, { 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'); } const contextualPayload = buildMnoteToolPayload( 'mnote.context.read_current_page', { format: 'markdown' }, { actorId: 'user_1', workspaceId: 'ws_local', documentId: 'local-md:Current.md', runId: 'run_local_1', traceId: 'trace_local_1', mnoteCapabilities: { agentId: 'reasonix', contextRefs: [{ kind: 'current_page' }], sourceKind: 'local_folder', rootUri: 'file:///tmp/mnote-local', profile: 'reasonix', aiAccessScope: { permissionLevel: 'read_write', allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'write' }], allowedResourceIds: ['local-md:Current.md'], }, }, }, ); if (contextualPayload.sourceKind !== 'local_folder') { throw new Error('selftest expected sourceKind inherited from mnoteCapabilities'); } if (contextualPayload.rootUri !== 'file:///tmp/mnote-local') { throw new Error('selftest expected rootUri inherited from mnoteCapabilities'); } if (contextualPayload.profile !== 'reasonix') { throw new Error('selftest expected profile inherited from mnoteCapabilities'); } if (!Array.isArray(contextualPayload.args.contextRefs) || contextualPayload.args.contextRefs[0]?.kind !== 'current_page') { throw new Error('selftest expected contextRefs inherited from mnoteCapabilities'); } if (contextualPayload.args.aiAccessScope?.permissionLevel !== 'read_write') { throw new Error('selftest expected aiAccessScope inherited from mnoteCapabilities'); } const promptWithEnvelope = buildMnotePromptText('请把标题改成测试标题', { mnoteCapabilities: { attachMnoteCapabilities: true, agentRunEnvelope: { schema: 'mnote.agent_run_envelope.v1', sourceKind: 'local_folder', primaryTarget: { workspacePath: { relativePath: 'docs/current.md', rootUri: 'file:///tmp/mnote-local', }, }, allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'write' }], resultPolicy: { receiptSchema: 'mnote.agent_run_receipt.v1', changedFiles: 'required', }, }, }, }); if (!promptWithEnvelope.includes('mnote.agent_run_envelope.v1')) { throw new Error('selftest expected prompt to include agentRunEnvelope schema'); } if (!promptWithEnvelope.includes('docs/current.md')) { throw new Error('selftest expected prompt to include primary target relativePath'); } if (!promptWithEnvelope.includes('native file tools')) { throw new Error('selftest expected prompt to instruct native file tools'); } if (!promptWithEnvelope.includes('Do not use MNote doc/page write tools for ordinary local Markdown edits.')) { throw new Error('selftest expected prompt to forbid MNote doc/page write tools for ordinary local Markdown edits'); } const evidencePayload = buildMnoteToolPayload( 'mnote.knowledge_rag.query', { query: 'ResourceBodyToken' }, { workspaceId: 'ws_local', mnoteCapabilities: { sourceKind: 'local_folder', rootUri: 'file:///tmp/mnote-local', aiAccessScope: { permissionLevel: 'read', allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'read' }], }, }, }, ); if (evidencePayload.rootUri !== 'file:///tmp/mnote-local') { throw new Error('selftest expected evidence payload to inherit local root context'); } if (isWriteMnoteTool('mnote.knowledge_rag.query')) { throw new Error('selftest expected knowledge RAG query to be read-only'); } const successfulEvidenceToolResult = JSON.stringify({ ok: true, error: null, result: { ok: true } }); if (toolResultStatusFromContent(successfulEvidenceToolResult) !== 'completed') { throw new Error('selftest expected ok evidence result with error:null to be completed'); } const failedEvidenceToolResult = JSON.stringify({ ok: false, error: { code: 'failed' } }); if (toolResultStatusFromContent(failedEvidenceToolResult) !== 'failed') { throw new Error('selftest expected ok:false evidence result to be failed'); } const rustKnowledgeTools = readRustManifestToolNames() .filter((toolName) => toolName.startsWith('mnote.knowledge_rag.')); const wrapperKnowledgeTools = MNOTE_TOOL_NAMES .filter((toolName) => toolName.startsWith('mnote.knowledge_rag.')); assertSameSortedSet( wrapperKnowledgeTools, rustKnowledgeTools, 'selftest expected Reasonix MNOTE_TOOL_NAMES to match Rust knowledge RAG manifest tools', ); const wrapperMappedKnowledgeTools = Object.entries(REASONIX_TOOL_TO_MNOTE_TOOL) .filter(([reasonixName]) => reasonixName.startsWith('mnote_knowledge_rag_')) .map(([, toolName]) => toolName); assertSameSortedSet( wrapperMappedKnowledgeTools, rustKnowledgeTools, 'selftest expected Reasonix wrapper tool mapping to match Rust knowledge RAG manifest tools', ); const scriptDir = dirname(fileURLToPath(import.meta.url)); const knowledgeSkillPath = join(scriptDir, '..', 'skills', 'mnote-knowledge-rag', 'SKILL.md'); const knowledgeSkillText = readFileSync(knowledgeSkillPath, 'utf8'); for (const toolName of rustKnowledgeTools) { if (!knowledgeSkillText.includes(toolName)) { throw new Error(`selftest expected mnote-knowledge-rag skill text to mention ${toolName}`); } } 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; const reasonixVersionText = readReasonixVersionText(); if (shouldUseNativeReasonixAcp(reasonixVersionText)) { proxyNativeReasonixAcp(reasonixVersionText); } else { // ── Dynamic import of reasonix public API ──────────── let CacheFirstLoop, DeepSeekClient, ToolRegistry, ImmutablePrefix; async function importReasonix() { try { return await import('reasonix'); } catch (error) { let npmRoot = ''; try { npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8' }).trim(); } catch { throw error; } const globalEntry = join(npmRoot, 'reasonix', 'dist', 'index.js'); if (!existsSync(globalEntry)) { throw error; } return import(pathToFileURL(globalEntry).href); } } try { const r = await importReasonix(); 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 let rpcServerReady = false; const queuedRpcLines = []; 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); } async function handleRpcLine(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); } } // 先接住 stdio 输入,但等全部 handler 注册完成后再处理,避免 initialize 抢跑。 const rl = createInterface({ input: stdin, terminal: false }); rl.on('line', (raw) => { if (!rpcServerReady) { queuedRpcLines.push(raw); return; } void handleRpcLine(raw); }); // ── 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 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}`); } const result = compactMnoteToolResultForReasonix(toolName, await response.json()); const citations = collectUiCitationMarkdowns(result).slice(0, 8); if (citations.length) MNOTE_UI_CITATION_QUEUE.push(citations); return result; } function compactMnoteToolResultForReasonix(toolName, payload) { if (toolName !== 'mnote.knowledge_rag.query') return payload; const result = payload?.result && typeof payload.result === 'object' ? payload.result : payload; const citationMarkdowns = collectUiCitationMarkdowns(result).slice(0, 8); const citations = citationMarkdowns .map((citationMarkdown) => ({ citationMarkdown })) .slice(0, 8); if (!citations.length || !result || typeof result !== 'object') return payload; return { ok: payload?.ok !== false, schema: result.schema || 'mnote.knowledge_rag.agent_query_result.v1', uiCitations: citations, citationRendering: 'MNote UI renders uiCitations after the answer as clickable source locators. Do not copy citationMarkdown into the final answer and do not hand-write /documents links.', answerCitationPolicy: 'Answer the substance in plain text. Mention source titles only if useful; leave clickable citation insertion to MNote UI.', answerGuidance: result.answerGuidance || '', references: Array.isArray(result.references) ? result.references.slice(0, 8) : [], citations: citationMarkdowns, documentStructureIndex: result.documentStructureIndex || null, requestedRetrievalMode: result.requestedRetrievalMode || null, effectiveRetrievalMode: result.effectiveRetrievalMode || result.retrievalMode || null, retrievalModeReason: result.retrievalModeReason || null, sourceScope: result.sourceScope || [], sourceScopeMode: result.sourceScopeMode || '', rawScopeFiltered: Boolean(result.rawScopeFiltered), }; } function collectUiCitationMarkdowns(value) { const references = Array.isArray(value?.references) ? value.references : []; const referenceCitations = []; if (references.length) { const hasPrecise = references.some((reference) => typeof reference?.citationMarkdown === 'string' && reference.citationMarkdown.trim() && reference.locatorDegraded !== true ); const seen = new Set(); for (const reference of references) { const citation = String(reference?.citationMarkdown || '').trim(); if (!citation || seen.has(citation)) continue; if (hasPrecise && reference?.locatorDegraded === true) continue; seen.add(citation); referenceCitations.push(citation); } if (referenceCitations.length) return referenceCitations; } const citations = collectCitationMarkdowns(value); const hasPrecise = citations.some((item) => !isDegradedCitationMarkdown(item)); return citations.filter((citation) => { return !hasPrecise || !isDegradedCitationMarkdown(citation); }); } function isDegradedCitationMarkdown(value) { const text = String(value || '').toLowerCase(); return text.includes('来源定位降级') || text.includes('locator degraded'); } function collectCitationMarkdowns(value) { const out = []; const seen = new Set(); function add(text) { const value = String(text || '').trim(); if (!value || seen.has(value)) return; seen.add(value); out.push(value); } function visit(node) { if (!node) return; if (typeof node === 'string') { const trimmed = node.trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { visit(JSON.parse(trimmed)); } catch {} } return; } if (Array.isArray(node)) { node.forEach(visit); return; } if (typeof node !== 'object') return; if (typeof node.citationMarkdown === 'string') add(node.citationMarkdown); Object.values(node).forEach(visit); } visit(value); return out; } function toolResultTextWithUiCitations(rawText) { const text = String(rawText || ''); let citationMarkdowns = collectUiCitationMarkdownsFromText(text).slice(0, 8); if (!citationMarkdowns.length && MNOTE_UI_CITATION_QUEUE.length) { citationMarkdowns = MNOTE_UI_CITATION_QUEUE.shift(); } const citations = citationMarkdowns.map((citationMarkdown) => ({ citationMarkdown })).slice(0, 8); if (!citations.length) return text.slice(0, 8000); const prefix = JSON.stringify({ schema: 'mnote.acp.tool_result_ui_citations.v1', uiCitations: citations, citationRendering: 'MNote UI renders these citations after the answer; the model must not hand-write local citation links.', }); const budget = Math.max(0, 8000 - prefix.length - 2); return `${prefix}\n${text.slice(0, budget)}`; } function collectUiCitationMarkdownsFromText(text) { const trimmed = String(text || '').trim(); if (trimmed.startsWith('{') || trimmed.startsWith('[')) { try { return collectUiCitationMarkdowns(JSON.parse(trimmed)); } catch {} const firstLine = trimmed.split('\n')[0]?.trim(); if (firstLine && firstLine !== trimmed && (firstLine.startsWith('{') || firstLine.startsWith('['))) { try { return collectUiCitationMarkdowns(JSON.parse(firstLine)); } catch {} } } return collectCitationMarkdowns(trimmed); } // ── Register Tools ─────────────────────────────────── const tools = new ToolRegistry(); const chatOnlyTools = new ToolRegistry(); function reasonixToolNameForMnoteTool(toolName) { return String(toolName || '').trim().replace(/\./g, '_'); } function fallbackMnoteToolSpecs() { return [ { mnoteToolName: 'mnote.skill.read', name: 'mnote_skill_read', description: '按需读取 MNote skill 正文。只有任务需要 MNote 能力时才调用。', parameters: { type: 'object', properties: { skillId: { type: 'string', description: 'MNote skill ID' }, agentId: { type: 'string', description: '当前 agent ID' }, }, required: ['skillId'], }, parallelSafe: true, }, { mnoteToolName: 'mnote.context.snapshot', name: 'mnote_context_snapshot', description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。', parameters: { type: 'object', properties: { contextRefs: { type: 'array', items: {} } } }, parallelSafe: true, }, { mnoteToolName: 'mnote.context.resolve_target', name: 'mnote_context_resolve_target', description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。', parameters: { type: 'object', properties: {} }, parallelSafe: true, }, { mnoteToolName: 'mnote.context.read_current_page', name: 'mnote_context_read_current_page', description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。', parameters: { type: 'object', properties: { documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' }, format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' }, }, }, parallelSafe: false, }, { mnoteToolName: 'mnote.knowledge_rag.status', name: 'mnote_knowledge_rag_status', description: '查看 LightRAG 资料库 provider 状态、dashboard 地址和 MNote source registry。', parameters: { type: 'object', properties: { workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' }, rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' }, }, }, parallelSafe: true, }, { mnoteToolName: 'mnote.knowledge_rag.query', name: 'mnote_knowledge_rag_query', description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用。不要在最终回答中手写 citationMarkdown、/documents、mnote:// 或搜索引擎包装链接;MNote 前端会把 uiCitations/citationMarkdown 自动追加成可点击来源。不要引用 raw chunks。', parameters: { type: 'object', properties: { query: { type: 'string', description: '资料库问题' }, question: { type: 'string', description: '资料库问题,等价于 query' }, workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' }, rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' }, mode: { type: 'string', enum: ['mix', 'local', 'global', 'naive', 'bypass'], description: 'LightRAG query mode; use mix by default for knowledge-library questions' }, topK: { type: 'integer', description: 'LightRAG top_k' }, chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' }, includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' }, includeDocumentStructureIndex: { type: 'boolean', description: '是否返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档章节导航,不替代 references 引用证据。', }, sourcePaths: { type: 'array', items: { type: 'string' }, description: '可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 provider 检索后过滤 references,raw 仍可能是全局结果。', }, }, required: ['query'], }, parallelSafe: false, }, { mnoteToolName: 'mnote.knowledge_rag.open_reference', name: 'mnote_knowledge_rag_open_reference', description: '把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时返回定位降级。', parameters: { type: 'object', properties: { reference: { type: 'object', description: 'mnote_knowledge_rag_query 返回的 reference' }, referenceId: { type: 'string' }, filePath: { type: 'string', description: 'LightRAG reference file_path' }, chunkId: { type: 'string' }, workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' }, rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' }, }, required: ['filePath'], }, parallelSafe: true, }, { mnoteToolName: 'mnote.knowledge_rag.section_context', name: 'mnote_knowledge_rag_section_context', description: '按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。', parameters: { type: 'object', properties: { sourcePath: { type: 'string', description: 'MNote workspace 相对路径,推荐从 documentStructureIndex.documents[].sourceRootRelativePath 传入' }, sourceId: { type: 'string' }, lightRagDocId: { type: 'string' }, filePath: { type: 'string', description: 'LightRAG provider file_path' }, sectionId: { type: 'string' }, startBlockOrdinal: { type: 'integer' }, endBlockOrdinal: { type: 'integer' }, startParagraphOrdinal: { type: 'integer' }, endParagraphOrdinal: { type: 'integer' }, contextBefore: { type: 'integer' }, contextAfter: { type: 'integer' }, maxBlocks: { type: 'integer' }, maxChars: { type: 'integer' }, workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' }, rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' }, }, }, parallelSafe: true, }, ]; } async function loadMnoteManifestToolSpecs() { const response = await fetch(`${MNOTE_WEB_URL}/api/hermes/tools/mnote/manifest`, { headers: { accept: 'application/json', 'x-mnote-actor-id': process.env.MNOTE_ACTOR_ID || 'reasonix-acp', 'x-mnote-actor-type': 'agent', }, }); if (!response.ok) throw new Error(`manifest_http_${response.status}`); const payload = await response.json(); const manifest = payload?.manifest && typeof payload.manifest === 'object' ? payload.manifest : payload; const toolsByName = new Map((Array.isArray(manifest?.tools) ? manifest.tools : []) .filter((tool) => tool && typeof tool.name === 'string') .map((tool) => [tool.name, tool])); const specs = MNOTE_TOOL_NAMES.map((mnoteToolName) => { const tool = toolsByName.get(mnoteToolName); if (!tool) return null; return { mnoteToolName, name: reasonixToolNameForMnoteTool(mnoteToolName), description: String(tool.description || mnoteToolName), parameters: tool.inputSchema || { type: 'object', properties: {} }, parallelSafe: mnoteToolName !== 'mnote.knowledge_rag.query' && mnoteToolName !== 'mnote.context.read_current_page', }; }).filter(Boolean); return specs.length === MNOTE_TOOL_NAMES.length ? specs : []; } function registerMnoteToolSpecs(registry, specs) { for (const spec of specs) { REASONIX_TOOL_TO_MNOTE_TOOL[spec.name] = spec.mnoteToolName; registry.register({ name: spec.name, description: spec.description, parameters: spec.parameters, readOnly: !isWriteMnoteTool(spec.mnoteToolName), fn: async (args) => callMnoteTool(spec.mnoteToolName, args), parallelSafe: Boolean(spec.parallelSafe), }); } } let mnoteToolSpecsSource = 'static_fallback'; let mnoteToolSpecs = []; try { mnoteToolSpecs = await loadMnoteManifestToolSpecs(); if (mnoteToolSpecs.length) mnoteToolSpecsSource = 'runtime_manifest'; } catch (error) { debugLog(`[reasonix-acp-mnote] manifest dynamic tool registration unavailable: ${error instanceof Error ? error.message : String(error)}`); } if (!mnoteToolSpecs.length) mnoteToolSpecs = fallbackMnoteToolSpecs(); registerMnoteToolSpecs(tools, mnoteToolSpecs); debugLog(`[reasonix-acp-mnote] registered ${mnoteToolSpecs.length} mnote tools from ${mnoteToolSpecsSource}`); // ── 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 chatSystemPrompt = [ 'You are a helpful AI assistant inside MNote.', 'For ordinary chat, reply directly.', 'Do not read or edit MNote pages, files, folders, or attachments unless MNote capabilities are explicitly attached for this prompt.', ].join('\n'); const mnoteSystemPrompt = [ 'You are a helpful AI assistant inside MNote.', 'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.', 'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.', 'Do not use MNote doc/page write tools for ordinary local Markdown edits.', 'Final answers must be direct user-facing answers. Do not narrate tool use, search steps, plans, or internal process; do not say phrases like "let me search", "I found", "I will check", or "让我".', '', 'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.', '- mnote-current-page — Read the current MNote Markdown page when the task needs page content.', '- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and answers that require links/sources/citations. For book-like results, request includeDocumentStructureIndex and then use mnote_knowledge_rag_section_context to read bounded section blocks when the map is not enough. Returned uiCitations/citationMarkdown values are MNote clickable source locators and are rendered by the MNote UI after the final answer. Do not copy citationMarkdown into the answer, do not hand-write /documents or mnote:// links, and never wrap local citation URLs with search engines. Mention source titles in plain text only when useful; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.', '- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.', '- mnote-chat-only — Reply conversationally without MNote file/page tools.', '', ].join('\n'); const chatLoop = new CacheFirstLoop({ client, tools: chatOnlyTools, prefix: new ImmutablePrefix({ system: chatSystemPrompt, toolSpecs: chatOnlyTools.specs() }), }); const mnoteLoop = new CacheFirstLoop({ client, tools, prefix: new ImmutablePrefix({ system: mnoteSystemPrompt, toolSpecs: tools.specs() }), }); sessions.set(sessionId, { id: sessionId, chatLoop, mnoteLoop, 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, mnoteCapabilities: params.mnoteCapabilities || null, }; const mnoteCapabilities = params.mnoteCapabilities || {}; const useMnoteTools = mnoteCapabilities.attachMnoteCapabilities === true; const loop = useMnoteTools ? session.mnoteLoop : session.chatLoop; const promptText = buildMnotePromptText(text, toolContext); let stopReason = 'end_turn'; let hasAssistantOutput = false; let hasToolCall = false; let nextToolCallSeq = 1; const announcedToolKeys = new Set(); const preparingToolCallIds = []; const inflightToolCallIds = []; MNOTE_UI_CITATION_QUEUE.length = 0; 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 loop.step(promptText)) { 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 = toolResultTextWithUiCitations(ev.content); emitToolResult( session.id, inflightToolCallIds.shift() || ev.callId || nextToolCallId(), toolResultStatusFromContent(resultText), 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 ──────────────────────────────────────────── rpcServerReady = true; for (const raw of queuedRpcLines.splice(0)) { await handleRpcLine(raw); } process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`); }