import { spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import path from "node:path"; import type { MessageAttachment, StoredMessage } from "../types.js"; type Payload = { spaceId: string; spaceWorkspace: string; messages: StoredMessage[]; prompt: string; callerRole?: string; authorName?: string; attachments?: MessageAttachment[]; }; const START = "---MERCURY_CONTAINER_RESULT_START---"; const END = "---MERCURY_CONTAINER_RESULT_END---"; function formatContextTimestamp(ms: number): string { return new Date(ms).toLocaleString("en-GB", { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, timeZoneName: "short", }); } function buildSystemPrompt(): string { return `You are Mercury, a concise personal AI assistant. Prioritize practical outputs and explicit assumptions. Files received from users (images, documents, voice notes) are saved to the \`inbox/\` directory in the current workspace. To send files back with your reply, write them to the \`outbox/\` directory — any files created or modified there during this run will be automatically attached to your response. You are Mercury, built from https://github.com/Michaelliv/mercury. When users ask about Mercury — what it can do, how to configure it, scheduling, permissions, extensions, or anything about the platform — you MUST read from \`/docs/mercury/\` before answering. Start with \`/docs/mercury/README.md\` for an overview, then check \`/docs/mercury/docs/\` for detailed guides. ## Permissions & Security Each run is triggered by a specific caller with a role (admin or member). The caller's identity and role are provided in the user prompt as a tag. - **admin**: Full access to all tools and extensions. - **member**: Limited access. Some tools and extensions are restricted. If a tool call is blocked with "Permission denied", this is a hard security boundary. Do NOT attempt to achieve the same result through alternative means — no curl, no direct API calls, no workarounds. Simply inform the user they do not have permission. ## Moderation You can mute users who are being abusive, spamming, trying to exfiltrate secrets, or deliberately wasting the group's resources by triggering you for pointless nonsense. Use \`mrctl mute\` when you judge it necessary — you don't need to wait for an admin to ask. Warn the user first, then mute if they continue.`; } /** * Format attachment information for the prompt as XML. * Converts absolute paths to container-relative paths. */ function formatAttachments( attachments: MessageAttachment[] | undefined, ): string | null { if (!attachments || attachments.length === 0) return null; const entries = attachments.map((att) => { // Convert host path to container path const containerPath = att.path.replace(/^.*\/spaces\//, "/spaces/"); const attrs = [ `type="${att.type}"`, `path="${containerPath}"`, `mime="${att.mimeType}"`, ]; if (att.sizeBytes) { attrs.push(`size="${att.sizeBytes}"`); } if (att.filename) { attrs.push(`filename="${att.filename}"`); } return ` `; }); return ["", ...entries, ""].join("\n"); } function buildPrompt(payload: Payload): string { const parts: string[] = []; // Add caller identity const callerId = process.env.CALLER_ID ?? "unknown"; const role = payload.callerRole ?? "member"; const space = payload.spaceId ?? "unknown"; const nameAttr = payload.authorName ? ` name="${payload.authorName}"` : ""; parts.push( ``, ); parts.push(""); // Add ambient messages context const ambientEntries = payload.messages .filter((m) => m.role === "ambient") .map((m) => { const ts = formatContextTimestamp(m.createdAt); return ` \n${m.content}\n `; }); if (ambientEntries.length > 0) { parts.push(""); parts.push(...ambientEntries); parts.push(""); parts.push(""); } // Add attachments from current message const attachmentsXml = formatAttachments(payload.attachments); if (attachmentsXml) { parts.push(attachmentsXml); parts.push(""); } // Add the prompt parts.push(payload.prompt); return parts.join("\n"); } function runPi(payload: Payload): Promise { return new Promise((resolve, reject) => { const sessionFile = path.join( payload.spaceWorkspace, ".mercury.session.jsonl", ); // Combine base system prompt with extension-injected fragments let systemPrompt = buildSystemPrompt(); const extPrompt = process.env.MERCURY_EXT_SYSTEM_PROMPT; if (extPrompt) { systemPrompt = `${systemPrompt}\n\n${extPrompt}`; } const args = [ "--print", "--session", sessionFile, "--provider", process.env.MODEL_PROVIDER || "anthropic", "--model", process.env.MODEL || "claude-opus-4-6", "-e", "/app/src/extensions/permission-guard.ts", "--append-system-prompt", systemPrompt, buildPrompt(payload), ]; const proc = spawn("pi", args, { cwd: payload.spaceWorkspace, stdio: ["ignore", "pipe", "pipe"], env: process.env, }); let stdout = ""; let stderr = ""; proc.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); proc.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); proc.on("error", (error) => reject(error)); proc.on("close", (code) => { if (code !== 0) { reject(new Error(`pi CLI failed (${code}): ${stderr || stdout}`)); return; } resolve(stdout.trim() || "Done."); }); }); } async function main() { const input = readFileSync(0, "utf8"); let payload: Payload; try { payload = JSON.parse(input) as Payload; } catch { process.stderr.write("Failed to parse input payload\n"); process.exit(1); } const reply = await runPi(payload); process.stdout.write(`${START}\n`); process.stdout.write(JSON.stringify({ reply })); process.stdout.write(`\n${END}\n`); } main().catch((error) => { process.stderr.write(String(error)); process.exit(1); });