Files

826 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
const CONTEXT_FILE = env("PI_MNOTE_CONTEXT_FILE")
|| DEFAULT_CONTEXT_FILE;
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);
}
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") : [];
}
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");
}
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 "";
}
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 },
): { 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);
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;
}
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")
|| stringField(input, "rootUri")
|| stringField(input, "root_uri");
const pagePath = stringField(currentPage || {}, "pagePath")
|| 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,
});
} 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);
}
}
function executeNativeSelectionRead() {
try {
const context = readContextSnapshot();
const selection = selectedContextPart(context, "selection");
return toolResult({
ok: true,
selection: selection ?? null,
text: stringField(selection || {}, "text"),
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,
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);
}
function executeNativeTool(tool: MnoteToolManifestEntry, params: unknown) {
if (!isPiRustNativeRuntime() || toolPolicy(tool.mnoteName) !== "allow") return undefined;
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);
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`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": bridgeToken(),
},
body: JSON.stringify({ sessionId, toolName, params: params || {} }),
});
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);
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;
}
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)
? (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;
}
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`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-mnote-pi-lab-bridge-token": bridgeToken(),
},
body: JSON.stringify({ sessionId, ...request }),
});
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()) {
return toolResult({
ok: false,
code: "mnote_pi_rust_service_bridge_unavailable",
message: `${tool.mnoteName} 缺少 MNote Pi bridge tokenPi Rust 当前应通过原生文件工具或 MNote 受控 bridge 调用。`,
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);
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);
}