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-06-07 10:35:21 +08:00
import { dirname , join } from 'node:path' ;
import { fileURLToPath , pathToFileURL } from 'node:url' ;
2026-06-09 18:48:46 +08:00
import { execFileSync , spawn } 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-06-09 18:48:46 +08:00
const REASONIX_BIN = process . env . MNOTE_REASONIX_BIN || 'reasonix' ;
const REASONIX_ACP_BACKEND = ( process . env . MNOTE_REASONIX_ACP_BACKEND || 'auto' ). trim (). toLowerCase ();
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 ();
2026-06-08 20:35:49 +08:00
const MNOTE_UI_CITATION_QUEUE = [];
2026-05-18 17:01:35 +08:00
2026-06-07 10:35:21 +08:00
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.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_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 ) } ` );
}
}
2026-06-09 18:48:46 +08:00
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 );
});
}
2026-05-18 17:01:35 +08:00
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-07 01:10:31 +08:00
'mnote.knowledge_rag.status' ,
'mnote.knowledge_rag.query' ,
'mnote.knowledge_rag.open_reference' ,
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
}
2026-06-09 18:48:46 +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-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' ) {
2026-06-09 18:48:46 +08:00
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' );
}
2026-05-18 17:01:35 +08:00
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-07 01:10:31 +08:00
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' );
}
2026-06-04 18:51:16 +08:00
const evidencePayload = buildMnoteToolPayload (
2026-06-07 01:10:31 +08:00
'mnote.knowledge_rag.query' ,
2026-06-04 18:51:16 +08:00
{ 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' );
}
2026-06-07 01:10:31 +08:00
if ( isWriteMnoteTool ( 'mnote.knowledge_rag.query' )) {
throw new Error ( 'selftest expected knowledge RAG query to be read-only' );
2026-06-04 18:51:16 +08:00
}
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-06-07 10:35:21 +08:00
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' ,
);
2026-06-09 18:48:46 +08:00
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 } ` );
}
}
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 ;
2026-06-09 18:48:46 +08:00
const reasonixVersionText = readReasonixVersionText ();
if ( shouldUseNativeReasonixAcp ( reasonixVersionText )) {
proxyNativeReasonixAcp ( reasonixVersionText );
} else {
2026-05-17 16:15:52 +08:00
// ── 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
2026-06-08 20:35:49 +08:00
let rpcServerReady = false ;
const queuedRpcLines = [];
2026-05-17 16:15:52 +08:00
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 );
}
2026-06-08 20:35:49 +08:00
async function handleRpcLine ( raw ) {
2026-05-17 16:15:52 +08:00
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 );
}
2026-06-08 20:35:49 +08:00
}
// 先接住 stdio 输入,但等全部 handler 注册完成后再处理,避免 initialize 抢跑。
const rl = createInterface ({ input : stdin , terminal : false });
rl . on ( 'line' , ( raw ) => {
if ( ! rpcServerReady ) {
queuedRpcLines . push ( raw );
return ;
}
void handleRpcLine ( raw );
2026-05-17 16:15:52 +08:00
});
// ── 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` ;
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 } ` );
}
2026-06-08 20:35:49 +08:00
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 ,
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 );
2026-05-17 16:15:52 +08:00
}
// ── 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
2026-06-07 10:35:21 +08:00
function reasonixToolNameForMnoteTool ( toolName ) {
return String ( toolName || '' ). trim (). replace ( /\./g , '_' );
}
2026-05-29 21:57:29 +08:00
2026-06-07 10:35:21 +08:00
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' ],
2026-06-07 01:10:31 +08:00
},
2026-06-07 10:35:21 +08:00
parallelSafe : true ,
2026-06-04 18:51:16 +08:00
},
2026-06-07 10:35:21 +08:00
{
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' ,
2026-06-08 20:35:49 +08:00
description : '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用。不要在最终回答中手写 citationMarkdown、/documents、mnote:// 或搜索引擎包装链接;MNote 前端会把 uiCitations/citationMarkdown 自动追加成可点击来源。不要引用 raw chunks。' ,
2026-06-07 10:35:21 +08:00
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 内容' },
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 ,
},
];
}
2026-06-05 23:00:53 +08:00
2026-06-07 10:35:21 +08:00
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' ,
2026-06-05 23:00:53 +08:00
},
2026-06-07 10:35:21 +08:00
});
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 } ` );
2026-06-05 23:00:53 +08:00
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.' ,
2026-06-07 01:10:31 +08:00
'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 "让我".' ,
2026-05-29 21:57:29 +08:00
'<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-08 20:35:49 +08:00
'- 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. 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.' ,
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 = [];
2026-06-08 20:35:49 +08:00
MNOTE_UI_CITATION_QUEUE . length = 0 ;
2026-05-17 20:11:39 +08:00
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 ;
2026-06-08 20:35:49 +08:00
const resultText = toolResultTextWithUiCitations ( ev . content );
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 ────────────────────────────────────────────
2026-06-08 20:35:49 +08:00
rpcServerReady = true ;
for ( const raw of queuedRpcLines . splice ( 0 )) {
await handleRpcLine ( raw );
}
2026-05-17 16:15:52 +08:00
process . stderr . write ( `[reasonix-acp-mnote] ready (mnote= ${ MNOTE_WEB_URL } )\n` );
2026-06-09 18:48:46 +08:00
}