feat(rag): harden post-LightRAG runtime

Retire legacy OCR/media/evidence fallbacks, add local-folder event bus and Page Aggregate guards, and archive completed design checklists.

Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
This commit is contained in:
lix-2026
2026-06-07 10:35:21 +08:00
parent 22a92edcda
commit 9551d4c1dc
59 changed files with 4053 additions and 5249 deletions
+209 -133
View File
@@ -25,8 +25,8 @@ 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 { dirname, join } from 'node:path';
import { fileURLToPath, 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';
@@ -38,6 +38,41 @@ function debugLog(message) {
const toolContextStorage = new AsyncLocalStorage();
const MNOTE_TOOL_NAMES = [
'mnote.skill.read',
'mnote.context.snapshot',
'mnote.context.resolve_target',
'mnote.context.read_current_page',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.open_reference',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_skill_read: 'mnote.skill.read',
mnote_context_snapshot: 'mnote.context.snapshot',
mnote_context_resolve_target: 'mnote.context.resolve_target',
mnote_context_read_current_page: 'mnote.context.read_current_page',
mnote_knowledge_rag_status: 'mnote.knowledge_rag.status',
mnote_knowledge_rag_query: 'mnote.knowledge_rag.query',
mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference',
};
function readRustManifestToolNames() {
const scriptDir = dirname(fileURLToPath(import.meta.url));
const manifestPath = join(scriptDir, '..', 'rust', 'crates', 'mnote-web', 'src', 'hermes_tools', 'manifest.rs');
const source = readFileSync(manifestPath, 'utf8');
return Array.from(source.matchAll(/"name":\s*"([^"]+)"/g)).map((match) => match[1]);
}
function assertSameSortedSet(actual, expected, label) {
const actualSorted = [...actual].sort();
const expectedSorted = [...expected].sort();
if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) {
throw new Error(`${label} mismatch: actual=${JSON.stringify(actualSorted)} expected=${JSON.stringify(expectedSorted)}`);
}
}
function isWriteMnoteTool(toolName) {
return ![
'mnote.skill.read',
@@ -278,6 +313,23 @@ if (process.env.MNOTE_REASONIX_ACP_SELFTEST === '1') {
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',
);
process.stderr.write('[reasonix-acp-mnote] selftest ok\n');
process.exit(0);
}
@@ -498,26 +550,6 @@ function emitUsage(sessionId, used, size) {
// 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.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',
};
async function callMnoteTool(toolName, args) {
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
const payload = buildMnoteToolPayload(toolName, args, toolContextStorage.getStore() || {});
@@ -542,121 +574,165 @@ async function callMnoteTool(toolName, args) {
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,
});
function reasonixToolNameForMnoteTool(toolName) {
return String(toolName || '').trim().replace(/\./g, '_');
}
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_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,可省略并使用当前上下文' },
},
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_status, args),
parallelSafe: true,
});
tools.register({
name: 'mnote_knowledge_rag_query',
description: '向 LightRAG 资料库提问,返回 answer、provider 原始结果和经 MNote registry 映射后的 references。回答必须引用返回来源。',
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 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。',
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,
},
required: ['query'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_query, args),
parallelSafe: false,
});
{
mnoteToolName: 'mnote.context.snapshot',
name: 'mnote_context_snapshot',
description: '读取本次 Page AI run 的 MNote 上下文摘要,不返回页面正文全文。',
parameters: { type: 'object', properties: { contextRefs: { type: 'array', items: {} } } },
parallelSafe: true,
},
{
mnoteToolName: 'mnote.context.resolve_target',
name: 'mnote_context_resolve_target',
description: '解析当前 MNote 工作区、文档、rootUri、relativePath 与 file version。',
parameters: { type: 'object', properties: {} },
parallelSafe: true,
},
{
mnoteToolName: 'mnote.context.read_current_page',
name: 'mnote_context_read_current_page',
description: '在用户任务明确需要当前页内容时读取当前 Markdown 页面。',
parameters: {
type: 'object',
properties: {
documentId: { type: 'string', description: '文档 ID(可选,默认当前文档)' },
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
},
},
parallelSafe: false,
},
{
mnoteToolName: 'mnote.knowledge_rag.status',
name: 'mnote_knowledge_rag_status',
description: '查看 LightRAG 资料库 provider 状态、dashboard 地址和 MNote source registry。',
parameters: {
type: 'object',
properties: {
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
},
},
parallelSafe: true,
},
{
mnoteToolName: 'mnote.knowledge_rag.query',
name: 'mnote_knowledge_rag_query',
description: '向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references。回答必须引用返回来源,不要引用 raw chunks。',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '资料库问题' },
question: { type: 'string', description: '资料库问题,等价于 query' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
mode: { type: 'string', enum: ['mix', 'local', 'global', 'naive', 'bypass'], description: 'LightRAG query mode; use mix by default for knowledge-library questions' },
topK: { type: 'integer', description: 'LightRAG top_k' },
chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' },
includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' },
sourcePaths: {
type: 'array',
items: { type: 'string' },
description: '可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 provider 检索后过滤 referencesraw 仍可能是全局结果。',
},
},
required: ['query'],
},
parallelSafe: false,
},
{
mnoteToolName: 'mnote.knowledge_rag.open_reference',
name: 'mnote_knowledge_rag_open_reference',
description: '把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时返回定位降级。',
parameters: {
type: 'object',
properties: {
reference: { type: 'object', description: 'mnote_knowledge_rag_query 返回的 reference' },
referenceId: { type: 'string' },
filePath: { type: 'string', description: 'LightRAG reference file_path' },
chunkId: { type: 'string' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
},
required: ['filePath'],
},
parallelSafe: true,
},
];
}
tools.register({
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,可省略并使用当前上下文' },
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',
},
required: ['filePath'],
},
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_knowledge_rag_open_reference, args),
parallelSafe: true,
});
});
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 ────────────────────────────────────
@@ -699,7 +775,7 @@ onRequest('session/new', async (params) => {
'<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 cite only returned references; if locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and cite only returned references; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
'</available-skills>',