973 lines
34 KiB
JavaScript
973 lines
34 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Reasonix ACP wrapper for mnote-web.
|
|
*
|
|
* Spawned by mnote-web's AcpRuntimeManager as:
|
|
* node scripts/reasonix-acp-wrapper.mjs
|
|
*
|
|
* Implements the ACP (Agent Client Protocol) JSON-RPC 2.0 server over stdio.
|
|
* This is a self-contained NDJSON implementation — no dependency on AcpServer
|
|
* (which is a Reasonix internal module not shipped in the npm package).
|
|
*
|
|
* Registers mnote tools as HTTP calls to mnote-web's tool endpoints.
|
|
*
|
|
* Reference: DeepSeek-Reasonix-main/src/cli/commands/acp.ts
|
|
*
|
|
* Environment:
|
|
* DEEPSEEK_API_KEY — DeepSeek API key (required)
|
|
* MNOTE_WEB_URL — mnote-web base URL (default: http://127.0.0.1:3000)
|
|
*/
|
|
|
|
import { createInterface } from 'node:readline';
|
|
import { stdin, stdout } from 'node:process';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
|
|
const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1';
|
|
|
|
function debugLog(message) {
|
|
if (DEBUG) process.stderr.write(`${message}\n`);
|
|
}
|
|
|
|
const toolContextStorage = new AsyncLocalStorage();
|
|
|
|
function isWriteMnoteTool(toolName) {
|
|
return ![
|
|
'mnote.skill.read',
|
|
'mnote.context.snapshot',
|
|
'mnote.context.resolve_target',
|
|
'mnote.context.read_current_page',
|
|
'mnote.evidence.search',
|
|
'mnote.evidence.read',
|
|
'mnote.evidence.open',
|
|
'mnote.index.status',
|
|
'mnote.index.refresh',
|
|
'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');
|
|
}
|
|
|
|
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');
|
|
}
|
|
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');
|
|
}
|
|
const evidencePayload = buildMnoteToolPayload(
|
|
'mnote.evidence.search',
|
|
{ query: 'ResourceBodyToken' },
|
|
{
|
|
workspaceId: 'ws_local',
|
|
mnoteCapabilities: {
|
|
sourceKind: 'local_folder',
|
|
rootUri: 'file:///tmp/mnote-local',
|
|
aiAccessScope: {
|
|
permissionLevel: 'read',
|
|
allowedRoots: [{ rootUri: 'file:///tmp/mnote-local', permission: 'read' }],
|
|
},
|
|
},
|
|
},
|
|
);
|
|
if (evidencePayload.rootUri !== 'file:///tmp/mnote-local') {
|
|
throw new Error('selftest expected evidence payload to inherit local root context');
|
|
}
|
|
if (isWriteMnoteTool('mnote.evidence.search')) {
|
|
throw new Error('selftest expected evidence search to be read-only');
|
|
}
|
|
const successfulEvidenceToolResult = JSON.stringify({ ok: true, error: null, result: { ok: true } });
|
|
if (toolResultStatusFromContent(successfulEvidenceToolResult) !== 'completed') {
|
|
throw new Error('selftest expected ok evidence result with error:null to be completed');
|
|
}
|
|
const failedEvidenceToolResult = JSON.stringify({ ok: false, error: { code: 'failed' } });
|
|
if (toolResultStatusFromContent(failedEvidenceToolResult) !== 'failed') {
|
|
throw new Error('selftest expected ok:false evidence result to be failed');
|
|
}
|
|
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
// 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。
|
|
function loadApiKey() {
|
|
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
|
|
const jsonConfigPath = join(homedir(), '.reasonix', 'config.json');
|
|
if (existsSync(jsonConfigPath)) {
|
|
try {
|
|
const raw = readFileSync(jsonConfigPath, 'utf-8');
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed?.apiKey === 'string' && parsed.apiKey.trim()) {
|
|
return parsed.apiKey.trim();
|
|
}
|
|
} catch {
|
|
// 配置损坏时继续尝试旧 YAML,不让启动阶段吞掉更明确的错误。
|
|
}
|
|
}
|
|
const configPath = join(homedir(), '.reasonix', 'config.yaml');
|
|
if (existsSync(configPath)) {
|
|
const raw = readFileSync(configPath, 'utf-8');
|
|
// 简单提取 YAML key,避免为 wrapper 额外引入 yaml parser。
|
|
const match = raw.match(/^\s*(?:api_key|apiKey)\s*:\s*['"]?(.+?)['"]?\s*$/m);
|
|
if (match) return match[1].trim();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const DEEPSEEK_API_KEY = loadApiKey();
|
|
if (!DEEPSEEK_API_KEY) {
|
|
process.stderr.write('FATAL: DEEPSEEK_API_KEY is required\n');
|
|
process.stderr.write('Set env var DEEPSEEK_API_KEY, or put apiKey in ~/.reasonix/config.json\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
process.env.DEEPSEEK_API_KEY = DEEPSEEK_API_KEY;
|
|
|
|
// ── Dynamic import of reasonix public API ────────────
|
|
|
|
let CacheFirstLoop, DeepSeekClient, ToolRegistry, ImmutablePrefix;
|
|
async function importReasonix() {
|
|
try {
|
|
return await import('reasonix');
|
|
} catch (error) {
|
|
let npmRoot = '';
|
|
try {
|
|
npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8' }).trim();
|
|
} catch {
|
|
throw error;
|
|
}
|
|
const globalEntry = join(npmRoot, 'reasonix', 'dist', 'index.js');
|
|
if (!existsSync(globalEntry)) {
|
|
throw error;
|
|
}
|
|
return import(pathToFileURL(globalEntry).href);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const r = await importReasonix();
|
|
CacheFirstLoop = r.CacheFirstLoop;
|
|
DeepSeekClient = r.DeepSeekClient;
|
|
ToolRegistry = r.ToolRegistry;
|
|
ImmutablePrefix = r.ImmutablePrefix;
|
|
} catch (e) {
|
|
process.stderr.write(`FATAL: reasonix not found — run "npm install reasonix"\n`);
|
|
process.stderr.write(`Error: ${e}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Simple NDJSON JSON-RPC 2.0 Server ───────────────
|
|
// Reference: Reasonix src/acp/server.ts (conceptual)
|
|
|
|
let nextId = 1;
|
|
const pendingReqs = new Map(); // id → { resolve, reject }
|
|
const requestHandlers = new Map(); // method → handler
|
|
const notificationHandlers = new Map(); // method → handler
|
|
|
|
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';
|
|
}
|
|
|
|
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.skill.read',
|
|
'mnote.context.snapshot',
|
|
'mnote.context.resolve_target',
|
|
'mnote.context.read_current_page',
|
|
'mnote.evidence.search',
|
|
'mnote.evidence.read',
|
|
'mnote.evidence.open',
|
|
'mnote.index.status',
|
|
'mnote.index.refresh',
|
|
'mnote.index.update_settings',
|
|
];
|
|
|
|
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_evidence_search: 'mnote.evidence.search',
|
|
mnote_evidence_read: 'mnote.evidence.read',
|
|
mnote_evidence_open: 'mnote.evidence.open',
|
|
mnote_index_status: 'mnote.index.status',
|
|
mnote_index_refresh: 'mnote.index.refresh',
|
|
mnote_index_update_settings: 'mnote.index.update_settings',
|
|
};
|
|
|
|
async function callMnoteTool(toolName, args) {
|
|
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
|
|
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-mnote-actor-id': payload.actorId,
|
|
'x-mnote-workspace-id': payload.workspaceId,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(`mnote tool ${toolName} failed: HTTP ${response.status} ${text}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
// ── Register Tools ───────────────────────────────────
|
|
|
|
const tools = new ToolRegistry();
|
|
const chatOnlyTools = new ToolRegistry();
|
|
|
|
tools.register({
|
|
name: 'mnote_skill_read',
|
|
description: '按需读取 MNote skill 正文。只有任务需要 MNote 能力时才调用。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
skillId: { type: 'string', description: 'MNote skill ID' },
|
|
agentId: { type: 'string', description: '当前 agent ID' },
|
|
},
|
|
required: ['skillId'],
|
|
},
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_skill_read, args),
|
|
parallelSafe: true,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_context_snapshot',
|
|
description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
contextRefs: { type: 'array', items: {} },
|
|
},
|
|
},
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_snapshot, args),
|
|
parallelSafe: true,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_context_resolve_target',
|
|
description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。',
|
|
parameters: { type: 'object', properties: {} },
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_resolve_target, args),
|
|
parallelSafe: true,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_context_read_current_page',
|
|
description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' },
|
|
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
|
|
},
|
|
},
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_context_read_current_page, args),
|
|
parallelSafe: false,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_evidence_search',
|
|
description: '搜索 MNote 本地文档和资源证据,返回 quote、locator 与 openAction。查询语言与资料语言可能不一致时,先在提示层做轻量多语关键词扩展,再用简短关键词检索。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
query: { type: 'string', description: '要搜索的问题或关键词' },
|
|
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
|
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
|
targetDocumentId: { type: 'string', description: '可选,限制到当前文档' },
|
|
includeResources: { type: 'boolean', description: '是否包含附件/资源标题' },
|
|
includeOcr: { type: 'boolean', description: '是否包含 OCR/source-map 证据' },
|
|
mode: { type: 'string', enum: ['hybrid', 'tree', 'graph'], description: '检索模式' },
|
|
topK: { type: 'integer', description: '最多返回结果数' },
|
|
scope: { type: 'object', description: '完整 EvidenceSearchScope,提供时优先使用' },
|
|
},
|
|
required: ['query'],
|
|
},
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_search, args),
|
|
parallelSafe: true,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_evidence_read',
|
|
description: '按 EvidenceLocator 读取原文证据及周边上下文。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
|
|
context: {
|
|
type: 'object',
|
|
properties: {
|
|
beforeBlocks: { type: 'integer' },
|
|
afterBlocks: { type: 'integer' },
|
|
includeSectionSummary: { type: 'boolean' },
|
|
},
|
|
},
|
|
},
|
|
required: ['locator'],
|
|
},
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_read, args),
|
|
parallelSafe: true,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_evidence_open',
|
|
description: '把 EvidenceLocator 归一化为 MNote 可执行的打开/定位动作。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
locator: { type: 'object', description: 'mnote_evidence_search 返回的 source/locator' },
|
|
},
|
|
required: ['locator'],
|
|
},
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_evidence_open, args),
|
|
parallelSafe: true,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_index_status',
|
|
description: '查看 MNote 本地索引范围、缓存文件状态、文档数和 evidence block 数。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
|
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
|
},
|
|
},
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_status, args),
|
|
parallelSafe: true,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_index_refresh',
|
|
description: '按当前有效范围重建 MNote 本地搜索/evidence 缓存,不修改 Markdown 正文。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
|
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
|
},
|
|
},
|
|
readOnly: true,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_refresh, args),
|
|
parallelSafe: false,
|
|
});
|
|
|
|
tools.register({
|
|
name: 'mnote_index_update_settings',
|
|
description: '新增或删除 MNote 本地索引范围;includePaths 为空表示删除当前用户范围。',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
|
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
|
includePaths: { type: 'array', items: { type: 'string' }, description: 'root 内相对路径列表,空数组表示删除范围' },
|
|
scheduleMode: { type: 'string', enum: ['manual', 'daily', 'weekly', 'monthly'], description: '刷新计划' },
|
|
scheduleTime: { type: 'string', description: 'HH:mm' },
|
|
scheduleDate: { type: 'string', description: '可选日期' },
|
|
runOnChange: { type: 'boolean', description: '文件变化时是否自动刷新' },
|
|
dryRun: { type: 'boolean', description: 'true 只返回计划;false 写入设置并刷新索引' },
|
|
idempotencyKey: { type: 'string', description: '写入幂等键' },
|
|
},
|
|
required: ['includePaths', 'dryRun', 'idempotencyKey'],
|
|
},
|
|
readOnly: false,
|
|
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_index_update_settings, args),
|
|
parallelSafe: false,
|
|
});
|
|
|
|
// ── Session Store ────────────────────────────────────
|
|
|
|
const sessions = new Map();
|
|
|
|
// ── ACP: initialize ──────────────────────────────────
|
|
|
|
onRequest('initialize', (params) => {
|
|
return {
|
|
protocolVersion: 1,
|
|
agentCapabilities: {
|
|
loadSession: false,
|
|
promptCapabilities: { image: false, audio: false, embeddedContext: true },
|
|
mcpCapabilities: { http: false, sse: false },
|
|
},
|
|
agentInfo: { name: 'reasonix-mnote', title: 'Reasonix MNote Agent', version: '0.1.0' },
|
|
authMethods: [],
|
|
};
|
|
});
|
|
|
|
// ── ACP: session/new ─────────────────────────────────
|
|
// Creates a CacheFirstLoop for the session.
|
|
|
|
onRequest('session/new', async (params) => {
|
|
const sessionId = randomUUID();
|
|
const client = new DeepSeekClient({ apiKey: DEEPSEEK_API_KEY });
|
|
|
|
const chatSystemPrompt = [
|
|
'You are a helpful AI assistant inside MNote.',
|
|
'For ordinary chat, reply directly.',
|
|
'Do not read or edit MNote pages, files, folders, or attachments unless MNote capabilities are explicitly attached for this prompt.',
|
|
].join('\n');
|
|
|
|
const mnoteSystemPrompt = [
|
|
'You are a helpful AI assistant inside MNote.',
|
|
'MNote capabilities are optional tools. Use them only when the user task requires MNote page, file, folder, attachment, or workspace context.',
|
|
'Use your native agent file tools for local file reads and edits inside allowed roots; MNote tools only provide target and context metadata.',
|
|
'<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.',
|
|
'- mnote-local-index — Search local documents with clickable evidence locators and manage local index scopes. When the query language may differ from the corpus language, infer likely corpus terms from filenames/titles/domain context, expand 2-6 concise multilingual keywords, and call mnote_evidence_search with the best short keyword queries. Do not assume the answer language from the corpus; answer in the user language and cite only retrieved evidence.',
|
|
'- 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({
|
|
client,
|
|
tools,
|
|
prefix: new ImmutablePrefix({ system: mnoteSystemPrompt, toolSpecs: tools.specs() }),
|
|
});
|
|
|
|
sessions.set(sessionId, { id: sessionId, chatLoop, mnoteLoop, client, aborter: null });
|
|
return { sessionId };
|
|
});
|
|
|
|
// ── ACP: session/prompt ─────────────────────────────
|
|
// Runs loop.step() and emits ACP session/update events.
|
|
|
|
onRequest('session/prompt', async (params) => {
|
|
if (!params?.sessionId) {
|
|
throw Object.assign(new Error('Missing sessionId'), { code: -32602 });
|
|
}
|
|
const session = sessions.get(params.sessionId);
|
|
if (!session) {
|
|
throw Object.assign(new Error(`Unknown session: ${params.sessionId}`), { code: -32602 });
|
|
}
|
|
|
|
const blocks = params.prompt || [];
|
|
const text = blocks
|
|
.map((b) => (b.type === 'text' ? b.text : b.resource?.text || ''))
|
|
.filter(Boolean)
|
|
.join('\n\n')
|
|
.trim();
|
|
|
|
if (!text) {
|
|
throw Object.assign(new Error('Empty prompt'), { code: -32602 });
|
|
}
|
|
|
|
session.aborter = new AbortController();
|
|
const toolContext = {
|
|
acpSessionId: params.sessionId,
|
|
sessionId: params.mnoteSessionId || params.sessionId,
|
|
runId: params.runId || params.mnoteRunId || params.sessionId,
|
|
actorId: params.actorId || params.mnoteActorId,
|
|
traceId: params.traceId || params.mnoteTraceId,
|
|
workspaceId: params.workspaceId,
|
|
documentId: params.documentId,
|
|
mnoteCapabilities: params.mnoteCapabilities || null,
|
|
};
|
|
const mnoteCapabilities = params.mnoteCapabilities || {};
|
|
const useMnoteTools = mnoteCapabilities.attachMnoteCapabilities === true;
|
|
const loop = useMnoteTools ? session.mnoteLoop : session.chatLoop;
|
|
const promptText = buildMnotePromptText(text, toolContext);
|
|
let stopReason = 'end_turn';
|
|
let hasAssistantOutput = false;
|
|
let hasToolCall = false;
|
|
let nextToolCallSeq = 1;
|
|
const announcedToolKeys = new Set();
|
|
const preparingToolCallIds = [];
|
|
const inflightToolCallIds = [];
|
|
|
|
function nextToolCallId() {
|
|
return `tc_${nextToolCallSeq++}`;
|
|
}
|
|
|
|
function toolKeyFor(ev) {
|
|
if (ev.toolCallIndex !== undefined && ev.toolCallIndex !== null) {
|
|
return `${ev.turn ?? 0}:${ev.toolCallIndex}`;
|
|
}
|
|
return `${ev.turn ?? 0}:${ev.toolName || 'tool'}:${nextToolCallSeq}`;
|
|
}
|
|
|
|
function parseToolArgs(raw) {
|
|
if (!raw) return undefined;
|
|
try {
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return raw;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await toolContextStorage.run(toolContext, async () => {
|
|
for await (const ev of loop.step(promptText)) {
|
|
if (session.aborter?.signal.aborted) {
|
|
stopReason = 'cancelled';
|
|
break;
|
|
}
|
|
|
|
debugLog(`[reasonix-acp] loop event role=${ev.role}`);
|
|
|
|
// Reasonix 的 LoopEvent 使用 role 字段;这里按官方 Eventizer 的核心语义映射到 ACP。
|
|
switch (ev.role) {
|
|
case 'assistant_delta': {
|
|
if (ev.content) {
|
|
hasAssistantOutput = true;
|
|
emitTextDelta(session.id, ev.content);
|
|
}
|
|
if (ev.reasoningDelta) {
|
|
emitThoughtDelta(session.id, ev.reasoningDelta);
|
|
}
|
|
break;
|
|
}
|
|
case 'assistant_final': {
|
|
if (ev.content && !hasAssistantOutput) {
|
|
hasAssistantOutput = true;
|
|
emitTextDelta(session.id, ev.content);
|
|
} else if (ev.content) {
|
|
hasAssistantOutput = true;
|
|
}
|
|
break;
|
|
}
|
|
case 'done': {
|
|
if (ev.content && !hasAssistantOutput) {
|
|
hasAssistantOutput = true;
|
|
emitTextDelta(session.id, ev.content);
|
|
}
|
|
break;
|
|
}
|
|
case 'tool_call_delta': {
|
|
hasToolCall = true;
|
|
const key = toolKeyFor(ev);
|
|
if (announcedToolKeys.has(key)) break;
|
|
announcedToolKeys.add(key);
|
|
const toolCallId = nextToolCallId();
|
|
preparingToolCallIds.push(toolCallId);
|
|
emitToolCall(
|
|
session.id,
|
|
toolCallId,
|
|
ev.toolName || 'tool',
|
|
'other',
|
|
'pending',
|
|
undefined,
|
|
);
|
|
break;
|
|
}
|
|
case 'tool_start': {
|
|
hasToolCall = true;
|
|
const toolCallId = preparingToolCallIds.shift() || ev.callId || nextToolCallId();
|
|
inflightToolCallIds.push(toolCallId);
|
|
emitToolCall(
|
|
session.id,
|
|
toolCallId,
|
|
ev.toolName || 'tool',
|
|
'other',
|
|
'in_progress',
|
|
parseToolArgs(ev.toolArgs),
|
|
);
|
|
break;
|
|
}
|
|
case 'tool': {
|
|
hasToolCall = true;
|
|
const resultText = String(ev.content || '').slice(0, 8000);
|
|
emitToolResult(
|
|
session.id,
|
|
inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
|
|
toolResultStatusFromContent(resultText),
|
|
resultText,
|
|
);
|
|
break;
|
|
}
|
|
case 'error': {
|
|
const message = ev.error || ev.content || 'Reasonix loop error';
|
|
emitTextDelta(session.id, `\n\n[error] ${message}`);
|
|
stopReason = 'error';
|
|
break;
|
|
}
|
|
case 'warning':
|
|
case 'status': {
|
|
if (ev.content) emitThoughtDelta(session.id, ev.content);
|
|
break;
|
|
}
|
|
}
|
|
// Usage info from stats
|
|
if (ev.stats) {
|
|
emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
|
|
}
|
|
}
|
|
});
|
|
} catch (err) {
|
|
const message = err.message || String(err);
|
|
const stack = err.stack || '';
|
|
process.stderr.write(`[reasonix-acp] prompt error: ${message}\n${stack}\n`);
|
|
emitTextDelta(session.id, `\n\n[error] ${message}`);
|
|
stopReason = 'error';
|
|
} finally {
|
|
// If no model output or tool calls were produced, it means the LLM backend failed.
|
|
// This can happen when the API key is invalid, base URL is wrong, or model is unavailable.
|
|
if (!hasAssistantOutput && !hasToolCall && stopReason !== 'error') {
|
|
stopReason = 'error';
|
|
emitTextDelta(session.id, '\n\n[error] AI 模型未返回输出。可能原因:API Key 无效、模型不可用、或网络连接失败。请检查 DEEPSEEK_API_KEY 是否正确设置(以 sk- 开头)。');
|
|
}
|
|
session.aborter = null;
|
|
}
|
|
|
|
return { stopReason };
|
|
});
|
|
|
|
// ── ACP: session/cancel (notification) ──────────────
|
|
|
|
onNotification('session/cancel', (params) => {
|
|
const session = params?.sessionId ? sessions.get(params.sessionId) : undefined;
|
|
if (session?.aborter) {
|
|
session.aborter.abort();
|
|
}
|
|
});
|
|
|
|
// ── Start ────────────────────────────────────────────
|
|
|
|
process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);
|