feat(page-ai): add resumable run journal descriptors

This commit is contained in:
lix-2026
2026-06-09 18:48:46 +08:00
parent 6ef233772e
commit 922965d30f
17 changed files with 3254 additions and 58 deletions
+111 -17
View File
@@ -27,10 +27,12 @@ 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 } from 'node:child_process';
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`);
@@ -74,6 +76,75 @@ function assertSameSortedSet(actual, expected, label) {
}
}
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',
@@ -89,6 +160,22 @@ function isWriteMnoteTool(toolName) {
].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()
@@ -181,6 +268,15 @@ function buildMnoteToolPayload(toolName, rawArgs = {}, context = {}) {
}
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',
{
@@ -331,6 +427,14 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
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);
}
@@ -369,6 +473,11 @@ if (!DEEPSEEK_API_KEY) {
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;
@@ -533,22 +642,6 @@ function emitToolResult(sessionId, toolCallId, status, text) {
});
}
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',
@@ -1129,3 +1222,4 @@ for (const raw of queuedRpcLines.splice(0)) {
await handleRpcLine(raw);
}
process.stderr.write(`[reasonix-acp-mnote] ready (mnote=${MNOTE_WEB_URL})\n`);
}