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 12:29:25 +08:00
import { readFileSync , existsSync , writeFileSync } from 'node:fs' ;
2026-05-17 16:15:52 +08:00
import { homedir } from 'node:os' ;
2026-05-19 12:29:25 +08:00
import { join , resolve } from 'node:path' ;
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-19 12:29:25 +08:00
const REASONIX_ALLOWED_FS_ROOTS = ( process . env . REASONIX_ALLOWED_FS_ROOTS || '' )
. split ( ':' )
. map (( value ) => value . trim ())
. filter ( Boolean )
. map (( value ) => resolve ( value ));
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 ) {
return ! [ 'mnote.doc.fetch' , 'mnote.page.get' , 'mnote.block.fetch' ]. includes ( toolName );
}
function stableIdPart ( value , fallback ) {
const text = String ( value || fallback || '' )
. trim ()
. replace ( /[^a-zA-Z0-9_.:-]+/g , '_' )
. slice ( 0 , 80 );
return text || String ( fallback || 'unknown' );
}
2026-05-19 12:29:25 +08:00
function isPathWithinRoot ( targetPath , rootPath ) {
const normalizedRoot = rootPath . endsWith ( '/' ) ? rootPath : ` ${ rootPath } /` ;
const normalizedTarget = targetPath . endsWith ( '/' ) ? targetPath : targetPath ;
return normalizedTarget === rootPath || normalizedTarget . startsWith ( normalizedRoot );
}
function assertAllowedFsPath ( targetPath ) {
const resolved = resolve ( targetPath );
if ( ! REASONIX_ALLOWED_FS_ROOTS . length ) {
throw new Error (
'reasonix local fs tools are disabled; set REASONIX_ALLOWED_FS_ROOTS to enable them' ,
);
}
const matchedRoot = REASONIX_ALLOWED_FS_ROOTS . find (( rootPath ) => isPathWithinRoot ( resolved , rootPath ));
if ( ! matchedRoot ) {
throw new Error (
`path not allowed: ${ resolved } . allowed roots: ${ REASONIX_ALLOWED_FS_ROOTS . join ( ', ' ) } ` ,
);
}
return resolved ;
}
function readAllowedFsFile ( targetPath ) {
const resolved = assertAllowedFsPath ( targetPath );
return {
path : resolved ,
content : readFileSync ( resolved , 'utf-8' ),
};
}
function writeAllowedFsFile ( targetPath , content ) {
const resolved = assertAllowedFsPath ( targetPath );
writeFileSync ( resolved , String ( content ), 'utf-8' );
return {
path : resolved ,
written : true ,
};
}
function replaceAllowedFsFile ( targetPath , search , replace ) {
const resolved = assertAllowedFsPath ( targetPath );
const current = readFileSync ( resolved , 'utf-8' );
const next = String ( current ). split ( String ( search )). join ( String ( replace ));
if ( next === current ) {
return {
path : resolved ,
written : false ,
replaced : false ,
};
}
writeFileSync ( resolved , next , 'utf-8' );
return {
path : resolved ,
written : true ,
replaced : true ,
};
}
2026-05-18 17:01:35 +08:00
function buildMnoteToolPayload ( toolName , rawArgs = {}, context = {}) {
const {
actorId ,
sessionId ,
runId ,
toolCallId ,
traceId ,
idempotencyKey ,
dryRun ,
workspaceId ,
... args
} = rawArgs || {};
const effectiveSessionId =
sessionId || context . sessionId || context . acpSessionId || `reasonix_acp_session_ ${ randomUUID () } ` ;
const effectiveRunId = runId || context . runId || effectiveSessionId ;
const effectiveToolCallId =
toolCallId || `reasonix_ ${ stableIdPart ( toolName , 'tool' ) } _ ${ randomUUID () } ` ;
const effectiveTraceId = traceId || context . traceId || `trace_ ${ stableIdPart ( effectiveRunId , 'run' ) } ` ;
const writeTool = isWriteMnoteTool ( toolName );
const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false ;
const effectiveIdempotencyKey =
idempotencyKey ||
`idem_ ${ stableIdPart ( toolName , 'tool' ) } _ ${ stableIdPart ( effectiveRunId , 'run' ) } _ ${ stableIdPart ( effectiveToolCallId , 'call' ) } ` ;
return {
toolName ,
args ,
workspaceId : workspaceId || context . workspaceId || 'default' ,
documentId : args . documentId || context . documentId ,
actorId : actorId || context . actorId || process . env . MNOTE_ACTOR_ID || 'reasonix-acp' ,
sessionId : effectiveSessionId ,
runId : effectiveRunId ,
toolCallId : effectiveToolCallId ,
traceId : effectiveTraceId ,
dryRun : effectiveDryRun ,
idempotencyKey : effectiveIdempotencyKey ,
};
}
if ( process . env . MNOTE_REASONIX_ACP_SELFTEST === '1' ) {
const payload = buildMnoteToolPayload (
'mnote.doc.markdown_edit' ,
{
workspaceId : 'ws_demo' ,
documentId : 'doc_1' ,
operations : [{ search : '旧' , replace : '新' }],
},
{
actorId : 'user_1' ,
sessionId : 'sess_1' ,
runId : 'run_1' ,
traceId : 'trace_1' ,
},
);
const required = [
'toolName' ,
'workspaceId' ,
'documentId' ,
'actorId' ,
'sessionId' ,
'runId' ,
'toolCallId' ,
'traceId' ,
'dryRun' ,
'idempotencyKey' ,
];
for ( const key of required ) {
if ( payload [ key ] === undefined || payload [ key ] === null || payload [ key ] === '' ) {
throw new Error ( `selftest missing ${ key } ` );
}
}
if ( payload . args . workspaceId !== undefined || payload . args . actorId !== undefined ) {
throw new Error ( 'selftest expected identity fields outside args' );
}
process . stderr . write ( '[reasonix-acp-mnote] selftest ok\n' );
process . exit ( 0 );
}
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 ;
try {
const r = await import ( 'reasonix' );
CacheFirstLoop = r . CacheFirstLoop ;
DeepSeekClient = r . DeepSeekClient ;
ToolRegistry = r . ToolRegistry ;
ImmutablePrefix = r . ImmutablePrefix ;
} catch ( e ) {
process . stderr . write ( `FATAL: reasonix not found — run "npm install reasonix"\n` );
process . stderr . write ( `Error: ${ e } \n` );
process . exit ( 1 );
}
// ── Simple NDJSON JSON-RPC 2.0 Server ───────────────
// Reference: Reasonix src/acp/server.ts (conceptual)
let nextId = 1 ;
const pendingReqs = new Map (); // id → { resolve, reject }
const requestHandlers = new Map (); // method → handler
const notificationHandlers = new Map (); // method → handler
function sendMessage ( msg ) {
const line = JSON . stringify ( msg ) + '\n' ;
stdout . write ( line );
}
function sendResponse ( id , result ) {
sendMessage ({ jsonrpc : '2.0' , id , result });
}
function sendError ( id , code , message ) {
sendMessage ({ jsonrpc : '2.0' , id , error : { code , message } });
}
function sendNotification ( method , params ) {
sendMessage ({ jsonrpc : '2.0' , method , params });
}
function onRequest ( method , handler ) {
requestHandlers . set ( method , handler );
}
function onNotification ( method , handler ) {
notificationHandlers . set ( method , handler );
}
// Start reading NDJSON from stdin
const rl = createInterface ({ input : stdin , terminal : false });
rl . on ( 'line' , async ( raw ) => {
const trimmed = raw . trim ();
if ( ! trimmed ) return ;
let msg ;
try {
msg = JSON . parse ( trimmed );
} catch {
sendError ( null , - 32700 , 'Parse error' );
return ;
}
const hasId = msg . id !== undefined && msg . id !== null ;
const hasMethod = typeof msg . method === 'string' && msg . method . length > 0 ;
if ( hasId && hasMethod ) {
// Incoming request from client
const handler = requestHandlers . get ( msg . method );
if ( ! handler ) {
sendError ( msg . id , - 32601 , `Method not found: ${ msg . method } ` );
return ;
}
try {
const result = await handler ( msg . params );
sendResponse ( msg . id , result );
} catch ( err ) {
sendError ( msg . id , err . code || - 32603 , err . message || String ( err ));
}
} else if ( hasId ) {
// Response to our outgoing request
const pending = pendingReqs . get ( msg . id );
if ( pending ) {
pendingReqs . delete ( msg . id );
if ( msg . error ) {
pending . reject ( new Error ( msg . error . message ));
} else {
pending . resolve ( msg . result );
}
}
} else if ( hasMethod ) {
// Notification from client
const handler = notificationHandlers . get ( msg . method );
if ( handler ) handler ( msg . params );
}
});
// ── Helper: send ACP session/update (camelCase per protocol spec) ──
function emitSessionUpdate ( sessionId , update ) {
sendNotification ( 'session/update' , { sessionId , update });
}
function emitTextDelta ( sessionId , text ) {
emitSessionUpdate ( sessionId , {
sessionUpdate : 'agent_message_chunk' ,
content : { type : 'text' , text },
});
}
function emitThoughtDelta ( sessionId , text ) {
emitSessionUpdate ( sessionId , {
sessionUpdate : 'agent_thought_chunk' ,
content : { type : 'text' , text },
});
}
function emitToolCall ( sessionId , toolCallId , title , kind , status , rawInput ) {
emitSessionUpdate ( sessionId , {
sessionUpdate : 'tool_call' ,
toolCallId ,
title ,
kind ,
status ,
rawInput ,
});
}
function emitToolResult ( sessionId , toolCallId , status , text ) {
emitSessionUpdate ( sessionId , {
sessionUpdate : 'tool_call_update' ,
toolCallId ,
status ,
content : text ? [{ type : 'content' , content : { type : 'text' , text } }] : undefined ,
});
}
function emitUsage ( sessionId , used , size ) {
emitSessionUpdate ( sessionId , {
sessionUpdate : 'usage_update' ,
used ,
size ,
});
}
// ── MNOTE Tool Implementations ───────────────────────
// Calls mnote-web's Rust tool endpoints via HTTP.
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs
const MNOTE_TOOL_NAMES = [
'mnote.doc.fetch' ,
'mnote.doc.markdown_edit' ,
'mnote.block.*' ,
'mnote.page.*' ,
];
2026-05-17 20:11:39 +08:00
const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_doc_fetch : 'mnote.doc.fetch' ,
mnote_doc_markdown_edit : 'mnote.doc.markdown_edit' ,
};
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 ();
tools . register ({
2026-05-17 20:11:39 +08:00
name : 'mnote_doc_fetch' ,
description : '读取当前 mnote 文档的 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 ,
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_doc_fetch , args ),
2026-05-17 16:15:52 +08:00
parallelSafe : false ,
});
tools . register ({
2026-05-17 20:11:39 +08:00
name : 'mnote_doc_markdown_edit' ,
2026-05-17 16:15:52 +08:00
description : '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。' ,
parameters : {
type : 'object' ,
properties : {
documentId : { type : 'string' , description : '文档 ID' },
operations : {
type : 'array' ,
items : {
type : 'object' ,
properties : {
search : { type : 'string' , description : '要搜索的文本片段' },
replace : { type : 'string' , description : '替换后的文本' },
},
required : [ 'search' , 'replace' ],
},
description : '搜索替换操作列表' ,
},
},
required : [ 'documentId' , 'operations' ],
},
2026-05-17 20:11:39 +08:00
fn : async ( args ) => callMnoteTool ( REASONIX_TOOL_TO_MNOTE_TOOL . mnote_doc_markdown_edit , args ),
2026-05-17 16:15:52 +08:00
parallelSafe : false ,
});
2026-05-19 12:29:25 +08:00
if ( REASONIX_ALLOWED_FS_ROOTS . length ) {
tools . register ({
name : 'reasonix_file_read' ,
description : '读取允许根目录中的本地 UTF-8 文件。' ,
parameters : {
type : 'object' ,
properties : {
path : { type : 'string' , description : '要读取的绝对路径文件' },
},
required : [ 'path' ],
},
readOnly : true ,
fn : async ( args ) => readAllowedFsFile ( args . path ),
parallelSafe : true ,
});
tools . register ({
name : 'reasonix_file_write' ,
description : '写入允许根目录中的本地 UTF-8 文件。' ,
parameters : {
type : 'object' ,
properties : {
path : { type : 'string' , description : '要写入的绝对路径文件' },
content : { type : 'string' , description : '完整文件内容' },
},
required : [ 'path' , 'content' ],
},
fn : async ( args ) => writeAllowedFsFile ( args . path , args . content ),
parallelSafe : false ,
});
tools . register ({
name : 'reasonix_file_replace' ,
description : '对允许根目录中的本地 UTF-8 文件做字符串替换。' ,
parameters : {
type : 'object' ,
properties : {
path : { type : 'string' , description : '要修改的绝对路径文件' },
search : { type : 'string' , description : '要查找的旧文本' },
replace : { type : 'string' , description : '要替换成的新文本' },
},
required : [ 'path' , 'search' , 'replace' ],
},
fn : async ( args ) => replaceAllowedFsFile ( args . path , args . search , args . replace ),
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 });
const systemPrompt = [
'You are a helpful AI assistant for editing Markdown documents.' ,
2026-05-17 20:11:39 +08:00
'Use mnote_doc_fetch to read the current document.' ,
'Use mnote_doc_markdown_edit to apply precise search/replace edits.' ,
'Always use mnote_doc_fetch first to understand the document content before editing.' ,
2026-05-19 12:29:25 +08:00
REASONIX_ALLOWED_FS_ROOTS . length
? `Local filesystem tools are enabled for these roots: ${ REASONIX_ALLOWED_FS_ROOTS . join ( ', ' ) } . Use reasonix_file_read, reasonix_file_replace, or reasonix_file_write for files inside those roots.`
: 'Local filesystem tools are disabled.' ,
2026-05-17 16:15:52 +08:00
]. join ( '\n' );
const loop = new CacheFirstLoop ({
client ,
tools ,
prefix : new ImmutablePrefix ({ system : systemPrompt , toolSpecs : tools . specs () }),
});
sessions . set ( sessionId , { id : sessionId , loop , client , aborter : null });
return { sessionId };
});
// ── ACP: session/prompt ─────────────────────────────
// Runs loop.step() and emits ACP session/update events.
onRequest ( 'session/prompt' , async ( params ) => {
if ( ! params ? . sessionId ) {
throw Object . assign ( new Error ( 'Missing sessionId' ), { code : - 32602 });
}
const session = sessions . get ( params . sessionId );
if ( ! session ) {
throw Object . assign ( new Error ( `Unknown session: ${ params . sessionId } ` ), { code : - 32602 });
}
const blocks = params . prompt || [];
const text = blocks
. map (( b ) => ( b . type === 'text' ? b . text : b . resource ? . text || '' ))
. filter ( Boolean )
. join ( '\n\n' )
. trim ();
if ( ! text ) {
throw Object . assign ( new Error ( 'Empty prompt' ), { code : - 32602 });
}
session . aborter = new AbortController ();
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-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 () => {
for await ( const ev of session . loop . step ( text )) {
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 (),
resultText . includes ( '"error"' ) ? 'failed' : 'completed' ,
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` );