2026-05-17 16:15:52 +08:00
#!/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' ;
2026-05-18 17:01:35 +08:00
import { AsyncLocalStorage } from 'node:async_hooks' ;
2026-05-19 13:24:34 +08:00
import { readFileSync , existsSync } from 'node:fs' ;
2026-05-17 16:15:52 +08:00
import { homedir } from 'node:os' ;
2026-05-19 13:24:34 +08:00
import { join } from 'node:path' ;
2026-05-21 23:53:39 +08:00
import { pathToFileURL } from 'node:url' ;
import { execFileSync } from 'node:child_process' ;
2026-05-17 16:15:52 +08:00
const MNOTE_WEB_URL = process . env . MNOTE_WEB_URL || 'http://127.0.0.1:3000' ;
2026-05-17 20:11:39 +08:00
const DEBUG = process . env . MNOTE_REASONIX_ACP_DEBUG === '1' ;
2026-05-17 16:15:52 +08:00
2026-05-17 20:11:39 +08:00
function debugLog ( message ) {
if ( DEBUG ) process . stderr . write ( ` ${ message } \n` );
}
2026-05-18 17:01:35 +08:00
const toolContextStorage = new AsyncLocalStorage ();
function isWriteMnoteTool ( toolName ) {
2026-05-29 21:57:29 +08:00
return ! [
'mnote.skill.read' ,
'mnote.context.snapshot' ,
'mnote.context.resolve_target' ,
'mnote.context.read_current_page' ,
2026-06-04 18:51:16 +08:00
'mnote.evidence.search' ,
'mnote.evidence.read' ,
'mnote.evidence.open' ,
2026-06-05 23:00:53 +08:00
'mnote.index.status' ,
'mnote.index.refresh' ,
2026-05-29 21:57:29 +08:00
'mnote.doc.fetch' ,
'mnote.page.get' ,
'mnote.block.fetch' ,
]. includes ( toolName );
2026-05-18 17:01:35 +08:00
}
function stableIdPart ( value , fallback ) {
const text = String ( value || fallback || '' )
. trim ()
. replace ( /[^a-zA-Z0-9_.:-]+/g , '_' )
. slice ( 0 , 80 );
return text || String ( fallback || 'unknown' );
}
2026-06-01 09:29:12 +08:00
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-agent-run>' ,
'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 ) } ` ,
'</mnote-agent-run>' ,
]. filter ( Boolean ). join ( '\n' );
return ` ${ guidance } \n\n ${ userPrompt } ` ;
}
2026-05-18 17:01:35 +08:00
function buildMnoteToolPayload ( toolName , rawArgs = {}, context = {}) {
const {
actorId ,
sessionId ,
runId ,
toolCallId ,
traceId ,
idempotencyKey ,
dryRun ,
workspaceId ,
2026-05-29 21:57:29 +08:00
documentId ,
sourceKind ,
rootUri ,
profile ,
capabilityScope ,
2026-05-18 17:01:35 +08:00
... 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 );
2026-05-29 21:57:29 +08:00
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 ;
2026-05-18 17:01:35 +08:00
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 ,
2026-05-29 21:57:29 +08:00
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 ,
2026-05-18 17:01:35 +08:00
actorId : actorId || context . actorId || process . env . MNOTE_ACTOR_ID || 'reasonix-acp' ,
sessionId : effectiveSessionId ,
runId : effectiveRunId ,
toolCallId : effectiveToolCallId ,
traceId : effectiveTraceId ,
dryRun : effectiveDryRun ,
idempotencyKey : effectiveIdempotencyKey ,
2026-05-29 21:57:29 +08:00
capabilityScope : capabilityScope || args . capabilityScope || context . capabilityScope || capabilities . capabilityScope ,
2026-05-18 17:01:35 +08:00
};
}
if ( process . env . MNOTE_REASONIX_ACP_SELFTEST === '1' ) {
const payload = buildMnoteToolPayload (
2026-05-29 21:57:29 +08:00
'mnote.context.read_current_page' ,
2026-05-18 17:01:35 +08:00
{
workspaceId : 'ws_demo' ,
documentId : 'doc_1' ,
2026-05-29 21:57:29 +08:00
format : 'markdown' ,
2026-05-18 17:01:35 +08:00
},
{
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' );
}
2026-05-29 21:57:29 +08:00
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' );
}
2026-06-01 09:29:12 +08:00
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' );
}
2026-06-04 18:51:16 +08:00
const evidencePayload = buildMnoteToolPayload (
'mnote.evidence.search' ,
{ 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.evidence.search' )) {
throw new Error ( 'selftest expected evidence search 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' );
}
2026-05-18 17:01:35 +08:00
process . stderr . write ( '[reasonix-acp-mnote] selftest ok\n' );
process . exit ( 0 );
}
2026-05-17 20:11:39 +08:00
// 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。
2026-05-17 16:15:52 +08:00
function loadApiKey () {
if ( process . env . DEEPSEEK_API_KEY ) return process . env . DEEPSEEK_API_KEY ;
2026-05-17 20:11:39 +08:00
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,不让启动阶段吞掉更明确的错误。
}
}
2026-05-17 16:15:52 +08:00
const configPath = join ( homedir (), '.reasonix' , 'config.yaml' );
if ( existsSync ( configPath )) {
const raw = readFileSync ( configPath , 'utf-8' );
2026-05-17 20:11:39 +08:00
// 简单提取 YAML key,避免为 wrapper 额外引入 yaml parser。
2026-05-17 16:15:52 +08:00
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' );
2026-05-17 20:11:39 +08:00
process . stderr . write ( 'Set env var DEEPSEEK_API_KEY, or put apiKey in ~/.reasonix/config.json\n' );
2026-05-17 16:15:52 +08:00
process . exit ( 1 );
}
process . env . DEEPSEEK_API_KEY = DEEPSEEK_API_KEY ;
// ── Dynamic import of reasonix public API ────────────
let CacheFirstLoop , DeepSeekClient , ToolRegistry , ImmutablePrefix ;
2026-05-21 23:53:39 +08:00
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 );
}
}
2026-05-17 16:15:52 +08:00
try {
2026-05-21 23:53:39 +08:00
const r = await importReasonix ();
2026-05-17 16:15:52 +08:00
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 ,
});
}
2026-06-04 18:51:16 +08:00
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' ;
}
2026-05-17 16:15:52 +08:00
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 = [
2026-05-29 21:57:29 +08:00
'mnote.skill.read' ,
'mnote.context.snapshot' ,
'mnote.context.resolve_target' ,
'mnote.context.read_current_page' ,
2026-06-04 18:51:16 +08:00
'mnote.evidence.search' ,
'mnote.evidence.read' ,
'mnote.evidence.open' ,
2026-06-05 23:00:53 +08:00
'mnote.index.status' ,
'mnote.index.refresh' ,
'mnote.index.update_settings' ,
2026-05-17 16:15:52 +08:00
];
2026-05-17 20:11:39 +08:00
const REASONIX_TOOL_TO_MNOTE_TOOL = {
2026-05-29 21:57:29 +08:00
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' ,
2026-06-04 18:51:16 +08:00
mnote_evidence_search : 'mnote.evidence.search' ,
mnote_evidence_read : 'mnote.evidence.read' ,
mnote_evidence_open : 'mnote.evidence.open' ,
2026-06-05 23:00:53 +08:00
mnote_index_status : 'mnote.index.status' ,
mnote_index_refresh : 'mnote.index.refresh' ,
mnote_index_update_settings : 'mnote.index.update_settings' ,
2026-05-17 20:11:39 +08:00
};
2026-05-17 16:15:52 +08:00
async function callMnoteTool ( toolName , args ) {
const url = ` ${ MNOTE_WEB_URL } /api/hermes/tools/mnote/call` ;
2026-05-18 17:01:35 +08:00
const payload = buildMnoteToolPayload ( toolName , args , toolContextStorage . getStore () || {});
2026-05-17 16:15:52 +08:00
const response = await fetch ( url , {
method : 'POST' ,
2026-05-18 17:01:35 +08:00
headers : {
'Content-Type' : 'application/json' ,
'x-mnote-actor-id' : payload . actorId ,
'x-mnote-workspace-id' : payload . workspaceId ,
},
body : JSON . stringify ( payload ),
2026-05-17 16:15:52 +08:00
});
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 ();
2026-05-29 21:57:29 +08:00
const chatOnlyTools = new ToolRegistry ();
2026-05-17 16:15:52 +08:00
tools . register ({
2026-05-29 21:57:29 +08:00
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' ],
},
readOnly : true ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_skill_read , args ),
parallelSafe : true ,
});
tools . register ({
name : 'mnote_context_snapshot' ,
description : '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。' ,
parameters : {
type : 'object' ,
properties : {
contextRefs : { type : 'array' , items : {} },
},
},
readOnly : true ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_context_snapshot , args ),
parallelSafe : true ,
});
tools . register ({
name : 'mnote_context_resolve_target' ,
description : '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。' ,
parameters : { type : 'object' , properties : {} },
readOnly : true ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_context_resolve_target , args ),
parallelSafe : true ,
});
tools . register ({
name : 'mnote_context_read_current_page' ,
description : '在用户任务明确需要当前页内容时读取当前 Markdown 页面。' ,
2026-05-17 16:15:52 +08:00
parameters : {
type : 'object' ,
properties : {
documentId : { type : 'string' , description : '文档 ID(可选,默认当前文档)' },
format : { type : 'string' , enum : [ 'markdown' , 'blocks' ], description : '返回格式' },
},
},
2026-05-17 20:11:39 +08:00
readOnly : true ,
2026-05-29 21:57:29 +08:00
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_context_read_current_page , args ),
2026-05-17 16:15:52 +08:00
parallelSafe : false ,
});
2026-06-04 18:51:16 +08:00
tools . register ({
name : 'mnote_evidence_search' ,
2026-06-05 23:00:53 +08:00
description : '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。查询语言与资料语言可能不一致时,先在提示层做轻量多语关键词扩展,再用简短关键词检索。' ,
2026-06-04 18:51:16 +08:00
parameters : {
type : 'object' ,
properties : {
query : { type : 'string' , description : '要搜索的问题或关键词' },
workspaceId : { type : 'string' , description : 'MNote workspace ID,可省略并使用当前上下文' },
rootUri : { type : 'string' , description : 'local folder rootUri,可省略并使用当前上下文' },
targetDocumentId : { type : 'string' , description : '可选,限制到当前文档' },
includeResources : { type : 'boolean' , description : '是否包含附件/资源标题' },
includeOcr : { type : 'boolean' , description : '是否包含 OCR/source-map 证据' },
mode : { type : 'string' , enum : [ 'hybrid' , 'tree' , 'graph' ], description : '检索模式' },
topK : { type : 'integer' , description : '最多返回结果数' },
scope : { type : 'object' , description : '完整 EvidenceSearchScope,提供时优先使用' },
},
required : [ 'query' ],
},
readOnly : true ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_evidence_search , args ),
parallelSafe : true ,
});
tools . register ({
name : 'mnote_evidence_read' ,
description : '按 EvidenceLocator 读取原文证据及周边上下文。' ,
parameters : {
type : 'object' ,
properties : {
locator : { type : 'object' , description : 'mnote_evidence_search 返回的 source/locator' },
context : {
type : 'object' ,
properties : {
beforeBlocks : { type : 'integer' },
afterBlocks : { type : 'integer' },
includeSectionSummary : { type : 'boolean' },
},
},
},
required : [ 'locator' ],
},
readOnly : true ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_evidence_read , args ),
parallelSafe : true ,
});
tools . register ({
name : 'mnote_evidence_open' ,
description : '把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。' ,
parameters : {
type : 'object' ,
properties : {
locator : { type : 'object' , description : 'mnote_evidence_search 返回的 source/locator' },
},
required : [ 'locator' ],
},
readOnly : true ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_evidence_open , args ),
parallelSafe : true ,
});
2026-06-05 23:00:53 +08:00
tools . register ({
name : 'mnote_index_status' ,
description : '查看 MNote 本地索引范围、缓存文件状态、文档数和 evidence block 数。' ,
parameters : {
type : 'object' ,
properties : {
workspaceId : { type : 'string' , description : 'MNote workspace ID,可省略并使用当前上下文' },
rootUri : { type : 'string' , description : 'local folder rootUri,可省略并使用当前上下文' },
},
},
readOnly : true ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_index_status , args ),
parallelSafe : true ,
});
tools . register ({
name : 'mnote_index_refresh' ,
description : '按当前有效范围重建 MNote 本地搜索/evidence 缓存,不修改 Markdown 正文。' ,
parameters : {
type : 'object' ,
properties : {
workspaceId : { type : 'string' , description : 'MNote workspace ID,可省略并使用当前上下文' },
rootUri : { type : 'string' , description : 'local folder rootUri,可省略并使用当前上下文' },
},
},
readOnly : true ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_index_refresh , args ),
parallelSafe : false ,
});
tools . register ({
name : 'mnote_index_update_settings' ,
description : '新增或删除 MNote 本地索引范围;includePaths 为空表示删除当前用户范围。' ,
parameters : {
type : 'object' ,
properties : {
workspaceId : { type : 'string' , description : 'MNote workspace ID,可省略并使用当前上下文' },
rootUri : { type : 'string' , description : 'local folder rootUri,可省略并使用当前上下文' },
includePaths : { type : 'array' , items : { type : 'string' }, description : 'root 内相对路径列表,空数组表示删除范围' },
scheduleMode : { type : 'string' , enum : [ 'manual' , 'daily' , 'weekly' , 'monthly' ], description : '刷新计划' },
scheduleTime : { type : 'string' , description : 'HH:mm' },
scheduleDate : { type : 'string' , description : '可选日期' },
runOnChange : { type : 'boolean' , description : '文件变化时是否自动刷新' },
dryRun : { type : 'boolean' , description : 'true 只返回计划;false 写入设置并刷新索引' },
idempotencyKey : { type : 'string' , description : '写入幂等键' },
},
required : [ 'includePaths' , 'dryRun' , 'idempotencyKey' ],
},
readOnly : false ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_index_update_settings , args ),
parallelSafe : false ,
});
2026-05-17 16:15:52 +08:00
// ── Session Store ────────────────────────────────────
const sessions = new Map ();
// ── ACP: initialize ──────────────────────────────────
onRequest ( 'initialize' , ( params ) => {
return {
protocolVersion : 1 ,
agentCapabilities : {
loadSession : false ,
2026-05-17 20:11:39 +08:00
promptCapabilities : { image : false , audio : false , embeddedContext : true },
2026-05-17 16:15:52 +08:00
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 });
2026-05-29 21:57:29 +08:00
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.' ,
2026-05-17 16:15:52 +08:00
]. join ( '\n' );
2026-05-29 21:57:29 +08:00
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.' ,
'<available-skills>' ,
'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.' ,
2026-06-05 23:00:53 +08:00
'- mnote-local-index — Search local documents with clickable evidence locators and manage local index scopes. When the query language may differ from the corpus language, infer likely corpus terms from filenames/titles/domain context, expand 2-6 concise multilingual keywords, and call mnote_evidence_search with the best short keyword queries. Do not assume the answer language from the corpus; answer in the user language and cite only retrieved evidence.' ,
2026-05-29 21:57:29 +08:00
'- 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.' ,
'</available-skills>' ,
]. join ( '\n' );
const chatLoop = new CacheFirstLoop ({
client ,
tools : chatOnlyTools ,
prefix : new ImmutablePrefix ({ system : chatSystemPrompt , toolSpecs : chatOnlyTools . specs () }),
});
const mnoteLoop = new CacheFirstLoop ({
2026-05-17 16:15:52 +08:00
client ,
tools ,
2026-05-29 21:57:29 +08:00
prefix : new ImmutablePrefix ({ system : mnoteSystemPrompt , toolSpecs : tools . specs () }),
2026-05-17 16:15:52 +08:00
});
2026-05-29 21:57:29 +08:00
sessions . set ( sessionId , { id : sessionId , chatLoop , mnoteLoop , client , aborter : null });
2026-05-17 16:15:52 +08:00
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 ();
2026-05-18 17:01:35 +08:00
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 ,
2026-05-29 21:57:29 +08:00
mnoteCapabilities : params . mnoteCapabilities || null ,
2026-05-18 17:01:35 +08:00
};
2026-05-29 21:57:29 +08:00
const mnoteCapabilities = params . mnoteCapabilities || {};
const useMnoteTools = mnoteCapabilities . attachMnoteCapabilities === true ;
const loop = useMnoteTools ? session . mnoteLoop : session . chatLoop ;
2026-06-01 09:29:12 +08:00
const promptText = buildMnotePromptText ( text , toolContext );
2026-05-17 16:15:52 +08:00
let stopReason = 'end_turn' ;
2026-05-17 20:11:39 +08:00
let hasAssistantOutput = false ;
2026-05-17 16:15:52 +08:00
let hasToolCall = false ;
2026-05-17 20:11:39 +08:00
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 ;
}
}
2026-05-17 16:15:52 +08:00
try {
2026-05-18 17:01:35 +08:00
await toolContextStorage . run ( toolContext , async () => {
2026-05-29 21:57:29 +08:00
for await ( const ev of loop . step ( promptText )) {
2026-05-17 16:15:52 +08:00
if ( session . aborter ? . signal . aborted ) {
stopReason = 'cancelled' ;
break ;
}
2026-05-17 20:11:39 +08:00
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 );
}
2026-05-17 16:15:52 +08:00
break ;
}
2026-05-17 20:11:39 +08:00
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 );
}
2026-05-17 16:15:52 +08:00
break ;
}
case 'tool_call_delta' : {
hasToolCall = true ;
2026-05-17 20:11:39 +08:00
const key = toolKeyFor ( ev );
if ( announcedToolKeys . has ( key )) break ;
announcedToolKeys . add ( key );
const toolCallId = nextToolCallId ();
preparingToolCallIds . push ( toolCallId );
2026-05-17 16:15:52 +08:00
emitToolCall (
session . id ,
2026-05-17 20:11:39 +08:00
toolCallId ,
ev . toolName || 'tool' ,
2026-05-17 16:15:52 +08:00
'other' ,
'pending' ,
2026-05-17 20:11:39 +08:00
undefined ,
2026-05-17 16:15:52 +08:00
);
break ;
}
case 'tool_start' : {
2026-05-17 20:11:39 +08:00
hasToolCall = true ;
const toolCallId = preparingToolCallIds . shift () || ev . callId || nextToolCallId ();
inflightToolCallIds . push ( toolCallId );
2026-05-17 16:15:52 +08:00
emitToolCall (
session . id ,
2026-05-17 20:11:39 +08:00
toolCallId ,
ev . toolName || 'tool' ,
2026-05-17 16:15:52 +08:00
'other' ,
'in_progress' ,
2026-05-17 20:11:39 +08:00
parseToolArgs ( ev . toolArgs ),
2026-05-17 16:15:52 +08:00
);
break ;
}
2026-05-17 20:11:39 +08:00
case 'tool' : {
hasToolCall = true ;
const resultText = String ( ev . content || '' ). slice ( 0 , 8000 );
2026-05-17 16:15:52 +08:00
emitToolResult (
session . id ,
2026-05-17 20:11:39 +08:00
inflightToolCallIds . shift () || ev . callId || nextToolCallId (),
2026-06-04 18:51:16 +08:00
toolResultStatusFromContent ( resultText ),
2026-05-17 16:15:52 +08:00
resultText ,
);
break ;
}
2026-05-17 20:11:39 +08:00
case 'error' : {
const message = ev . error || ev . content || 'Reasonix loop error' ;
emitTextDelta ( session . id , `\n\n[error] ${ message } ` );
stopReason = 'error' ;
2026-05-17 16:15:52 +08:00
break ;
}
2026-05-17 20:11:39 +08:00
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 );
2026-05-17 16:15:52 +08:00
}
2026-05-18 17:01:35 +08:00
}
});
2026-05-17 16:15:52 +08:00
} catch ( err ) {
const message = err . message || String ( err );
2026-05-17 20:11:39 +08:00
const stack = err . stack || '' ;
process . stderr . write ( `[reasonix-acp] prompt error: ${ message } \n ${ stack } \n` );
2026-05-17 16:15:52 +08:00
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.
2026-05-17 20:11:39 +08:00
if ( ! hasAssistantOutput && ! hasToolCall && stopReason !== 'error' ) {
2026-05-17 16:15:52 +08:00
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` );