1226 lines
46 KiB
JavaScript
1226 lines
46 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 { dirname, join } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { execFileSync, spawn } 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';
|
||
const REASONIX_BIN = process.env.MNOTE_REASONIX_BIN || 'reasonix';
|
||
const REASONIX_ACP_BACKEND = (process.env.MNOTE_REASONIX_ACP_BACKEND || 'auto').trim().toLowerCase();
|
||
|
||
function debugLog(message) {
|
||
if (DEBUG) process.stderr.write(`${message}\n`);
|
||
}
|
||
|
||
const toolContextStorage = new AsyncLocalStorage();
|
||
const MNOTE_UI_CITATION_QUEUE = [];
|
||
|
||
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 parseReasonixMajorVersion(versionText) {
|
||
const text = String(versionText || '');
|
||
const match = text.match(/\b(?:npm-v|v)?(\d+)(?:\.\d+){0,2}(?:[-+][\w.-]+)?\b/);
|
||
if (!match) return null;
|
||
const major = Number.parseInt(match[1], 10);
|
||
return Number.isFinite(major) ? major : null;
|
||
}
|
||
|
||
function readReasonixVersionText() {
|
||
try {
|
||
return execFileSync(REASONIX_BIN, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||
} catch (error) {
|
||
debugLog(`[reasonix-acp-mnote] unable to read ${REASONIX_BIN} --version: ${error instanceof Error ? error.message : String(error)}`);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function shouldUseNativeReasonixAcp(versionText = readReasonixVersionText()) {
|
||
if (REASONIX_ACP_BACKEND === 'legacy') return false;
|
||
if (REASONIX_ACP_BACKEND === 'native') return true;
|
||
const major = parseReasonixMajorVersion(versionText);
|
||
return major !== null && major >= 1;
|
||
}
|
||
|
||
function nativeReasonixAcpArgs() {
|
||
const args = ['acp'];
|
||
const model = (process.env.REASONIX_MODEL || '').trim();
|
||
if (model) args.push('-model', model);
|
||
const extraArgsText = (process.env.MNOTE_REASONIX_ACP_ARGS_JSON || '').trim();
|
||
if (extraArgsText) {
|
||
let extraArgs;
|
||
try {
|
||
extraArgs = JSON.parse(extraArgsText);
|
||
} catch (error) {
|
||
throw new Error(`MNOTE_REASONIX_ACP_ARGS_JSON must be a JSON array of strings: ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
if (!Array.isArray(extraArgs) || extraArgs.some((value) => typeof value !== 'string')) {
|
||
throw new Error('MNOTE_REASONIX_ACP_ARGS_JSON must be a JSON array of strings');
|
||
}
|
||
args.push(...extraArgs);
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function proxyNativeReasonixAcp(versionText = readReasonixVersionText()) {
|
||
const args = nativeReasonixAcpArgs();
|
||
debugLog(`[reasonix-acp-mnote] delegating to native ${REASONIX_BIN} ${args.join(' ')} (${versionText || 'version unknown'})`);
|
||
const child = spawn(REASONIX_BIN, args, {
|
||
stdio: ['inherit', 'inherit', 'inherit'],
|
||
env: process.env,
|
||
});
|
||
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
||
process.on(signal, () => {
|
||
if (!child.killed) child.kill(signal);
|
||
});
|
||
}
|
||
child.on('error', (error) => {
|
||
process.stderr.write(`FATAL: failed to spawn native Reasonix ACP (${REASONIX_BIN}): ${error.message}\n`);
|
||
process.exit(1);
|
||
});
|
||
child.on('exit', (code, signal) => {
|
||
if (signal) {
|
||
process.kill(process.pid, signal);
|
||
return;
|
||
}
|
||
process.exit(code === null ? 1 : code);
|
||
});
|
||
}
|
||
|
||
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 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 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') {
|
||
if (parseReasonixMajorVersion('reasonix 0.53.2') !== 0) {
|
||
throw new Error('selftest expected legacy Reasonix 0.x version parsing');
|
||
}
|
||
if (parseReasonixMajorVersion('reasonix npm-v1.4.0-rc.1') !== 1) {
|
||
throw new Error('selftest expected native Reasonix 1.x version parsing');
|
||
}
|
||
if (parseReasonixMajorVersion('unversioned') !== null) {
|
||
throw new Error('selftest expected unversioned Reasonix output to be unknown');
|
||
}
|
||
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');
|
||
}
|
||
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');
|
||
}
|
||
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',
|
||
);
|
||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||
const knowledgeSkillPath = join(scriptDir, '..', 'skills', 'mnote-knowledge-rag', 'SKILL.md');
|
||
const knowledgeSkillText = readFileSync(knowledgeSkillPath, 'utf8');
|
||
for (const toolName of rustKnowledgeTools) {
|
||
if (!knowledgeSkillText.includes(toolName)) {
|
||
throw new Error(`selftest expected mnote-knowledge-rag skill text to mention ${toolName}`);
|
||
}
|
||
}
|
||
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;
|
||
|
||
const reasonixVersionText = readReasonixVersionText();
|
||
if (shouldUseNativeReasonixAcp(reasonixVersionText)) {
|
||
proxyNativeReasonixAcp(reasonixVersionText);
|
||
} else {
|
||
|
||
// ── 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
|
||
let rpcServerReady = false;
|
||
const queuedRpcLines = [];
|
||
|
||
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);
|
||
}
|
||
|
||
async function handleRpcLine(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);
|
||
}
|
||
}
|
||
|
||
// 先接住 stdio 输入,但等全部 handler 注册完成后再处理,避免 initialize 抢跑。
|
||
const rl = createInterface({ input: stdin, terminal: false });
|
||
rl.on('line', (raw) => {
|
||
if (!rpcServerReady) {
|
||
queuedRpcLines.push(raw);
|
||
return;
|
||
}
|
||
void handleRpcLine(raw);
|
||
});
|
||
|
||
// ── Helper: send ACP session/update (camelCase per protocol spec) ──
|
||
|
||
function emitSessionUpdate(sessionId, update) {
|
||
sendNotification('session/update', { sessionId, update });
|
||
}
|
||
|
||
function emitTextDelta(sessionId, text) {
|
||
emitSessionUpdate(sessionId, {
|
||
sessionUpdate: 'agent_message_chunk',
|
||
content: { type: 'text', text },
|
||
});
|
||
}
|
||
|
||
function emitThoughtDelta(sessionId, text) {
|
||
emitSessionUpdate(sessionId, {
|
||
sessionUpdate: 'agent_thought_chunk',
|
||
content: { type: 'text', text },
|
||
});
|
||
}
|
||
|
||
function emitToolCall(sessionId, toolCallId, title, kind, status, rawInput) {
|
||
emitSessionUpdate(sessionId, {
|
||
sessionUpdate: 'tool_call',
|
||
toolCallId,
|
||
title,
|
||
kind,
|
||
status,
|
||
rawInput,
|
||
});
|
||
}
|
||
|
||
function emitToolResult(sessionId, toolCallId, status, text) {
|
||
emitSessionUpdate(sessionId, {
|
||
sessionUpdate: 'tool_call_update',
|
||
toolCallId,
|
||
status,
|
||
content: text ? [{ type: 'content', content: { type: 'text', text } }] : undefined,
|
||
});
|
||
}
|
||
|
||
function emitUsage(sessionId, used, size) {
|
||
emitSessionUpdate(sessionId, {
|
||
sessionUpdate: 'usage_update',
|
||
used,
|
||
size,
|
||
});
|
||
}
|
||
|
||
// ── MNOTE Tool Implementations ───────────────────────
|
||
// Calls mnote-web's Rust tool endpoints via HTTP.
|
||
// Reference: rust/crates/mnote-web/src/routes/hermes_tools.rs
|
||
|
||
async function callMnoteTool(toolName, args) {
|
||
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
|
||
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}`);
|
||
}
|
||
const result = compactMnoteToolResultForReasonix(toolName, await response.json());
|
||
const citations = collectUiCitationMarkdowns(result).slice(0, 8);
|
||
if (citations.length) MNOTE_UI_CITATION_QUEUE.push(citations);
|
||
return result;
|
||
}
|
||
|
||
function compactMnoteToolResultForReasonix(toolName, payload) {
|
||
if (toolName !== 'mnote.knowledge_rag.query') return payload;
|
||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : payload;
|
||
const citationMarkdowns = collectUiCitationMarkdowns(result).slice(0, 8);
|
||
const citations = citationMarkdowns
|
||
.map((citationMarkdown) => ({ citationMarkdown }))
|
||
.slice(0, 8);
|
||
if (!citations.length || !result || typeof result !== 'object') return payload;
|
||
return {
|
||
ok: payload?.ok !== false,
|
||
schema: result.schema || 'mnote.knowledge_rag.agent_query_result.v1',
|
||
uiCitations: citations,
|
||
citationRendering: 'MNote UI renders uiCitations after the answer as clickable source locators. Do not copy citationMarkdown into the final answer and do not hand-write /documents links.',
|
||
answerCitationPolicy: 'Answer the substance in plain text. Mention source titles only if useful; leave clickable citation insertion to MNote UI.',
|
||
answerGuidance: result.answerGuidance || '',
|
||
references: Array.isArray(result.references) ? result.references.slice(0, 8) : [],
|
||
citations: citationMarkdowns,
|
||
sourceScope: result.sourceScope || [],
|
||
sourceScopeMode: result.sourceScopeMode || '',
|
||
rawScopeFiltered: Boolean(result.rawScopeFiltered),
|
||
};
|
||
}
|
||
|
||
function collectUiCitationMarkdowns(value) {
|
||
const references = Array.isArray(value?.references) ? value.references : [];
|
||
const referenceCitations = [];
|
||
if (references.length) {
|
||
const hasPrecise = references.some((reference) =>
|
||
typeof reference?.citationMarkdown === 'string' &&
|
||
reference.citationMarkdown.trim() &&
|
||
reference.locatorDegraded !== true
|
||
);
|
||
const seen = new Set();
|
||
for (const reference of references) {
|
||
const citation = String(reference?.citationMarkdown || '').trim();
|
||
if (!citation || seen.has(citation)) continue;
|
||
if (hasPrecise && reference?.locatorDegraded === true) continue;
|
||
seen.add(citation);
|
||
referenceCitations.push(citation);
|
||
}
|
||
if (referenceCitations.length) return referenceCitations;
|
||
}
|
||
const citations = collectCitationMarkdowns(value);
|
||
const hasPrecise = citations.some((item) => !isDegradedCitationMarkdown(item));
|
||
return citations.filter((citation) => {
|
||
return !hasPrecise || !isDegradedCitationMarkdown(citation);
|
||
});
|
||
}
|
||
|
||
function isDegradedCitationMarkdown(value) {
|
||
const text = String(value || '').toLowerCase();
|
||
return text.includes('来源定位降级') || text.includes('locator degraded');
|
||
}
|
||
|
||
function collectCitationMarkdowns(value) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
function add(text) {
|
||
const value = String(text || '').trim();
|
||
if (!value || seen.has(value)) return;
|
||
seen.add(value);
|
||
out.push(value);
|
||
}
|
||
function visit(node) {
|
||
if (!node) return;
|
||
if (typeof node === 'string') {
|
||
const trimmed = node.trim();
|
||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||
try {
|
||
visit(JSON.parse(trimmed));
|
||
} catch {}
|
||
}
|
||
return;
|
||
}
|
||
if (Array.isArray(node)) {
|
||
node.forEach(visit);
|
||
return;
|
||
}
|
||
if (typeof node !== 'object') return;
|
||
if (typeof node.citationMarkdown === 'string') add(node.citationMarkdown);
|
||
Object.values(node).forEach(visit);
|
||
}
|
||
visit(value);
|
||
return out;
|
||
}
|
||
|
||
function toolResultTextWithUiCitations(rawText) {
|
||
const text = String(rawText || '');
|
||
let citationMarkdowns = collectUiCitationMarkdownsFromText(text).slice(0, 8);
|
||
if (!citationMarkdowns.length && MNOTE_UI_CITATION_QUEUE.length) {
|
||
citationMarkdowns = MNOTE_UI_CITATION_QUEUE.shift();
|
||
}
|
||
const citations = citationMarkdowns.map((citationMarkdown) => ({ citationMarkdown })).slice(0, 8);
|
||
if (!citations.length) return text.slice(0, 8000);
|
||
const prefix = JSON.stringify({
|
||
schema: 'mnote.acp.tool_result_ui_citations.v1',
|
||
uiCitations: citations,
|
||
citationRendering: 'MNote UI renders these citations after the answer; the model must not hand-write local citation links.',
|
||
});
|
||
const budget = Math.max(0, 8000 - prefix.length - 2);
|
||
return `${prefix}\n${text.slice(0, budget)}`;
|
||
}
|
||
|
||
function collectUiCitationMarkdownsFromText(text) {
|
||
const trimmed = String(text || '').trim();
|
||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||
try {
|
||
return collectUiCitationMarkdowns(JSON.parse(trimmed));
|
||
} catch {}
|
||
const firstLine = trimmed.split('\n')[0]?.trim();
|
||
if (firstLine && firstLine !== trimmed && (firstLine.startsWith('{') || firstLine.startsWith('['))) {
|
||
try {
|
||
return collectUiCitationMarkdowns(JSON.parse(firstLine));
|
||
} catch {}
|
||
}
|
||
}
|
||
return collectCitationMarkdowns(trimmed);
|
||
}
|
||
|
||
// ── Register Tools ───────────────────────────────────
|
||
|
||
const tools = new ToolRegistry();
|
||
const chatOnlyTools = new ToolRegistry();
|
||
|
||
function reasonixToolNameForMnoteTool(toolName) {
|
||
return String(toolName || '').trim().replace(/\./g, '_');
|
||
}
|
||
|
||
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'],
|
||
},
|
||
parallelSafe: true,
|
||
},
|
||
{
|
||
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/citations。用户要求链接、来源、引用或证据时优先调用。不要在最终回答中手写 citationMarkdown、/documents、mnote:// 或搜索引擎包装链接;MNote 前端会把 uiCitations/citationMarkdown 自动追加成可点击来源。不要引用 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 检索后过滤 references,raw 仍可能是全局结果。',
|
||
},
|
||
},
|
||
required: ['query'],
|
||
},
|
||
parallelSafe: false,
|
||
},
|
||
{
|
||
mnoteToolName: 'mnote.knowledge_rag.open_reference',
|
||
name: 'mnote_knowledge_rag_open_reference',
|
||
description: '把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时返回定位降级。',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
reference: { type: 'object', description: 'mnote_knowledge_rag_query 返回的 reference' },
|
||
referenceId: { type: 'string' },
|
||
filePath: { type: 'string', description: 'LightRAG reference file_path' },
|
||
chunkId: { type: 'string' },
|
||
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
|
||
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
|
||
},
|
||
required: ['filePath'],
|
||
},
|
||
parallelSafe: true,
|
||
},
|
||
];
|
||
}
|
||
|
||
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',
|
||
},
|
||
});
|
||
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}`);
|
||
|
||
// ── 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.',
|
||
'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.',
|
||
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and answers that require links/sources/citations. Returned uiCitations/citationMarkdown values are MNote clickable source locators and are rendered by the MNote UI after the final answer. Do not copy citationMarkdown into the answer, do not hand-write /documents or mnote:// links, and never wrap local citation URLs with search engines. Mention source titles in plain text only when useful; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
|
||
'- 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 = [];
|
||
MNOTE_UI_CITATION_QUEUE.length = 0;
|
||
|
||
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 = toolResultTextWithUiCitations(ev.content);
|
||
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 ────────────────────────────────────────────
|
||
|
||
rpcServerReady = true;
|
||
for (const raw of queuedRpcLines.splice(0)) {
|
||
await handleRpcLine(raw);
|
||
}
|
||
process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);
|
||
}
|