Files
mnote/packages/pi-mnote/extensions/mnote-bridge.ts
T

826 lines
33 KiB
TypeScript
Raw Normal View History

2026-07-10 10:54:34 +08:00
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
type ExtensionContext = {
hasUI?: boolean;
ui?: {
confirm?: (title: string, message: string, options?: Record<string, unknown>) => Promise<boolean>;
};
};
type ExtensionAPI = {
registerTool: (spec: Record<string, unknown>) => void;
on?: (
eventName: string,
handler: (event: Record<string, unknown>) => Record<string, unknown> | void,
) => void;
};
type MnoteToolManifestEntry = {
piName: string;
mnoteName: string;
label: string;
description: string;
};
const DEFAULT_TOOLS: MnoteToolManifestEntry[] = [
{
piName: "mnote_current_page_read",
mnoteName: "mnote.current_page.read",
label: "MNote current page read",
description: "Read the current MNote page through MNote access scope.",
},
{
piName: "mnote_selection_read",
mnoteName: "mnote.selection.read",
label: "MNote selection read",
description: "Read the current MNote editor selection snapshot supplied by MNote.",
},
{
piName: "mnote_allowed_roots_describe",
mnoteName: "mnote.allowed_roots.describe",
label: "MNote allowed roots describe",
description: "Describe MNote allowed roots and disabled raw tools.",
},
{
piName: "mnote_local_file_read",
mnoteName: "mnote.local_file.read",
label: "MNote local file read",
description: "Read a file only through MNote allowed roots.",
},
{
piName: "mnote_local_file_patch",
mnoteName: "mnote.local_file.patch",
label: "MNote local file patch",
description: "Patch a file only through MNote allowed roots and watcher refresh.",
},
{
piName: "mnote_knowledge_rag_status",
mnoteName: "mnote.knowledge_rag.status",
label: "MNote LightRAG status",
description: "Check the MNote LightRAG knowledge provider status, indexed source registry, dashboard URL, and sync state. Use before knowledge-base questions when availability is uncertain. Params: optional workspaceId/rootUri; MNote fills the current Pi session context when omitted.",
},
{
piName: "mnote_knowledge_rag_query",
mnoteName: "mnote.knowledge_rag.query",
label: "MNote LightRAG query",
description: "Ask the MNote LightRAG knowledge library across indexed books, papers, Office files, PDFs, images, and attachments. Use this for knowledge-base questions and answers that require sources, citations, or evidence. For book or long-document questions, pass query, mode='naive' or 'mix', topK, chunkTopK, includeChunkContent=true, and includeDocumentStructureIndex=true. Use returned references/citations quotes as evidence; do not invent page numbers or hand-write /documents/mnote:// links.",
},
{
piName: "mnote_knowledge_rag_section_context",
mnoteName: "mnote.knowledge_rag.section_context",
label: "MNote LightRAG section context",
description: "Read bounded section blocks/chunks from a LightRAG sidecar using documentStructureIndex ranges returned by mnote_knowledge_rag_query. Use this for second-pass reading of large books or long documents when query references are not enough. Params include sourcePath/sourceId/lightRagDocId/filePath/sectionId, block or paragraph ordinal range, contextBefore/contextAfter, maxBlocks, maxChars.",
},
{
piName: "mnote_knowledge_rag_open_reference",
mnoteName: "mnote.knowledge_rag.open_reference",
label: "MNote LightRAG open reference",
description: "Convert a LightRAG reference, filePath, or chunkId returned by mnote_knowledge_rag_query into a MNote clickable local resource locator. Use when the user asks to open or verify a cited source.",
},
{
piName: "mnote_reference_open",
mnoteName: "mnote.reference.open",
label: "MNote reference open",
description: "Legacy alias for opening a citation/reference through MNote mapping. Prefer mnote_knowledge_rag_open_reference for LightRAG references.",
},
{
piName: "mnote_codex_rescue_request",
mnoteName: "mnote.codex_rescue.request",
label: "MNote Codex rescue",
description: "Ask local Codex to rescue hard MNote/Pi problems before escalating to the user. Use when tools, skills, MCP, LightRAG, environment, or local repo behavior looks broken and normal Pi troubleshooting is insufficient. This tool is admin-gated, approval-gated by default, runs codex exec with workspace-write sandbox and a timeout, and returns Codex's final answer plus stdout/stderr snippets. Provide issue, evidence/logs, attempted steps, and desired outcome. Call at most once per unresolved incident; if Codex cannot fix it, summarize the blocker to the user.",
},
{
piName: "mnote_tool_receipt_write",
mnoteName: "mnote.tool_receipt.write",
label: "MNote tool receipt write",
description: "Write a provider-neutral MNote tool receipt.",
},
];
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_CONTEXT_FILE = path.join(EXTENSION_DIR, "mnote-context.json");
const EMBEDDED_CONTEXT: Record<string, unknown> | undefined = undefined;
2026-07-10 10:54:34 +08:00
const CONTEXT_FILE = env("PI_MNOTE_CONTEXT_FILE")
|| DEFAULT_CONTEXT_FILE;
2026-07-10 10:54:34 +08:00
const RUNTIME_IMPL = env("PI_MNOTE_RUNTIME_IMPL")
|| env("MNOTE_PI_RUNTIME_IMPL")
|| (CONTEXT_FILE ? "pi-rust" : "");
const HTTP_BRIDGE_AVAILABLE = env("PI_MNOTE_HTTP_BRIDGE_AVAILABLE") === "1";
const TOOL_POLICIES = parseJsonRecord(env("PI_MNOTE_BRIDGE_TOOL_POLICIES") || env("MNOTE_PI_BRIDGE_TOOL_POLICIES"), {});
const TOOLS = normalizeTools(parseJsonUnknown(env("PI_MNOTE_BRIDGE_TOOLS") || env("MNOTE_PI_BRIDGE_TOOLS")) ?? DEFAULT_TOOLS);
const CONTEXT_PREFIX = "[[MNOTE_PI_CONTEXT_V1:";
let liveContext: Record<string, unknown> | undefined;
function env(name: string): string {
return (process.env[name] || "").trim();
}
function contextFileExists(): boolean {
return Boolean(CONTEXT_FILE) && fs.existsSync(CONTEXT_FILE);
}
2026-07-10 10:54:34 +08:00
function parseJsonUnknown(raw: string): unknown {
if (!raw) return undefined;
try {
return JSON.parse(raw);
} catch {
return undefined;
}
}
function parseJsonRecord(raw: string, fallback: Record<string, unknown>): Record<string, unknown> {
const parsed = parseJsonUnknown(raw);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: fallback;
}
function normalizeTools(value: unknown): MnoteToolManifestEntry[] {
if (!Array.isArray(value)) return DEFAULT_TOOLS;
const tools = value
.map((entry) => {
if (!entry || typeof entry !== "object") return undefined;
const record = entry as Record<string, unknown>;
const piName = stringField(record, "piName") || stringField(record, "pi_name");
const mnoteName = stringField(record, "mnoteName") || stringField(record, "mnote_name");
const label = stringField(record, "label") || piName;
const description = stringField(record, "description") || label;
if (!piName || !mnoteName) return undefined;
return { piName, mnoteName, label, description };
})
.filter((entry): entry is MnoteToolManifestEntry => Boolean(entry));
return tools.length ? tools : DEFAULT_TOOLS;
}
function stringField(record: Record<string, unknown>, key: string): string {
const value = record[key];
return typeof value === "string" ? value.trim() : "";
}
function recordField(record: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined {
if (!record) return undefined;
const value = record[key];
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
function selectedContextPart(context: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
return recordField(recordField(context, "selectedContext"), key);
}
function contextRefs(context: Record<string, unknown>): string[] {
const refs = context.contextRefs;
return Array.isArray(refs) ? refs.filter((ref): ref is string => typeof ref === "string") : [];
}
2026-07-10 10:54:34 +08:00
function toolResult(payload: Record<string, unknown>, isError = false) {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
isError,
};
}
function decodeHexJson(value: string): Record<string, unknown> {
if (!value || value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) {
throw new Error("MNote Pi context hex 无效");
}
let jsonText = "";
for (let index = 0; index < value.length; index += 2) {
jsonText += String.fromCharCode(Number.parseInt(value.slice(index, index + 2), 16));
}
const parsed = JSON.parse(decodeURIComponent(escape(jsonText)));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("MNote Pi context payload 格式无效");
}
return parsed as Record<string, unknown>;
}
function captureInputContext(event: Record<string, unknown>) {
const text = typeof event.text === "string" ? event.text : "";
const start = text.indexOf(CONTEXT_PREFIX);
if (start < 0) return { action: "continue" };
const end = text.indexOf("]]\n", start);
if (end < 0) return { action: "continue" };
const encoded = text.slice(start + CONTEXT_PREFIX.length, end);
try {
liveContext = decodeHexJson(encoded);
} catch (error) {
liveContext = {
contextError: error instanceof Error ? error.message : String(error),
};
}
return {
action: "transform",
text: `${text.slice(0, start)}${text.slice(end + 3)}`,
images: event.images,
};
}
function readContextFileSnapshot(): Record<string, unknown> {
if (!contextFileExists()) {
throw new Error("MNote Pi 上下文快照不可用;Pi Rust bridge 需要 input hook、embedded context 或 context file");
2026-07-10 10:54:34 +08:00
}
const raw = fs.readFileSync(CONTEXT_FILE, "utf8");
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("MNote Pi 上下文文件格式无效");
}
return parsed as Record<string, unknown>;
}
function embeddedContextSnapshot(): Record<string, unknown> | undefined {
return EMBEDDED_CONTEXT && typeof EMBEDDED_CONTEXT === "object" && !Array.isArray(EMBEDDED_CONTEXT)
? EMBEDDED_CONTEXT
: undefined;
}
function activeContextSnapshot(): Record<string, unknown> | undefined {
try {
if (contextFileExists()) return readContextFileSnapshot();
} catch {
// Fall back to the input-hook snapshot when the host cannot read files.
}
return liveContext || embeddedContextSnapshot();
}
function readContextSnapshot(): Record<string, unknown> {
const snapshot = activeContextSnapshot();
if (snapshot) return snapshot;
return readContextFileSnapshot();
}
function bridgeToken(): string {
const fromEnv = env("PI_MNOTE_BRIDGE_TOKEN")
|| env("MNOTE_PI_BRIDGE_TOKEN")
|| env("MNOTE_PI_LAB_BRIDGE_TOKEN");
if (fromEnv) return fromEnv;
const fromContext = activeContextSnapshot();
if (fromContext) {
const value = stringField(fromContext, "bridgeToken");
if (value) return value;
}
return "";
}
function bridgeBaseUrl(): string {
const fromEnv = env("PI_MNOTE_BRIDGE_BASE_URL")
|| env("MNOTE_PI_BRIDGE_BASE_URL")
|| env("MNOTE_PI_LAB_BASE_URL");
if (fromEnv) return fromEnv;
const fromContext = activeContextSnapshot();
if (fromContext) {
const value = stringField(fromContext, "bridgeBaseUrl");
if (value) return value;
}
return "http://127.0.0.1:3000";
}
function bridgeSessionId(): string {
const fromContext = activeContextSnapshot();
const fromContextSession = fromContext ? stringField(fromContext, "sessionId") : "";
if (fromContextSession) return fromContextSession;
const fromEnv = env("PI_MNOTE_BRIDGE_SESSION_ID")
|| env("MNOTE_PI_BRIDGE_SESSION_ID");
if (fromEnv) return fromEnv;
return "";
}
2026-07-10 10:54:34 +08:00
function contextAllowedRoots(context: Record<string, unknown>): Record<string, unknown>[] {
const snapshot = context.allowedRoots;
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return [];
const roots = (snapshot as Record<string, unknown>).roots;
if (!Array.isArray(roots)) return [];
return roots.filter((root): root is Record<string, unknown> => (
Boolean(root) && typeof root === "object" && !Array.isArray(root)
));
}
function pathIsInside(target: string, root: string): boolean {
const relative = path.relative(root, target);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function resolveAllowedTarget(
context: Record<string, unknown>,
rootUri: string,
relativePath: string,
options?: { allowMissing?: boolean },
2026-07-10 10:54:34 +08:00
): { target: string; readPath: string } {
const roots = contextAllowedRoots(context);
const matchedRoot = roots.find((root) => stringField(root, "rootUri") === rootUri);
const rootPath = matchedRoot
? stringField(matchedRoot, "rootPath")
: stringField(context, "primaryRootPath") || process.cwd();
if (!rootPath) throw new Error("MNote allowed root 缺少 rootPath");
const canonicalRoot = fs.realpathSync(rootPath);
const requestedTarget = path.isAbsolute(relativePath)
? path.resolve(relativePath)
: path.resolve(canonicalRoot, relativePath);
const canonicalTarget = options?.allowMissing
? canonicalOrParent(requestedTarget)
: fs.realpathSync(requestedTarget);
2026-07-10 10:54:34 +08:00
const allowedRoots = roots
.map((root) => stringField(root, "rootPath"))
.filter(Boolean)
.map((root) => fs.realpathSync(root));
const candidates = allowedRoots.length ? allowedRoots : [canonicalRoot];
if (!candidates.some((root) => pathIsInside(canonicalTarget, root))) {
throw new Error(`路径超出 MNote allowed roots: ${relativePath}`);
}
const processRoot = fs.realpathSync(process.cwd());
const readPath = pathIsInside(canonicalTarget, processRoot)
? path.relative(processRoot, canonicalTarget) || "."
: canonicalTarget;
return { target: canonicalTarget, readPath };
}
function canonicalOrParent(target: string): string {
try {
return fs.realpathSync(target);
} catch {
const parent = path.dirname(target);
try {
return path.join(fs.realpathSync(parent), path.basename(target));
} catch {
return path.resolve(target);
}
}
}
function sessionRootUri(context: Record<string, unknown>, input: Record<string, unknown>): string {
return stringField(input, "rootUri")
|| stringField(input, "root_uri")
|| stringField(selectedContextPart(context, "currentPage") || {}, "rootUri")
|| stringField(selectedContextPart(context, "currentFolder") || {}, "rootUri")
|| stringField(context, "rootUri");
}
function joinRelativePath(basePath: string, childPath: string): string {
const normalizedChild = childPath.replace(/\\/g, "/").replace(/^\/+/, "");
const normalizedBase = basePath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
if (!normalizedBase || path.isAbsolute(childPath)) return normalizedChild;
if (!normalizedChild || normalizedChild === ".") return normalizedBase;
if (normalizedChild === normalizedBase || normalizedChild.startsWith(`${normalizedBase}/`)) return normalizedChild;
return `${normalizedBase}/${normalizedChild}`;
}
function sessionRelativePath(context: Record<string, unknown>, input: Record<string, unknown>, options?: { preferFolder?: boolean }): string {
const explicitPath = stringField(input, "relativePath")
|| stringField(input, "relative_path")
|| stringField(input, "path");
const folderPath = stringField(input, "folderPath")
|| stringField(input, "folder_path")
|| stringField(selectedContextPart(context, "currentFolder") || {}, "folderPath");
if (explicitPath) {
return options?.preferFolder ? joinRelativePath(folderPath, explicitPath) : explicitPath;
}
return stringField(input, "pagePath")
|| stringField(input, "page_path")
|| stringField(selectedContextPart(context, "currentPage") || {}, "pagePath")
|| folderPath
|| stringField(context, "pagePath");
}
function contextualToolParams(toolName: string, params: unknown): Record<string, unknown> {
const nextParams = { ...((params || {}) as Record<string, unknown>) };
const context = activeContextSnapshot();
if (!context) return nextParams;
const currentPage = selectedContextPart(context, "currentPage");
const currentFolder = selectedContextPart(context, "currentFolder");
const selection = selectedContextPart(context, "selection");
if (!stringField(nextParams, "rootUri")) {
nextParams.rootUri = stringField(currentPage || {}, "rootUri")
|| stringField(currentFolder || {}, "rootUri")
|| stringField(context, "rootUri")
|| undefined;
}
if (!stringField(nextParams, "workspaceId")) {
nextParams.workspaceId = stringField(currentPage || {}, "workspaceId")
|| stringField(currentFolder || {}, "workspaceId")
|| stringField(context, "workspaceId")
|| undefined;
}
if (!stringField(nextParams, "pagePath")) {
nextParams.pagePath = stringField(currentPage || {}, "pagePath") || undefined;
}
if (!stringField(nextParams, "folderPath")) {
nextParams.folderPath = stringField(currentFolder || {}, "folderPath") || undefined;
}
if (toolName === "mnote.selection.read") {
nextParams.selectionSource = "mnote_sidebar_host";
nextParams.selection = selection || null;
}
return nextParams;
}
2026-07-10 10:54:34 +08:00
function executeNativeCurrentPageRead(params: unknown) {
try {
const input = params && typeof params === "object" && !Array.isArray(params)
? params as Record<string, unknown>
: {};
const context = readContextSnapshot();
const currentPage = selectedContextPart(context, "currentPage");
const rootUri = stringField(currentPage || {}, "rootUri")
2026-07-10 10:54:34 +08:00
|| stringField(input, "rootUri")
|| stringField(input, "root_uri");
const pagePath = stringField(currentPage || {}, "pagePath")
2026-07-10 10:54:34 +08:00
|| stringField(input, "pagePath")
|| stringField(input, "page_path")
|| stringField(input, "path");
if (!rootUri) throw new Error("读取当前页缺少 rootUri");
if (!pagePath) throw new Error("读取当前页缺少 pagePath");
const resolved = resolveAllowedTarget(context, rootUri, pagePath);
const content = fs.readFileSync(resolved.readPath, "utf8");
const stat = fs.statSync(resolved.readPath);
return toolResult({
ok: true,
rootUri,
pagePath,
path: resolved.target,
content,
contentLength: content.length,
format: "markdown",
fileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
contextRefs: contextRefs(context),
selectedContextCurrentPage: currentPage ?? null,
2026-07-10 10:54:34 +08:00
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_current_page_read_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
}, true);
}
}
function executeNativeLocalFileRead(params: unknown) {
try {
const input = params && typeof params === "object" && !Array.isArray(params)
? params as Record<string, unknown>
: {};
const context = readContextSnapshot();
const rootUri = sessionRootUri(context, input);
const relativePath = sessionRelativePath(context, input, { preferFolder: true });
if (!rootUri) throw new Error("读取文件缺少 rootUri");
if (!relativePath) throw new Error("读取文件缺少 relativePath/path");
const resolved = resolveAllowedTarget(context, rootUri, relativePath);
const content = fs.readFileSync(resolved.readPath, "utf8");
const stat = fs.statSync(resolved.readPath);
return toolResult({
ok: true,
rootUri,
relativePath,
path: resolved.target,
content,
contentLength: content.length,
fileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_local_file_read_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
}, true);
}
}
function applyTextOperations(current: string, operations: unknown): string {
if (!Array.isArray(operations)) throw new Error("operations 必须是数组");
let next = current;
for (const operation of operations) {
if (!operation || typeof operation !== "object" || Array.isArray(operation)) {
throw new Error("operation 格式无效");
}
const op = operation as Record<string, unknown>;
const type = stringField(op, "type") || stringField(op, "op");
if (type === "replace") {
const oldText = typeof op.oldText === "string" ? op.oldText : typeof op.old_text === "string" ? op.old_text : "";
const newText = typeof op.newText === "string" ? op.newText : typeof op.new_text === "string" ? op.new_text : "";
if (!oldText) throw new Error("replace operation 缺少 oldText");
const index = next.indexOf(oldText);
if (index < 0) throw new Error("replace operation 未找到 oldText");
next = next.slice(0, index) + newText + next.slice(index + oldText.length);
} else if (type === "append") {
const text = typeof op.text === "string" ? op.text : "";
next += text;
} else {
throw new Error(`不支持的 operation: ${type || "unknown"}`);
}
}
return next;
}
function executeNativeLocalFilePatch(params: unknown) {
try {
const input = params && typeof params === "object" && !Array.isArray(params)
? params as Record<string, unknown>
: {};
const context = readContextSnapshot();
const rootUri = sessionRootUri(context, input);
const relativePath = sessionRelativePath(context, input, { preferFolder: true });
if (!rootUri) throw new Error("写入文件缺少 rootUri");
if (!relativePath) throw new Error("写入文件缺少 relativePath/path");
const resolved = resolveAllowedTarget(context, rootUri, relativePath, { allowMissing: true });
const before = fs.existsSync(resolved.target) ? fs.readFileSync(resolved.target, "utf8") : "";
const next = typeof input.content === "string"
? input.content
: applyTextOperations(before, input.operations);
fs.mkdirSync(path.dirname(resolved.target), { recursive: true });
fs.writeFileSync(resolved.target, next, "utf8");
const stat = fs.statSync(resolved.target);
return toolResult({
ok: true,
rootUri,
relativePath,
path: resolved.target,
beforeFileVersion: `pi-rust-native-before-${before.length}`,
afterFileVersion: `pi-rust-native-${Math.trunc(stat.mtimeMs)}-${stat.size}`,
oldSize: before.length,
newSize: next.length,
diffSummary: before === next ? "no_changes" : `bytes_delta=${next.length - before.length}`,
refresh: "mnote local-folder watcher / document-session external refresh",
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_local_file_patch_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-fs",
contextFile: CONTEXT_FILE,
}, true);
}
}
2026-07-10 10:54:34 +08:00
function executeNativeSelectionRead() {
try {
const context = readContextSnapshot();
const selection = selectedContextPart(context, "selection");
2026-07-10 10:54:34 +08:00
return toolResult({
ok: true,
selection: selection ?? null,
text: stringField(selection || {}, "text"),
2026-07-10 10:54:34 +08:00
selectionSource: "mnote_sidebar_host_snapshot",
rootUri: context.rootUri ?? null,
pagePath: context.pagePath ?? null,
contextRefs: context.contextRefs ?? [],
transport: "pi-rust-native-context-file",
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_selection_read_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-context-file",
}, true);
}
}
function executeNativeAllowedRootsDescribe() {
try {
const context = readContextSnapshot();
return toolResult({
ok: true,
allowedRoots: context.allowedRoots ?? { roots: [] },
rootUri: context.rootUri ?? null,
pagePath: context.pagePath ?? null,
workspaceId: context.workspaceId ?? null,
contextRefs: context.contextRefs ?? [],
selectedContext: context.selectedContext ?? null,
2026-07-10 10:54:34 +08:00
primaryRootPath: context.primaryRootPath ?? process.cwd(),
transport: "pi-rust-native-context-file",
});
} catch (error) {
return toolResult({
ok: false,
code: "mnote_pi_rust_allowed_roots_read_failed",
message: error instanceof Error ? error.message : String(error),
transport: "pi-rust-native-context-file",
}, true);
}
}
function isPiRustNativeRuntime(): boolean {
return runtimeImplementation() === "pi-rust" || Boolean(CONTEXT_FILE);
}
2026-07-10 10:54:34 +08:00
function executeNativeTool(tool: MnoteToolManifestEntry, params: unknown) {
if (!isPiRustNativeRuntime() || toolPolicy(tool.mnoteName) !== "allow") return undefined;
2026-07-10 10:54:34 +08:00
if (tool.mnoteName === "mnote.current_page.read") return executeNativeCurrentPageRead(params);
if (tool.mnoteName === "mnote.selection.read") return executeNativeSelectionRead();
if (tool.mnoteName === "mnote.allowed_roots.describe") return executeNativeAllowedRootsDescribe();
if (tool.mnoteName === "mnote.local_file.read") return executeNativeLocalFileRead(params);
if (tool.mnoteName === "mnote.local_file.patch") return executeNativeLocalFilePatch(params);
2026-07-10 10:54:34 +08:00
return undefined;
}
async function callMnote(toolName: string, params: unknown) {
const sessionId = bridgeSessionId();
if (!sessionId) {
return toolResult({
ok: false,
code: "mnote_pi_bridge_session_id_missing",
message: "MNote Pi bridge 缺少有效的 pi_lab sessionId;已忽略 Pi runtime provider session id。",
toolName,
transport: "mnote-bridge-http",
}, true);
}
const response = await fetch(`${bridgeBaseUrl()}/api/page-ai/pi/tool-call-bridge`, {
2026-07-10 10:54:34 +08:00
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": bridgeToken(),
2026-07-10 10:54:34 +08:00
},
body: JSON.stringify({ sessionId, toolName, params: params || {} }),
2026-07-10 10:54:34 +08:00
});
const payload = await response.json().catch(() => ({ ok: false, code: "bad_json" }));
const result = (payload as Record<string, unknown>).result || payload;
const text = JSON.stringify(modelSafeBridgeResult(toolName, result), null, 2);
2026-07-10 10:54:34 +08:00
return {
content: [{ type: "text", text }],
details: payload,
};
}
function modelSafeBridgeResult(toolName: string, value: unknown): unknown {
if (toolName !== "mnote.knowledge_rag.query" || !value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result = { ...(value as Record<string, unknown>) };
delete result.uiCitations;
delete result.citationMarkdowns;
delete result.citationRendering;
result.uiCitationCount = Array.isArray((value as Record<string, unknown>).uiCitations)
? ((value as Record<string, unknown>).uiCitations as unknown[]).length
: 0;
result.uiCitationDelivery = "Clickable citations are retained in tool details for the MNote UI and omitted from model context.";
return result;
}
2026-07-10 10:54:34 +08:00
function stableJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
}
function stableHash(value: string): string {
let hash = 5381 >>> 0;
for (let index = 0; index < value.length; index += 1) {
hash = (((hash << 5) + hash) + value.charCodeAt(index)) >>> 0;
}
return hash.toString(16);
}
function paramsHash(params: unknown): string {
const copy = { ...((params || {}) as Record<string, unknown>) };
delete copy.mnoteApproval;
delete copy.mnote_approval;
return stableHash(stableJson(copy));
}
function toolPolicy(toolName: string): string {
const contextPolicies = activeContextSnapshot()?.toolPolicies;
let contextValue = contextPolicies && typeof contextPolicies === "object" && !Array.isArray(contextPolicies)
2026-07-10 10:54:34 +08:00
? (contextPolicies as Record<string, unknown>)[toolName]
: undefined;
const value = contextValue ?? TOOL_POLICIES[toolName];
return typeof value === "string" && value.trim() ? value.trim() : "allow";
}
function runtimeImplementation(): string {
const fromContext = activeContextSnapshot();
return fromContext ? stringField(fromContext, "runtimeImplementation") || RUNTIME_IMPL : RUNTIME_IMPL;
2026-07-10 10:54:34 +08:00
}
async function requestMnoteApprovalViaBridge(request: Record<string, unknown>): Promise<Record<string, unknown>> {
const sessionId = bridgeSessionId();
if (!sessionId) {
return {
ok: false,
cancelled: true,
code: "mnote_pi_bridge_session_id_missing",
message: "MNote Pi bridge 缺少有效的 pi_lab sessionId",
};
}
const response = await fetch(`${bridgeBaseUrl()}/api/page-ai/pi/ui-request-bridge`, {
2026-07-10 10:54:34 +08:00
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": bridgeToken(),
2026-07-10 10:54:34 +08:00
},
body: JSON.stringify({ sessionId, ...request }),
2026-07-10 10:54:34 +08:00
});
return await response.json().catch(() => ({ ok: false, cancelled: true, code: "bad_json" }));
}
async function requestMnoteApproval(
ctx: ExtensionContext | undefined,
toolCallId: string,
toolName: string,
label: string,
params: unknown,
): Promise<null | Record<string, unknown>> {
if (toolPolicy(toolName) !== "ask") return null;
const approvalId = toolCallId || `approval_${Date.now()}_${Math.random().toString(16).slice(2)}`;
const approval = { approvalId, toolName, paramsHash: paramsHash(params) };
const summary = JSON.stringify(params || {}, null, 2).slice(0, 1200);
const message = `${label}\n${toolName}\n\n${summary}`;
if (ctx?.hasUI && ctx.ui && typeof ctx.ui.confirm === "function") {
const result = await ctx.ui.confirm("审批 Pi 工具调用", message, { timeout: 60000 });
if (result === true) return approval;
return { ...approval, denied: true };
}
const result = await requestMnoteApprovalViaBridge({
id: approvalId,
method: "confirm",
title: "审批 Pi 工具调用",
message,
timeoutMs: 60000,
mnoteApproval: approval,
});
if (result && result.confirmed === true) return approval;
return { ...approval, denied: true };
}
function register(pi: ExtensionAPI, tool: MnoteToolManifestEntry) {
pi.registerTool({
name: tool.piName,
label: tool.label,
description: tool.description,
promptSnippet: `${tool.label}: ${tool.description}`,
parameters: { type: "object", additionalProperties: true },
execute(toolCallId: unknown, params: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext | undefined) {
if (toolPolicy(tool.mnoteName) === "deny") {
return toolResult({
ok: false,
code: "mnote_tool_denied_by_permission_mode",
message: `当前 Pi 模式禁止调用工具 ${tool.mnoteName}`,
toolName: tool.mnoteName,
}, true);
}
const nativeResult = executeNativeTool(tool, params);
if (nativeResult) return nativeResult;
if (runtimeImplementation() === "pi-rust" && !HTTP_BRIDGE_AVAILABLE && !bridgeToken()) {
2026-07-10 10:54:34 +08:00
return toolResult({
ok: false,
code: "mnote_pi_rust_service_bridge_unavailable",
message: `${tool.mnoteName} 缺少 MNote Pi bridge tokenPi Rust 当前应通过原生文件工具或 MNote 受控 bridge 调用。`,
2026-07-10 10:54:34 +08:00
toolName: tool.mnoteName,
runtimeImplementation: runtimeImplementation(),
}, true);
}
return executeBridgeTool(toolCallId, params, ctx, tool);
},
});
}
async function executeBridgeTool(
toolCallId: unknown,
params: unknown,
ctx: ExtensionContext | undefined,
tool: MnoteToolManifestEntry,
) {
const nextParams = contextualToolParams(tool.mnoteName, params);
2026-07-10 10:54:34 +08:00
const approval = await requestMnoteApproval(ctx, String(toolCallId || ""), tool.mnoteName, tool.label, nextParams);
if (approval && approval.denied) {
return {
content: [{ type: "text", text: JSON.stringify({ ok: false, code: "mnote_tool_approval_cancelled", message: "用户拒绝或未完成 MNote 工具审批", toolName: tool.mnoteName }, null, 2) }],
details: { ok: false, code: "mnote_tool_approval_cancelled", toolName: tool.mnoteName },
};
}
if (toolPolicy(tool.mnoteName) === "ask") {
nextParams.mnoteApproval = {
...approval,
confirmed: true,
method: "extension_ui_confirm",
toolCallId: String(toolCallId || ""),
toolName: tool.mnoteName,
approvedAt: new Date().toISOString(),
};
}
return callMnote(tool.mnoteName, nextParams);
}
export default function mnotePi(pi: ExtensionAPI) {
pi.on?.("input", captureInputContext);
for (const tool of TOOLS) register(pi, tool);
}