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:
+209
-133
@@ -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 检索后过滤 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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>',
|
||||
|
||||
Reference in New Issue
Block a user