Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478#!/usr/bin/env node// dm-mcp.mjs — one MCP surface over a person's real DMs: iMessage + Signal,// read AND send, so any agent can digest "what did X say" and reply without// knowing which app or CLI it lives in.//// It wraps the sibling bridges that already exist in slab/bin:// • signal.mjs — reads Signal Desktop's synced SQLCipher DB (status / read /// attachments / save). Read-only; carries no send verb.// • signal-cli — the linked-device CLI (Homebrew). Adds the SEND path Signal// Desktop can't script, plus contact resolution + receive.// • imsg.mjs — reads Messages' chat.db + sends via Messages.app// (status / indexed chats / history / FTS search / send).//// House style: hand-rolled JSON-RPC over stdio (newline-delimited), matching// frame-mcp.mjs / ants/mail-mcp / artery/emacs-mcp.mjs — no SDK, node builtins// only, so it travels with the repo and needs no PATH setup. `--http [port]`// runs one resident daemon every session shares (toolchain/mcp/http-front.mjs).//// LOCAL-FIRST. This instance operates on the machine it runs on (neo). neo and// blueberry are linked to the SAME Signal account and the SAME iMessage account,// so their message CONTENT mirrors — the reason to have both is availability,// not different inboxes. The `machine` arg is accepted and, for signal-cli-// backed calls (send / contacts / receive), routes over `ssh <machine>` using// the host in ~/.config/slab/puppet.json (same registry `frame` reads).// Desktop-DB reads (signal.mjs / imsg.mjs) stay local — to read blueberry's// Desktop DB, run a dm-mcp instance THERE. That per-machine daemon is the// documented next step, mirroring how the http-front daemons already deploy.//// SAFETY: dm_send NEVER sends on the first call. It resolves + echoes the target// and the message and asks for `confirm: true` — outward, hard-to-unsend, often// to NDA contacts. This is the send guardrail baked in, not bolted on.import { execFile } from "node:child_process";import { existsSync, readFileSync } from "node:fs";import { homedir } from "node:os";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { httpPort, serveHttp, serveStdio } from "../../toolchain/mcp/http-front.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));const SIGNAL = join(HERE, "signal.mjs");const IMSG = join(HERE, "imsg.mjs");const PUPPET_JSON = join(homedir(), ".config", "slab", "puppet.json");
// ── shell helpers ───────────────────────────────────────────────────────────// Run a command, resolve { stdout, stderr }. Rejects with stderr (not a bare// exit code) so tool errors read cleanly back to the agent.function run(cmd, args, { timeoutMs = 30000, input } = {}) { return new Promise((resolve, reject) => { const child = execFile( cmd, args, { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, encoding: "utf8" }, (err, stdout, stderr) => { if (err && !stdout) return reject(new Error((stderr || err.message).trim())); resolve({ stdout, stderr }); }, ); if (input != null) { child.stdin.write(input); child.stdin.end(); } });}
// The ssh host for a machine name, from the registry `frame`/`puppet` share.// Falls back to the bare name (works when it's already an ssh alias, e.g. blueberry).function sshHost(machine) { try { if (existsSync(PUPPET_JSON)) { const reg = JSON.parse(readFileSync(PUPPET_JSON, "utf8")); const m = (reg.machines || reg)[machine]; if (m) return m.ssh || m.host || machine; } } catch { /* fall through */ } return machine;}
const LOCAL = new Set(["", "local", "neo", "this", undefined, null]);const isLocal = (machine) => LOCAL.has(machine);
// Run a signal-cli invocation, locally or over ssh for a remote machine.function runSignalCli(args, machine, opts) { if (isLocal(machine)) return run("signal-cli", args, opts); // quote each arg for the remote shell; signal-cli is on PATH on linked machines const remote = ["signal-cli", ...args].map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(" "); return run("ssh", [sshHost(machine), remote], opts);}
// A node bridge (signal.mjs / imsg.mjs) only runs where its Desktop DB lives.function runBridge(bridge, args, machine, opts) { if (!isLocal(machine)) { throw new Error( `Desktop-DB read for "${machine}" isn't available from this instance — ` + `run a dm-mcp instance on ${machine} for its view. (signal-cli-backed ` + `tools like dm_send/dm_contacts DO route to ${machine} over ssh.)`, ); } return run("node", [bridge, ...args], opts);}
// ── Signal account (the linked +number) — detected once, cached ──────────────let _acctCache;async function signalAccount(machine) { if (isLocal(machine) && _acctCache) return _acctCache; const { stdout } = await runSignalCli(["listAccounts"], machine); const m = stdout.match(/\+[0-9]+/); if (!m) throw new Error("no linked signal-cli account (run: signal-cli link)"); if (isLocal(machine)) _acctCache = m[0]; return m[0];}
// Resolve a Signal recipient the agent named. Accept ACI/UUID, +E164, or a// profile-name substring (resolved via signal-cli listContacts → its ACI).async function resolveSignalRecipient(to, machine) { if (!to) throw new Error("`to` is required for a Signal send (ACI, +number, or a name)"); if (/^\+[0-9]{6,}$/.test(to)) return { id: to, label: to, how: "e164" }; if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(to)) return { id: to, label: `ACI ${to}`, how: "aci" }; const acct = await signalAccount(machine); const { stdout } = await runSignalCli(["-a", acct, "listContacts"], machine); const needle = to.toLowerCase(); const hits = stdout.split("\n").filter((l) => l.toLowerCase().includes(needle)); const withAci = hits.map((l) => l.match(/ACI:\s*([0-9a-f-]{36})/i)?.[1]).filter(Boolean); if (!withAci.length) throw new Error(`no Signal contact matched "${to}" — try dm_contacts to find the ACI`); if (withAci.length > 1) throw new Error(`"${to}" matched ${withAci.length} contacts — pass the exact ACI (dm_contacts)`); return { id: withAci[0], label: `${to} (ACI ${withAci[0]})`, how: "name→aci" };}
const text = (s) => [{ type: "text", text: s }];
// ── tools ────────────────────────────────────────────────────────────────────
// Unified "what's waiting" across both channels. Returning the snapshot is// ingestion, so both Slab-owned notification cursors advance afterward.async function toolInbox({ machine } = {}) { const L = []; const names = ["Signal", "iMessage"]; const results = await Promise.allSettled([ runBridge(SIGNAL, ["status"], machine), runBridge(IMSG, ["status"], machine), ]); // Capturing the summaries is ingestion: clear Slab's passive notification // cursors only after both snapshots have been obtained. await Promise.allSettled([ runBridge(SIGNAL, ["ack"], machine), runBridge(IMSG, ["ack"], machine), ]); results.forEach((r, i) => { const name = names[i]; if (r.status === "rejected") { L.push(`${name}: ⚠️ ${r.reason.message}`); return; } let j; try { j = JSON.parse(r.value.stdout); } catch { L.push(`${name}: ${r.value.stdout.trim()}`); return; } const last = j.last || {}; const direction = last.dir || last.direction || (last.fromMe === true ? "outgoing" : last.fromMe === false ? "incoming" : ""); L.push( `${name}: ${j.name || j.displayName || "(contact)"} — un-ingested ${j.unread ?? "?"}` + (last.text ? ` · last ${direction} "${String(last.text).slice(0, 80)}" (${last.ago || "?"})` : ""), ); }); return text(L.join("\n"));}
// The recent-conversation list — who's talking, unread, last message. Find a// thread here, then pass its name/id to dm_read's `to`.async function toolChats({ channel = "signal", n = 20, machine } = {}) { const ch = String(channel).toLowerCase(); if (!["signal", "imessage", "imsg"].includes(ch)) { throw new Error(`unknown channel "${channel}" (use "signal" or "imessage")`); } const bridge = ch === "imessage" || ch === "imsg" ? IMSG : SIGNAL; const { stdout } = await runBridge(bridge, ["chats", String(n)], machine, { timeoutMs: 120000 }); return text(stdout.trim() || "(no conversations)");}
// The groups-only view of the above (Signal group threads: name + id).async function toolGroups({ n = 20, machine } = {}) { const { stdout } = await runBridge(SIGNAL, ["groups", String(n)], machine); return text(stdout.trim() || "(no groups)");}
// Thread history. Both channels accept an optional target without changing the// notification/default contact. iMessage history comes from its private local// incremental FTS index; Signal reads its Desktop database directly.// `to` (alias `group`) targets ANY conversation — group or person — by name or// conversationId; omit for the configured 1:1 contact. Group reads attribute// each sender. An ambiguous target fails loudly rather than guessing.async function toolRead({ channel, n = 15, to, group, machine } = {}) { const ch = (channel || "signal").toLowerCase(); const dest = to || group; if (ch === "signal") { const toArgs = dest ? ["--to", String(dest)] : []; const { stdout } = await runBridge(SIGNAL, ["read", String(n), ...toArgs], machine); return text(stdout.trim() || "(no messages)"); } if (ch === "imessage" || ch === "imsg") { if (group) throw new Error("iMessage group lookup uses `to`, not the Signal-only `group` alias"); const toArgs = dest ? ["--to", String(dest)] : []; const { stdout } = await runBridge(IMSG, ["read", String(n), ...toArgs], machine, { timeoutMs: 120000 }); return text(stdout.trim() || "(no messages)"); } throw new Error(`unknown channel "${channel}" (use "signal" or "imessage")`);}
// Full-text iMessage search, optionally scoped to one configured contact or// raw handle. This never changes or acknowledges the watched/default thread.async function toolSearch({ query, to, n = 25, machine } = {}) { if (!String(query || "").trim()) throw new Error("`query` is required"); const args = ["search", String(query), "--limit", String(n)]; if (to) args.push("--to", String(to)); const { stdout } = await runBridge(IMSG, args, machine, { timeoutMs: 120000 }); return text(stdout.trim() || "(no matching iMessages)");}
// List / decrypt Signal attachments (the voice-memo → transcribe pipeline).async function toolAttachments({ n = 10, save, machine } = {}) { if (save !== undefined && save !== null) { const { stdout } = await runBridge(SIGNAL, ["save", String(save)], machine); const path = stdout.trim().split("\n").pop(); return text( `saved → ${path}\n` + `(audio? transcribe: ffmpeg -y -i "${path}" -ar 16000 -ac 1 -c:a pcm_s16le /tmp/x.wav ` + `&& whisper-cli -m ~/.whisper-models/ggml-small.bin -f /tmp/x.wav -otxt -of /tmp/x)`, ); } const { stdout } = await runBridge(SIGNAL, ["attachments", String(n)], machine); return text(stdout.trim() || "(no attachments)");}
// Find a Signal contact's ACI by name.async function toolContacts({ query, machine } = {}) { const acct = await signalAccount(machine); const { stdout } = await runSignalCli(["-a", acct, "listContacts"], machine); const lines = stdout.split("\n").filter(Boolean); const shown = query ? lines.filter((l) => l.toLowerCase().includes(query.toLowerCase())) : lines; return text(shown.slice(0, 40).join("\n") || `(no contacts${query ? ` matching "${query}"` : ""})`);}
// Send — two-step by design. First call previews the resolved target + message;// only `confirm: true` actually sends.async function toolSend({ channel, to, text: body, confirm, machine } = {}) { const ch = (channel || "").toLowerCase(); if (!body) throw new Error("`text` (the message) is required");
if (ch === "signal") { const rcpt = await resolveSignalRecipient(to, machine); if (!confirm) { return text( `PREVIEW — not sent. Re-call with confirm:true to send.\n` + `channel: Signal machine: ${isLocal(machine) ? "local" : machine}\n` + `to: ${rcpt.label} [${rcpt.how}]\n--- message ---\n${body}`, ); } const acct = await signalAccount(machine); const { stdout } = await runSignalCli(["-a", acct, "send", "-m", body, rcpt.id], machine, { timeoutMs: 60000 }); // Best effort: if this is the conversation Slab watches, replying is also // an acknowledgement. Other conversations keep independent cursors. await runBridge(SIGNAL, ["ack", "--to", String(to)], machine).catch(() => {}); const ts = (stdout.match(/\d{10,}/) || [])[0]; return text(`✅ sent to ${rcpt.label}${ts ? ` (ts ${ts})` : ""}`); }
if (ch === "imessage" || ch === "imsg") { // Never silently fall back to imsg.json's default contact. An explicit // recipient is required, resolved before preview, and resolved again by // imsg.mjs at send time. if (!to) throw new Error("`to` is required for an iMessage send (named contact or raw handle)"); const toArgs = ["--to", String(to)]; const { stdout: resolved } = await runBridge(IMSG, ["resolve", ...toArgs], machine, { timeoutMs: 30000 }); const rcpt = JSON.parse(resolved); if (!confirm) { return text( `PREVIEW — not sent. Re-call with confirm:true to send.\n` + `channel: iMessage machine: ${isLocal(machine) ? "local" : machine}\n` + `to: ${rcpt.displayName} [requested: "${to}"]\n` + `--- message ---\n${body}`, ); } const { stdout } = await runBridge(IMSG, ["send", body, ...toArgs], machine, { timeoutMs: 30000 }); return text(`✅ iMessage sent to ${rcpt.displayName}${stdout.trim() ? `: ${stdout.trim()}` : ""}`); }
throw new Error(`unknown channel "${channel}" (use "signal" or "imessage")`);}
const TAPBACK_LABELS = new Map([ ["heart", "heart"], ["love", "heart"], ["thumbs-up", "thumbs up"], ["thumbsup", "thumbs up"], ["like", "thumbs up"], ["thumbs-down", "thumbs down"], ["thumbsdown", "thumbs down"], ["dislike", "thumbs down"], ["haha", "Ha Ha"], ["laugh", "Ha Ha"], ["emphasis", "emphasis"], ["!!", "emphasis"], ["question", "question mark"], ["?", "question mark"],]);
// Classic iMessage Tapback on the selected contact's most recent incoming// message. Apple exposes this through Messages' Cmd-T, 1–6 shortcuts rather// than its scripting dictionary, so execution uses guarded UI automation.async function toolReact({ to, reaction, confirm, machine } = {}) { if (!to) throw new Error("`to` is required for an iMessage reaction (named contact or raw handle)"); const requested = String(reaction || "").trim().toLowerCase(); const label = TAPBACK_LABELS.get(requested); if (!label) throw new Error("unknown `reaction` — use heart, thumbs-up, thumbs-down, haha, emphasis, or question"); const toArgs = ["--to", String(to)]; const { stdout: resolved } = await runBridge(IMSG, ["resolve", ...toArgs], machine, { timeoutMs: 30000 }); const rcpt = JSON.parse(resolved); if (!confirm) { return text( `PREVIEW — no reaction added. Re-call with confirm:true to react.\n` + `channel: iMessage machine: ${isLocal(machine) ? "local" : machine}\n` + `to: ${rcpt.displayName} [requested: "${to}"]\n` + `target: most recent incoming message\nreaction: ${label}`, ); } const { stdout } = await runBridge(IMSG, ["react", requested, ...toArgs], machine, { timeoutMs: 30000 }); const result = JSON.parse(stdout); return text(`✅ added ${result.reaction} Tapback to ${result.displayName}'s most recent incoming message`);}
const TOOLS = [ { name: "dm_inbox", description: "Unified 'what's waiting' across Signal + iMessage on this machine: per channel, the configured contact, un-ingested count, and the last message (direction, text, how long ago). Reading this snapshot acknowledges both Slab notification cursors.", inputSchema: { type: "object", properties: { machine: { type: "string", description: "Machine to read (default local). Desktop-DB reads are local-only." } } }, }, { name: "dm_chats", description: "List recent conversations for Signal or iMessage (most-recent-first). iMessage uses a private local incremental index and does not change the watched/default contact. Pass a result's name/id to dm_read's `to`.", inputSchema: { type: "object", properties: { channel: { type: "string", enum: ["signal", "imessage"], description: "Channel to list (default signal)." }, n: { type: "number", description: "How many conversations (default 20)." }, machine: { type: "string", description: "Machine (default local; Signal Desktop DB is local-only)." }, }, }, }, { name: "dm_read", description: "Read recent thread history (← inbound / → outbound). `to` targets a person/thread without changing the channel's watched/default contact. Signal accepts a name or conversationId; iMessage accepts a configured contact name or raw handle and reads its private local incremental index.", inputSchema: { type: "object", properties: { channel: { type: "string", enum: ["signal", "imessage"], description: "Which channel (default signal)." }, n: { type: "number", description: "How many recent messages (default 15)." }, to: { type: "string", description: "Conversation target. Signal: name/conversationId. iMessage: configured contact name or raw +number/email. Omit for the channel's default contact." }, group: { type: "string", description: "Signal only: alias for `to` — a group/person name substring or conversationId (find ids with dm_groups)." }, machine: { type: "string", description: "Machine (default local)." }, }, }, }, { name: "dm_search", description: "Full-text search the private local iMessage index, optionally scoped to a configured contact name or raw handle. Search is read-only and does not change or acknowledge the watched/default contact.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Words to find (all terms must match)." }, to: { type: "string", description: "Optional configured iMessage contact name or raw +number/email." }, n: { type: "number", description: "Maximum results (default 25, max 200)." }, machine: { type: "string", description: "Machine (default local; Messages DB reads are local-only)." }, }, required: ["query"], }, }, { name: "dm_groups", description: "List recently-active Signal group threads (name + conversationId, newest first). Use this to find the `group` argument for dm_read.", inputSchema: { type: "object", properties: { n: { type: "number", description: "How many groups to list (default 20)." }, machine: { type: "string", description: "Machine (default local; Signal Desktop DB is local-only)." }, }, }, }, { name: "dm_attachments", description: "List recent Signal attachments (indexed: type, size, filename), or decrypt one to a temp path by passing `save: <index>`. Voice memos come back as m4a/aac — the result includes the exact ffmpeg+whisper line to transcribe them.", inputSchema: { type: "object", properties: { n: { type: "number", description: "How many recent attachments to list (default 10)." }, save: { type: "number", description: "Index from the list to decrypt to a temp file." }, machine: { type: "string", description: "Machine (default local; Signal Desktop DB is local-only)." }, }, }, }, { name: "dm_contacts", description: "Find a Signal contact and its ACI (the stable id used to send), filtered by a name substring. Use this to resolve who to send to before dm_send.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Name substring to filter (omit to list all)." }, machine: { type: "string", description: "Machine (default local; routes over ssh for remote)." }, }, }, }, { name: "dm_send", description: "Send a DM. TWO-STEP AND SAFE: the first call PREVIEWS the resolved recipient + message and does NOT send; call again with confirm:true to actually send. `to` is required. Signal accepts an ACI, +E164, or name. iMessage accepts a named contact from imsg.json or a raw handle and shows the resolved display name in the preview.", inputSchema: { type: "object", properties: { channel: { type: "string", enum: ["signal", "imessage"], description: "Which channel." }, to: { type: "string", description: "Required recipient. Signal: ACI / +number / name. iMessage: named imsg.json contact or raw +number/email." }, text: { type: "string", description: "The message body (multi-line ok)." }, confirm: { type: "boolean", description: "Must be true to actually send. Omit/false = preview only." }, machine: { type: "string", description: "Machine (default local; signal-cli sends route over ssh for remote)." }, }, required: ["channel", "text"], }, }, { name: "dm_react", description: "Add a classic iMessage Tapback to a contact's most recent incoming message. TWO-STEP AND SAFE: preview first, then re-call with confirm:true. Requires explicit `to`. Supported reactions: heart, thumbs-up, thumbs-down, haha, emphasis, question.", inputSchema: { type: "object", properties: { to: { type: "string", description: "Required named imsg.json contact or raw +number/email." }, reaction: { type: "string", enum: ["heart", "thumbs-up", "thumbs-down", "haha", "emphasis", "question"], description: "Classic Tapback to add." }, confirm: { type: "boolean", description: "Must be true to actually react. Omit/false = preview only." }, machine: { type: "string", description: "Machine (default local; Messages UI automation is local-only)." }, }, required: ["to", "reaction"], }, },];
async function callTool(name, args) { switch (name) { case "dm_inbox": return toolInbox(args || {}); case "dm_chats": return toolChats(args || {}); case "dm_read": return toolRead(args || {}); case "dm_search": return toolSearch(args || {}); case "dm_groups": return toolGroups(args || {}); case "dm_attachments": return toolAttachments(args || {}); case "dm_contacts": return toolContacts(args || {}); case "dm_send": return toolSend(args || {}); case "dm_react": return toolReact(args || {}); default: throw new Error(`Unknown tool: ${name}`); }}
async function handleMessage(message) { const { id, method, params } = message; try { switch (method) { case "initialize": return { jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "dm-mcp", version: "1.1.0" }, }, }; case "initialized": case "notifications/initialized": return null; case "ping": return { jsonrpc: "2.0", id, result: {} }; case "tools/list": return { jsonrpc: "2.0", id, result: { tools: TOOLS } }; case "tools/call": { const content = await callTool(params?.name, params?.arguments); return { jsonrpc: "2.0", id, result: { content } }; } default: return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } }; } } catch (error) { if (method === "tools/call") { return { jsonrpc: "2.0", id, result: { isError: true, content: text(String(error.message || error)) } }; } return { jsonrpc: "2.0", id, error: { code: -32000, message: String(error.message || error) } }; }}
const port = httpPort(process.argv, 7771);if (port) serveHttp({ handleMessage, port, banner: "✉️ dm-mcp shared daemon" });else serveStdio({ handleMessage, banner: "✉️ dm-mcp server started (dm_inbox, dm_chats, dm_read, dm_search, dm_groups, dm_attachments, dm_contacts, dm_send, dm_react)" });