433 lines
13 KiB
JavaScript
433 lines
13 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 { readFileSync, existsSync } from 'node:fs';
|
||
|
|
import { homedir } from 'node:os';
|
||
|
|
import { join } from 'node:path';
|
||
|
|
|
||
|
|
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
|
||
|
|
|
||
|
|
// Load DeepSeek API key — same logic as Reasonix's loadApiKey():
|
||
|
|
// 1. DEEPSEEK_API_KEY env var
|
||
|
|
// 2. ~/.reasonix/config.yaml (yaml: api_key or apiKey)
|
||
|
|
function loadApiKey() {
|
||
|
|
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
|
||
|
|
const configPath = join(homedir(), '.reasonix', 'config.yaml');
|
||
|
|
if (existsSync(configPath)) {
|
||
|
|
const raw = readFileSync(configPath, 'utf-8');
|
||
|
|
// Simple YAML key extraction (no yaml parser dependency needed)
|
||
|
|
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 api_key in ~/.reasonix/config.yaml\n');
|
||
|
|
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.*',
|
||
|
|
];
|
||
|
|
|
||
|
|
async function callMnoteTool(toolName, args) {
|
||
|
|
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
|
||
|
|
const response = await fetch(url, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
toolName,
|
||
|
|
args,
|
||
|
|
workspaceId: args.workspaceId || 'default',
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
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({
|
||
|
|
name: 'mnote.doc.fetch',
|
||
|
|
description: '读取当前文档的 markdown 内容。返回文档标题和正文。',
|
||
|
|
parameters: {
|
||
|
|
type: 'object',
|
||
|
|
properties: {
|
||
|
|
documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' },
|
||
|
|
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
|
||
|
|
},
|
||
|
|
},
|
||
|
|
call: async (args) => callMnoteTool('mnote.doc.fetch', args),
|
||
|
|
parallelSafe: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
tools.register({
|
||
|
|
name: 'mnote.doc.markdown_edit',
|
||
|
|
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'],
|
||
|
|
},
|
||
|
|
call: async (args) => callMnoteTool('mnote.doc.markdown_edit', 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: false },
|
||
|
|
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.',
|
||
|
|
'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.',
|
||
|
|
].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();
|
||
|
|
let stopReason = 'end_turn';
|
||
|
|
let hasModelOutput = false;
|
||
|
|
let hasToolCall = false;
|
||
|
|
|
||
|
|
try {
|
||
|
|
for await (const ev of session.loop.step(text)) {
|
||
|
|
if (session.aborter?.signal.aborted) {
|
||
|
|
stopReason = 'cancelled';
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
switch (ev.type) {
|
||
|
|
case 'model_delta': {
|
||
|
|
hasModelOutput = true;
|
||
|
|
if (ev.text) emitTextDelta(session.id, ev.text);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'model_thinking': {
|
||
|
|
hasModelOutput = true;
|
||
|
|
if (ev.text) emitThoughtDelta(session.id, ev.text);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'tool_call_delta': {
|
||
|
|
hasToolCall = true;
|
||
|
|
const call = ev.toolCall || {};
|
||
|
|
emitToolCall(
|
||
|
|
session.id,
|
||
|
|
call.id || `tc_${Date.now()}`,
|
||
|
|
call.name || ev.name || 'tool',
|
||
|
|
'other',
|
||
|
|
'pending',
|
||
|
|
call.args || ev.args,
|
||
|
|
);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'tool_start': {
|
||
|
|
const call = ev.toolCall || {};
|
||
|
|
emitToolCall(
|
||
|
|
session.id,
|
||
|
|
call.id || `tc_${Date.now()}`,
|
||
|
|
call.name || ev.name || 'tool',
|
||
|
|
'other',
|
||
|
|
'in_progress',
|
||
|
|
call.args || ev.args,
|
||
|
|
);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'tool_result': {
|
||
|
|
const resultText = typeof ev.result === 'string'
|
||
|
|
? ev.result.slice(0, 8000)
|
||
|
|
: JSON.stringify(ev.result).slice(0, 8000);
|
||
|
|
emitToolResult(
|
||
|
|
session.id,
|
||
|
|
ev.toolCallId || `tc_${Date.now()}`,
|
||
|
|
ev.error ? 'failed' : 'completed',
|
||
|
|
resultText,
|
||
|
|
);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case 'usage': {
|
||
|
|
emitUsage(session.id, ev.inputTokens || 0, ev.cacheHitTokens || 0);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
const message = err.message || String(err);
|
||
|
|
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 (!hasModelOutput && !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`);
|