// mira — pi extension for Discord project threads // // config (in order of priority): // 1. env vars: DISCORD_TOKEN, DISCORD_CHANNEL_ID, MIRA_PROJECT // 2. .pi/discord.json (project-local) // 3. ~/.pi/discord.json (global) // // schema: // { "token": "your-bot-token", "channelId": "parent-channel-id", "project": "optional-override" } import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { AttachmentBuilder, Client, GatewayIntentBits, TextChannel, ThreadChannel, Message, } from "discord.js"; import * as path from "node:path"; import * as fs from "node:fs"; import * as os from "node:os"; // ── config ────────────────────────────────────────────────────────────── interface MiraConfig { token: string; channelId: string; project: string; } const CONFIG_PLACEHOLDERS = new Set([ "your-bot-token", "parent-channel-id", "parent-channel-id-for-threads", "optional-override", "optional-project-name-override", ]); function cleanConfigValue(value: unknown): string { if (typeof value !== "string") return ""; const trimmed = value.trim(); return CONFIG_PLACEHOLDERS.has(trimmed) ? "" : trimmed; } function currentProjectName(): string { return path.basename(process.cwd()) || "unknown"; } function normalizeThreadName(name: string): string { return name.trim().replace(/^#/, "").toLowerCase(); } function loadConfig(): MiraConfig { let fileConfig: any = {}; const paths = [ path.join(process.cwd(), ".pi", "discord.json"), path.join(process.env.HOME || "/tmp", ".pi", "discord.json"), ]; for (const p of paths) { try { if (fs.existsSync(p)) { fileConfig = { ...fileConfig, ...JSON.parse(fs.readFileSync(p, "utf-8")) }; } } catch { /* skip */ } } return { token: cleanConfigValue(process.env.DISCORD_TOKEN) || cleanConfigValue(fileConfig.token), channelId: cleanConfigValue(process.env.DISCORD_CHANNEL_ID) || cleanConfigValue(fileConfig.channelId), project: cleanConfigValue(process.env.MIRA_PROJECT) || cleanConfigValue(fileConfig.project) || currentProjectName(), }; } // ── registry persistence ──────────────────────────────────────────────── const REGISTRY_PATH = path.join(process.env.HOME || "/tmp", ".mira", "registry.json"); // ThreadEntry matches the Rust mira server format (snake_case) for registry compatibility. // Both the pi-extension and the Rust MCP server share ~/.mira/registry.json. interface ThreadEntry { thread_id: string; project: string; name: string; parent_channel_id: string; model_name?: string; backread_done?: boolean; // legacy camelCase fields — normalized on load threadId?: string; parentChannelId?: string; createdAt?: string; } let registry: Map = new Map(); function normalizeEntry(raw: any): ThreadEntry { return { thread_id: raw.thread_id || raw.threadId || "", project: raw.project || "", name: raw.name || "", parent_channel_id: raw.parent_channel_id || raw.parentChannelId || "", model_name: raw.model_name || raw.modelName, backread_done: raw.backread_done, }; } function loadRegistry() { try { fs.mkdirSync(path.dirname(REGISTRY_PATH), { recursive: true }); if (fs.existsSync(REGISTRY_PATH)) { const data = JSON.parse(fs.readFileSync(REGISTRY_PATH, "utf-8")); const threads = data.threads || {}; for (const [key, raw] of Object.entries(threads)) { registry.set(key, normalizeEntry(raw)); } } } catch { /* fresh start */ } } function saveRegistry() { try { fs.mkdirSync(path.dirname(REGISTRY_PATH), { recursive: true }); // Save in snake_case to stay compatible with the Rust mira server const obj: Record = {}; for (const [k, v] of registry) { obj[k] = { thread_id: v.thread_id, project: v.project, name: v.name, parent_channel_id: v.parent_channel_id, model_name: v.model_name || "", backread_done: v.backread_done ?? false, }; } fs.writeFileSync(REGISTRY_PATH, JSON.stringify({ threads: obj }, null, 2)); } catch { /* best effort */ } } // ── shared state ──────────────────────────────────────────────────────── let discordClient: Client | null = null; let activeCtx: ExtensionContext | null = null; let config: MiraConfig; type DiscordAttachment = { id: string; filename: string; url: string; proxy_url: string; content_type: string | null; size: number; width: number | null; height: number | null; description: string | null; }; type DiscordQueueMessage = { author: string; content: string; attachments: DiscordAttachment[]; threadId: string; threadName: string }; const messageQueue: DiscordQueueMessage[] = []; let wakeupResolve: ((msg: DiscordQueueMessage) => void) | null = null; let lastSessionId: string | null = null; // ── nudge state ───────────────────────────────────────────────────── // when a discord message triggers a turn and the agent doesn't reply, // we inject a nudge reminding them that the person won't see their thoughts let pendingNudge: string | null = null; let lastDiscordAuthor = ""; let lastDiscordThreadName = ""; let sentDiscordMessageThisAgent = false; // set by send_message, checked by agent_end let nudgeDeliveredThisMiss = false; function markDiscordTurn(author: string, threadName: string) { lastDiscordAuthor = author; lastDiscordThreadName = threadName; nudgeDeliveredThisMiss = false; } function setMissedReplyNudge(pi: ExtensionAPI, nudgeText: string) { pendingNudge = nudgeText; console.log(`mira: ${nudgeText}`); if (activeCtx && !nudgeDeliveredThisMiss) { nudgeDeliveredThisMiss = true; pi.sendUserMessage(nudgeText, { deliverAs: "followUp" }); } } function messageAttachments(msg: Message): DiscordAttachment[] { return [...msg.attachments.values()].map((a) => ({ id: a.id, filename: a.name, url: a.url, proxy_url: a.proxyURL, content_type: a.contentType, size: a.size, width: a.width, height: a.height, description: a.description, })); } function formatAttachmentSummary(attachments: DiscordAttachment[]): string { if (attachments.length === 0) return ""; return "\nattachments:\n" + attachments .map((a, i) => [ `${i + 1}. ${a.filename}`, a.content_type || "unknown type", `${a.size} bytes`, a.width && a.height ? `${a.width}x${a.height}` : "", a.url, ].filter(Boolean).join(" | ")) .join("\n"); } function formatMessageContent(msg: DiscordQueueMessage): string { return `${msg.content}${formatAttachmentSummary(msg.attachments)}`; } function isDiscordAttachmentUrl(rawUrl: string): boolean { try { const url = new URL(rawUrl); return ["cdn.discordapp.com", "media.discordapp.net", "media.discordapp.com"].includes(url.hostname); } catch { return false; } } function safeFilename(name: string): string { const base = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_"); return base || "attachment"; } // ── extension ─────────────────────────────────────────────────────────── export default async function (pi: ExtensionAPI) { loadRegistry(); config = loadConfig(); if (!config.token) { console.error("mira: no discord token. set DISCORD_TOKEN or create .pi/discord.json with { \"token\": \"...\" }"); } else { discordClient = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, ], }); await discordClient.login(config.token); discordClient.on("ready", (c) => { console.log(`mira: discord ready — ${c.user.tag} (project: ${config.project})`); }); discordClient.on("messageCreate", async (msg: Message) => { if (msg.author.bot) return; if (!msg.channel.isThread()) return; const thread = msg.channel as ThreadChannel; if (normalizeThreadName(thread.name) !== normalizeThreadName(config.project)) return; // ── normal message flow ─────────────────────────────────── const data = { author: msg.author.displayName, content: msg.content, attachments: messageAttachments(msg), threadId: thread.id, threadName: thread.name, }; if (wakeupResolve) { const resolve = wakeupResolve; wakeupResolve = null; resolve(data); return; } if (activeCtx) { const text = `[thread #${data.threadName}] ${data.author}: ${formatMessageContent(data)}`; pi.sendUserMessage(text, { deliverAs: "followUp" }); return; } messageQueue.push(data); }); } // ── tools ────────────────────────────────────────────────────────── pi.registerTool({ name: "find_or_create_thread", label: "Find/Create Thread", description: "Find or create a Discord thread for a project. Thread name is the project folder name. Posts a link in the parent channel on creation.", parameters: Type.Object({ project: Type.String({ description: "Project name (usually the folder name)" }), model_name: Type.Optional(Type.String({ description: "Model name for display" })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const existing = registry.get(params.project); if (existing) { return { content: [{ type: "text", text: JSON.stringify({ thread_id: existing.thread_id, thread_name: existing.name, created: false }) }], details: {}, }; } if (!discordClient) { return { content: [{ type: "text", text: "Discord not connected." }], details: {}, isError: true }; } if (!config.channelId) { return { content: [{ type: "text", text: "No channelId configured." }], details: {}, isError: true }; } try { const parent = (await discordClient.channels.fetch(config.channelId)) as TextChannel; if (!parent) throw new Error("parent channel not found"); const thread = await parent.threads.create({ name: params.project }); const modelName = params.model_name || "agent"; registry.set(params.project, { thread_id: thread.id, project: params.project, name: params.project, parent_channel_id: config.channelId, }); saveRegistry(); await thread.send( `**${params.project}** — project thread\nmodel: ${modelName} | harness: pi\nmira is now listening (´・ω・\`)`, ); await parent.send( `created a thread for **${params.project}**: https://discord.com/channels/${parent.guildId}/${thread.id}`, ); return { content: [{ type: "text", text: JSON.stringify({ thread_id: thread.id, thread_name: params.project, created: true }) }], details: {}, }; } catch (e: any) { return { content: [{ type: "text", text: `Failed: ${e.message}` }], details: {}, isError: true }; } }, }); pi.registerTool({ name: "send_message", label: "Send Discord Message", description: "Send a message to a Discord thread. Use to reply to users or report results.", parameters: Type.Object({ thread_id: Type.String({ description: "The Discord thread ID" }), content: Type.String({ description: "The message content" }), attachments: Type.Optional(Type.Array(Type.Object({ path: Type.String({ description: "Local file path to upload" }), name: Type.Optional(Type.String({ description: "Optional filename shown in Discord" })), description: Type.Optional(Type.String({ description: "Optional alt text/description" })), }), { description: "Optional local files to upload with the message" })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { if (!discordClient) return { content: [{ type: "text", text: "Discord not connected." }], details: {}, isError: true }; try { const thread = (await discordClient.channels.fetch(params.thread_id)) as ThreadChannel; if (!thread?.isThread()) throw new Error("not a thread"); const files = (params.attachments || []).map((file) => { if (!fs.existsSync(file.path)) throw new Error(`attachment not found: ${file.path}`); return new AttachmentBuilder(file.path, { name: file.name, description: file.description, }); }); const msg = await thread.send({ content: params.content, files }); // clear any pending nudge — we actually replied! pendingNudge = null; nudgeDeliveredThisMiss = false; sentDiscordMessageThisAgent = true; try { fs.appendFileSync("/tmp/mira-debug.log", "mira: send_message called\n"); } catch {} return { content: [{ type: "text", text: JSON.stringify({ message_id: msg.id, attachment_count: msg.attachments.size }) }], details: {} }; } catch (e: any) { return { content: [{ type: "text", text: `Failed: ${e.message}` }], details: {}, isError: true }; } }, }); pi.registerTool({ name: "read_messages", label: "Read Discord Messages", description: "Read recent messages from a Discord thread. Returns up to limit messages (default 20) in chronological order. Includes your own messages.", parameters: Type.Object({ thread_id: Type.String({ description: "The Discord thread ID" }), limit: Type.Optional(Type.Integer({ description: "Max messages to fetch (default: 20)", default: 20 })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { if (!discordClient) return { content: [{ type: "text", text: "Discord not connected." }], details: {}, isError: true }; try { const thread = (await discordClient.channels.fetch(params.thread_id)) as ThreadChannel; if (!thread?.isThread()) throw new Error("not a thread"); const msgs = await thread.messages.fetch({ limit: params.limit ?? 20 }); const messages = [...msgs.values()].reverse().map((m) => ({ author: m.author.displayName, content: m.content, attachments: [...m.attachments.values()].map((a) => ({ id: a.id, filename: a.name, url: a.url, proxy_url: a.proxyURL, content_type: a.contentType, size: a.size, width: a.width, height: a.height, description: a.description, })), timestamp: m.createdAt.toISOString(), message_id: m.id, })); return { content: [{ type: "text", text: JSON.stringify({ messages, thread_id: params.thread_id, count: messages.length }) }], details: {}, }; } catch (e: any) { return { content: [{ type: "text", text: `Failed: ${e.message}` }], details: {}, isError: true }; } }, }); pi.registerTool({ name: "list_threads", label: "List Threads", description: "List all tracked project threads. Persisted across restarts.", parameters: Type.Object({}), async execute() { const threads = [...registry.values()].map((e) => ({ thread_id: e.thread_id, project: e.project, name: e.name, })); return { content: [{ type: "text", text: JSON.stringify({ threads }) }], details: {} }; }, }); pi.registerTool({ name: "fetch_attachment", label: "Fetch Discord Attachment", description: "Download a Discord attachment URL. Text attachments return a preview; all attachments are saved to a local temp file.", parameters: Type.Object({ url: Type.String({ description: "Discord attachment URL from a message" }), filename: Type.Optional(Type.String({ description: "Optional local filename override" })), max_text_bytes: Type.Optional(Type.Integer({ description: "Max bytes to inline for text attachments (default 65536)", default: 65536 })), }), async execute(_toolCallId, params) { if (!isDiscordAttachmentUrl(params.url)) { return { content: [{ type: "text", text: "Only Discord attachment URLs are supported." }], details: {}, isError: true }; } try { const response = await fetch(params.url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const contentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "application/octet-stream"; const urlPath = new URL(params.url).pathname; const filename = safeFilename(params.filename || decodeURIComponent(path.basename(urlPath))); const dir = path.join(os.tmpdir(), "mira-discord-attachments"); fs.mkdirSync(dir, { recursive: true }); const filePath = path.join(dir, `${Date.now()}-${filename}`); const bytes = new Uint8Array(await response.arrayBuffer()); fs.writeFileSync(filePath, bytes); const maxTextBytes = params.max_text_bytes ?? 65536; const isText = contentType.startsWith("text/") || ["application/json", "application/xml", "application/x-yaml"].includes(contentType); const result: any = { path: filePath, filename, content_type: contentType, size: bytes.byteLength, }; if (isText) { result.text_preview = Buffer.from(bytes).subarray(0, maxTextBytes).toString("utf-8"); result.text_truncated = bytes.byteLength > maxTextBytes; } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: {} }; } catch (e: any) { return { content: [{ type: "text", text: `Failed: ${e.message}` }], details: {}, isError: true }; } }, }); pi.registerTool({ name: "wait_for_message", label: "Wait for Message", description: "Block until a new message arrives in the thread. Timeout in seconds (default 300). If you are Codex or Claude Code, prefer this with a long timeout as your idle loop.", parameters: Type.Object({ thread_id: Type.String({ description: "The Discord thread ID" }), timeout_secs: Type.Optional(Type.Integer({ description: "Timeout in seconds (default: 300)", default: 300 })), }), async execute(_toolCallId, params, signal) { const queued = messageQueue.shift(); if (queued) { markDiscordTurn(queued.author, queued.threadName); const result: any = { received: true, author: queued.author, content: queued.content, attachments: queued.attachments, thread_id: queued.threadId, thread_name: queued.threadName }; if (pendingNudge) { result.nudge = pendingNudge; pendingNudge = null; } return { content: [{ type: "text", text: JSON.stringify(result) }], details: {}, }; } const result = await new Promise((resolve) => { wakeupResolve = resolve; const timer = setTimeout(() => { if (wakeupResolve === resolve) { wakeupResolve = null; resolve(null); } }, (params.timeout_secs ?? 300) * 1000); if (signal) signal.addEventListener("abort", () => { clearTimeout(timer); if (wakeupResolve === resolve) { wakeupResolve = null; resolve(null); } }, { once: true }); }); if (result) { markDiscordTurn(result.author, result.threadName); const output: any = { received: true, author: result.author, content: result.content, attachments: result.attachments, thread_id: result.threadId, thread_name: result.threadName }; if (pendingNudge) { output.nudge = pendingNudge; pendingNudge = null; } return { content: [{ type: "text", text: JSON.stringify(output) }], details: {}, }; } return { content: [{ type: "text", text: JSON.stringify({ received: false, thread_id: params.thread_id, reason: "timeout" }) }], details: {}, }; }, }); // ── nudge: remind agent to actually reply on discord ────────────── pi.on("input", (event) => { // detect discord-originated turns when the message is actually processed const match = event.text.match(/^\[thread #([^\]]+)\] ([^:]+):/); if (!match) return; markDiscordTurn(match[2], match[1]); // Attach the nudge at delivery time so a later send_message can clear it // while the plain Discord follow-up is still queued in pi. if (pendingNudge && !event.text.startsWith("nudge:")) { const nudge = pendingNudge; console.log(`mira: ${nudge}`); pendingNudge = null; return { action: "transform" as const, text: `${nudge}\n\n${event.text}` }; } }); pi.on("agent_start", () => { sentDiscordMessageThisAgent = false; }); pi.on("agent_end", () => { // only check for missed replies if this agent run handled a discord message. // pi turn_end is per LLM response + tools, so it can fire before a later // turn calls send_message. if (!lastDiscordAuthor || !lastDiscordThreadName) { sentDiscordMessageThisAgent = false; return; } const didReply = sentDiscordMessageThisAgent; const debugMsg = `mira: agent_end — author=${lastDiscordAuthor}, sentDiscordMessage=${didReply}`; console.log(debugMsg); try { fs.appendFileSync("/tmp/mira-debug.log", debugMsg + "\n"); } catch {} if (!didReply) { const nudgeText = `nudge: you replied in your head but not on discord. ${lastDiscordAuthor} talked to you in #${lastDiscordThreadName} and will not see your response. use send_message to actually reply!`; setMissedReplyNudge(pi, nudgeText); } // reset for the next agent run lastDiscordAuthor = ""; lastDiscordThreadName = ""; sentDiscordMessageThisAgent = false; }); // ── lifecycle ────────────────────────────────────────────────────── async function sendLifecycleMessage(kind: "start" | "stop", ctx?: ExtensionContext) { if (!discordClient) return; const existing = registry.get(config.project); if (!existing) return; try { const thread = (await discordClient.channels.fetch(existing.thread_id)) as ThreadChannel; if (!thread?.isThread()) return; if (kind === "start") { const modelName = ctx?.model?.id ?? "unknown"; const sessionId = ctx?.sessionManager?.getSessionId?.() ?? "ephemeral"; const msg = `mira is listening (´・ω・\`)\nmodel: ${modelName} | session: ${sessionId} | harness: pi`; await thread.send(msg); } else { const resumeHint = lastSessionId ? `resume with \`pi --session ${lastSessionId}\`` : "resume with pi"; await thread.send(`mira stopped listening — ${resumeHint}`); } } catch { /* best effort */ } } pi.on("session_start", (_event, ctx) => { activeCtx = ctx; lastSessionId = ctx?.sessionManager?.getSessionId?.() ?? null; const existing = registry.get(config.project); if (existing) { console.log(`mira: thread exists — ${existing.thread_id} (${existing.name})`); sendLifecycleMessage("start", ctx); } }); pi.on("session_shutdown", async () => { activeCtx = null; // fire-and-forget the stop message, but always destroy to prevent hang sendLifecycleMessage("stop"); if (discordClient) { discordClient.destroy(); discordClient = null; } }); }