Files
mnote/scripts/reasonix-acp-wrapper.mjs
T

994 lines
36 KiB
JavaScript
Raw Normal View History

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';
import { AsyncLocalStorage } from 'node:async_hooks';
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-05-21 23:53:39 +08:00
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`);
}
const toolContextStorage = new AsyncLocalStorage();
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)}`);
}
}
function isWriteMnoteTool(toolName) {
return ![
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.open_reference',
'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-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}`;
}
function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
const {
actorId,
sessionId,
runId,
toolCallId,
traceId,
idempotencyKey,
dryRun,
workspaceId,
documentId,
sourceKind,
rootUri,
profile,
capabilityScope,
...args
} = rawArgs || {};
const effectiveSessionId =
sessionId || context.sessionId || context.acpSessionId || `reasonix_acp_session_${randomUUID()}`;
const effectiveRunId = runId || context.runId || effectiveSessionId;
const effectiveToolCallId =
toolCallId || `reasonix_${stableIdPart(toolName, 'tool')}_${randomUUID()}`;
const effectiveTraceId = traceId || context.traceId || `trace_${stableIdPart(effectiveRunId, 'run')}`;
const writeTool = isWriteMnoteTool(toolName);
const capabilities = context.mnoteCapabilities || {};
if (!args.contextRefs && capabilities.contextRefs) args.contextRefs = capabilities.contextRefs;
if (!args.agentId && capabilities.agentId) args.agentId = capabilities.agentId;
if (!args.aiAccessScope && capabilities.aiAccessScope) args.aiAccessScope = capabilities.aiAccessScope;
if (!args.allowedRoots && capabilities.allowedRoots) args.allowedRoots = capabilities.allowedRoots;
const effectiveDryRun = typeof dryRun === 'boolean' ? dryRun : writeTool ? false : false;
const effectiveIdempotencyKey =
idempotencyKey ||
`idem_${stableIdPart(toolName, 'tool')}_${stableIdPart(effectiveRunId, 'run')}_${stableIdPart(effectiveToolCallId, 'call')}`;
return {
toolName,
args,
workspaceId: workspaceId || context.workspaceId || capabilities.workspaceId || 'default',
documentId: documentId || args.documentId || context.documentId || capabilities.documentId,
sourceKind: sourceKind || args.sourceKind || context.sourceKind || capabilities.sourceKind,
rootUri: rootUri || args.rootUri || context.rootUri || capabilities.rootUri,
profile: profile || args.profile || context.profile || capabilities.profile,
actorId: actorId || context.actorId || process.env.MNOTE_ACTOR_ID || 'reasonix-acp',
sessionId: effectiveSessionId,
runId: effectiveRunId,
toolCallId: effectiveToolCallId,
traceId: effectiveTraceId,
dryRun: effectiveDryRun,
idempotencyKey: effectiveIdempotencyKey,
capabilityScope: capabilityScope || args.capabilityScope || context.capabilityScope || capabilities.capabilityScope,
};
}
if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
const payload = buildMnoteToolPayload(
'mnote.context.read_current_page',
{
workspaceId: 'ws_demo',
documentId: 'doc_1',
format: 'markdown',
},
{
actorId: 'user_1',
sessionId: 'sess_1',
runId: 'run_1',
traceId: 'trace_1',
},
);
const required = [
'toolName',
'workspaceId',
'documentId',
'actorId',
'sessionId',
'runId',
'toolCallId',
'traceId',
'dryRun',
'idempotencyKey',
];
for (const key of required) {
if (payload[key] === undefined || payload[key] === null || payload[key] === '') {
throw new Error(`selftest missing ${key}`);
}
}
if (payload.args.workspaceId !== undefined || payload.args.actorId !== undefined) {
throw new Error('selftest expected identity fields outside args');
}
const contextualPayload = buildMnoteToolPayload(
'mnote.context.read_current_page',
{ format: 'markdown' },
{
actorId: 'user_1',
workspaceId: 'ws_local',
documentId: 'local-md:Current.md',
runId: 'run_local_1',
traceId: 'trace_local_1',
mnoteCapabilities: {
agentId: 'reasonix',
contextRefs: [{ kind: 'current_page' }],
sourceKind: 'local_folder',
rootUri: 'file:///tmp/mnote-local',
profile: 'reasonix',
aiAccessScope: {
permissionLevel: 'read_write',
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'write' }],
allowedResourceIds: ['local-md:Current.md'],
},
},
},
);
if (contextualPayload.sourceKind !== 'local_folder') {
throw new Error('selftest expected sourceKind inherited from mnoteCapabilities');
}
if (contextualPayload.rootUri !== 'file:///tmp/mnote-local') {
throw new Error('selftest expected rootUri inherited from mnoteCapabilities');
}
if (contextualPayload.profile !== 'reasonix') {
throw new Error('selftest expected profile inherited from mnoteCapabilities');
}
if (!Array.isArray(contextualPayload.args.contextRefs) || contextualPayload.args.contextRefs[0]?.kind !== 'current_page') {
throw new Error('selftest expected contextRefs inherited from mnoteCapabilities');
}
if (contextualPayload.args.aiAccessScope?.permissionLevel !== 'read_write') {
throw new Error('selftest expected aiAccessScope inherited from mnoteCapabilities');
}
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');
}
if (!promptWithEnvelope.includes('Do not use MNote doc/page write tools for ordinary local Markdown edits.')) {
throw new Error('selftest expected prompt to forbid MNote doc/page write tools for ordinary local Markdown edits');
}
const evidencePayload = buildMnoteToolPayload(
'mnote.knowledge_rag.query',
{ query: 'ResourceBodyToken' },
{
workspaceId: 'ws_local',
mnoteCapabilities: {
sourceKind: 'local_folder',
rootUri: 'file:///tmp/mnote-local',
aiAccessScope: {
permissionLevel: 'read',
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'read' }],
},
},
},
);
if (evidencePayload.rootUri !== 'file:///tmp/mnote-local') {
throw new Error('selftest expected evidence payload to inherit local root context');
}
if (isWriteMnoteTool('mnote.knowledge_rag.query')) {
throw new Error('selftest expected knowledge RAG query to be read-only');
}
const successfulEvidenceToolResult = JSON.stringify({ ok: true, error: null, result: { ok: true } });
if (toolResultStatusFromContent(successfulEvidenceToolResult) !== 'completed') {
throw new Error('selftest expected ok evidence result with error:null to be completed');
}
const failedEvidenceToolResult = JSON.stringify({ ok: false, error: { code: 'failed' } });
if (toolResultStatusFromContent(failedEvidenceToolResult) !== 'failed') {
throw new Error('selftest expected ok:false evidence result to be failed');
}
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',
);
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,
});
}
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
async function callMnoteTool(toolName, args) {
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
2026-05-17 16:15:52 +08:00
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-mnote-actor-id': payload.actorId,
'x-mnote-workspace-id': payload.workspaceId,
},
body: JSON.stringify(payload),
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();
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-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 10:35:21 +08:00
parallelSafe: true,
},
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',
description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references。回答必须引用返回来源,不要引用 raw chunks。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '资料库问题' },
question: { type: 'string', description: '资料库问题,等价于 query' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
mode: { type: 'string', enum: ['mix', 'local', 'global', 'naive', 'bypass'], description: 'LightRAG query mode; use mix by default for knowledge-library questions' },
topK: { type: 'integer', description: 'LightRAG top_k' },
chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' },
includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' },
sourcePaths: {
type: 'array',
items: { type: 'string' },
description: '可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 provider 检索后过滤 referencesraw 仍可能是全局结果。',
},
},
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-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-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-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 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');
const mnoteSystemPrompt = [
'You are a helpful AI assistant inside MNote.',
'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.',
'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.',
'Do not use MNote doc/page write tools for ordinary local Markdown edits.',
'Final answers must be direct user-facing answers. Do not narrate tool use, search steps, plans, or internal process; do not say phrases like "let me search", "I found", "I will check", or "让我".',
'<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-07 10:35:21 +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 cite only returned references; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
'</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,
prefix: new ImmutablePrefix({ system: mnoteSystemPrompt, toolSpecs: tools.specs() }),
2026-05-17 16:15:52 +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();
const toolContext = {
acpSessionId: params.sessionId,
sessionId: params.mnoteSessionId || params.sessionId,
runId: params.runId || params.mnoteRunId || params.sessionId,
actorId: params.actorId || params.mnoteActorId,
traceId: params.traceId || params.mnoteTraceId,
workspaceId: params.workspaceId,
documentId: params.documentId,
mnoteCapabilities: params.mnoteCapabilities || null,
};
const mnoteCapabilities = params.mnoteCapabilities || {};
const useMnoteTools = mnoteCapabilities.attachMnoteCapabilities === true;
const loop = useMnoteTools ? session.mnoteLoop : session.chatLoop;
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 {
await toolContextStorage.run(toolContext, async () => {
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(),
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-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`);