48 lines
12 KiB
JSON
48 lines
12 KiB
JSON
[
|
|
{
|
|
"path": "imessage/send-message.ts",
|
|
"composio": true,
|
|
"contents": "import { experimental_createTool } from \"@composio/core\";\nimport { z } from \"zod/v3\";\nimport { runAppleScript } from \"./applescript\";\n\nconst SEND_SCRIPT = `on run {targetBuddy, targetText}\n\ttell application \"Messages\"\n\t\tset targetService to 1st service whose service type = iMessage\n\t\tsend targetText to buddy targetBuddy of targetService\n\tend tell\nend run`;\n\n// \"Think\" time + time to type the message, with jitter, capped so messages\n// don't fire instantly.\nfunction humanDelayMs(text: string): number {\n const think = 800 + Math.random() * 1700; // 0.8\u20132.5s\n const typing = text.length * (40 + Math.random() * 40); // ~40\u201380ms/char\n return Math.min(think + typing, 12_000);\n}\n\nexport const sendMessage = experimental_createTool(\"SEND\", {\n name: \"Send iMessage\",\n description:\n \"Send an iMessage from the user's Mac to a phone number or iMessage email handle.\",\n preload: true,\n inputParams: z.object({\n to: z\n .string()\n .describe(\"Recipient handle: phone number (e.g. +15551234567) or iMessage email.\"),\n text: z.string().describe(\"Message body to send.\"),\n name: z.string().optional().describe(\"Recipient's contact name, for display.\"),\n }),\n execute: async ({ to, text }) => {\n await new Promise((r) => setTimeout(r, humanDelayMs(text)));\n await runAppleScript(SEND_SCRIPT, [to, text]);\n return { sent: true, to };\n },\n});"
|
|
},
|
|
{
|
|
"path": "imessage/find-contact.ts",
|
|
"composio": true,
|
|
"contents": "import { experimental_createTool } from \"@composio/core\";\nimport Fuse from \"fuse.js\";\nimport { z } from \"zod/v3\";\nimport { normalizeHandle } from \"../imessage/handles\";\nimport { runAppleScript } from \"./applescript\";\n\ntype Contact = {\n name: string;\n nickname: string;\n initials: string;\n numbers: { label: string; value: string }[];\n};\n\n// Emits one line per person: name|nickname|label=value;label=value\n//\n// Bulk-fetches each property for ALL people in one Apple Event apiece (a few\n// IPC round-trips total), then assembles locally. Reading properties per-person\n// inside a loop is thousands of round-trips and is what made this slow.\nconst CONTACTS_SCRIPT = `tell application \"Contacts\"\n\tset theNames to name of every person\n\tset theNicks to nickname of every person\n\tset theVals to value of phones of every person\n\tset theLbls to label of phones of every person\nend tell\nset out to {}\nrepeat with i from 1 to count of theNames\n\tset nm to item i of theNames\n\tif nm is missing value then set nm to \"\"\n\tset nk to item i of theNicks\n\tif nk is missing value then set nk to \"\"\n\tset vals to item i of theVals\n\tset lbls to item i of theLbls\n\tset phParts to {}\n\trepeat with j from 1 to count of vals\n\t\tset lb to item j of lbls\n\t\tif lb is missing value then set lb to \"other\"\n\t\tset end of phParts to (lb & \"=\" & item j of vals)\n\tend repeat\n\tset AppleScript's text item delimiters to \";\"\n\tset phStr to phParts as text\n\tset AppleScript's text item delimiters to \"\"\n\tset end of out to (nm & \"|\" & nk & \"|\" & phStr)\nend repeat\nset AppleScript's text item delimiters to (ASCII character 10)\nreturn out as text`;\n\nfunction cleanLabel(raw: string): string {\n return raw.replace(/^_\\$!<(.+)>!\\$_$/, \"$1\") || \"other\";\n}\n\nfunction initialsOf(name: string): string {\n return name\n .split(/\\s+/)\n .filter(Boolean)\n .map((w) => w[0])\n .join(\"\")\n .toLowerCase();\n}\n\nfunction parseContact(line: string): Contact | null {\n const [name = \"\", nickname = \"\", phonesPart = \"\"] = line.split(\"|\");\n const numbers = phonesPart\n .split(\";\")\n .filter(Boolean)\n .map((entry) => {\n const idx = entry.indexOf(\"=\");\n return { label: cleanLabel(entry.slice(0, idx)), value: entry.slice(idx + 1).trim() };\n })\n .filter((n) => n.value);\n if (!name || numbers.length === 0) return null;\n return { name, nickname, initials: initialsOf(name), numbers };\n}\n\nlet contactsCache: Contact[] | null = null;\n\nasync function loadContacts(): Promise<Contact[]> {\n if (contactsCache) return contactsCache;\n const stdout = await runAppleScript(CONTACTS_SCRIPT);\n contactsCache = stdout\n .split(\"\\n\")\n .map((l) => parseContact(l.trim()))\n .filter((c): c is Contact => c !== null);\n return contactsCache;\n}\n\nexport async function searchContacts(\n query: string,\n limit = 5,\n): Promise<{ name: string; numbers: { label: string; value: string }[] }[]> {\n const contacts = await loadContacts();\n const fuse = new Fuse(contacts, {\n keys: [\"name\", \"nickname\", \"initials\"],\n threshold: 0.4,\n ignoreLocation: true,\n });\n return fuse\n .search(query)\n .slice(0, limit)\n .map((r) => ({ name: r.item.name, numbers: r.item.numbers }));\n}\n\n// Reverse lookup: contact name for an incoming handle (for nicer prompts).\nexport async function nameForHandle(handle: string): Promise<string | null> {\n const target = normalizeHandle(handle);\n for (const c of await loadContacts()) {\n for (const n of c.numbers) {\n if (normalizeHandle(n.value) === target) return c.name;\n }\n }\n return null;\n}\n\nexport const findContact = experimental_createTool(\"FIND_CONTACT\", {\n name: \"Find Contact\",\n description:\n \"Fuzzy-search the user's Mac contacts by name, nickname, or initials. Returns matching contacts with their phone numbers (labeled). Use this to resolve who a message is for before sending.\",\n preload: true,\n inputParams: z.object({\n query: z.string().describe(\"Name, nickname, or initials to search for (e.g. 'sh').\"),\n }),\n execute: async ({ query }) => ({ candidates: await searchContacts(query) }),\n});\n"
|
|
},
|
|
{
|
|
"path": "imessage/read-messages.ts",
|
|
"composio": true,
|
|
"contents": "import { experimental_createTool } from \"@composio/core\";\nimport { z } from \"zod/v3\";\nimport { decodeAttributedBody, runSqlite } from \"../imessage/chat-db\";\n\n// Apple stores message dates as ns since 2001-01-01; 978307200 = that in unix epoch.\nfunction buildQuery(limit: number, handle?: string): string {\n const select = `SELECT\n CASE WHEN m.is_from_me = 1 THEN 'me' ELSE h.id END AS sender,\n datetime(m.date / 1000000000 + 978307200, 'unixepoch', 'localtime') AS time,\n m.text AS text,\n CASE WHEN m.text IS NULL THEN hex(m.attributedBody) ELSE NULL END AS bodyHex\nFROM message m\nLEFT JOIN handle h ON m.handle_id = h.ROWID`;\n\n if (handle) {\n const safe = handle.replace(/'/g, \"''\");\n return `${select}\nJOIN chat_message_join cmj ON cmj.message_id = m.ROWID\nWHERE cmj.chat_id IN (\n SELECT chj.chat_id FROM chat_handle_join chj\n JOIN handle hh ON hh.ROWID = chj.handle_id\n WHERE hh.id = '${safe}'\n)\nORDER BY m.date DESC LIMIT ${limit};`;\n }\n return `${select}\nORDER BY m.date DESC LIMIT ${limit};`;\n}\n\ntype Row = { sender: string | null; time: string; text: string | null; bodyHex: string | null };\n\nexport const readMessages = experimental_createTool(\"READ_MESSAGES\", {\n name: \"Read iMessages\",\n description:\n \"Read recent iMessages from the user's Mac. Defaults to the last 5. Pass a larger limit to see more, or a handle (a phone/email, e.g. from find_contact) to read one conversation.\",\n preload: true,\n inputParams: z.object({\n handle: z\n .string()\n .optional()\n .describe(\"Phone number or email to focus on a single conversation.\"),\n limit: z\n .number()\n .int()\n .optional()\n .describe(\"How many recent messages to read (default 5, max 50).\"),\n }),\n execute: async ({ handle, limit }) => {\n const n = Math.min(Math.max(limit ?? 5, 1), 50);\n const rows = await runSqlite<Row>(buildQuery(n, handle));\n const messages = rows\n .reverse() // chronological: oldest first\n .map((r) => ({\n sender: r.sender ?? \"unknown\",\n time: r.time,\n text: r.text ?? decodeAttributedBody(r.bodyHex) ?? \"[unsupported message]\",\n }));\n return { messages };\n },\n});\n"
|
|
},
|
|
{
|
|
"path": "imessage/memory-tools.ts",
|
|
"composio": true,
|
|
"contents": "import { experimental_createTool } from \"@composio/core\";\nimport { z } from \"zod/v3\";\nimport { readMemory, writeMemory } from \"../imessage/memory\";\n\nexport const recallContact = experimental_createTool(\"RECALL\", {\n name: \"Recall Contact\",\n description:\n \"Recall what you remember about a contact (notes from past conversations) by their phone/email handle. Call this before replying to or discussing someone.\",\n preload: true,\n inputParams: z.object({\n handle: z.string().describe(\"Phone number or email of the contact.\"),\n }),\n execute: async ({ handle }) => ({ notes: await readMemory(handle) }),\n});\n\nexport const rememberContact = experimental_createTool(\"REMEMBER\", {\n name: \"Remember Contact\",\n description:\n \"Save concise notes about a contact (facts, ongoing topics, plans) for future conversations. Replaces the existing notes, so pass the full updated notes. Keep them short, a few lines.\",\n preload: true,\n inputParams: z.object({\n handle: z.string().describe(\"Phone number or email of the contact.\"),\n notes: z.string().describe(\"The full updated notes to store (concise).\"),\n }),\n execute: async ({ handle, notes }) => {\n await writeMemory(handle, notes);\n return { saved: true };\n },\n});\n"
|
|
},
|
|
{
|
|
"path": "imessage/applescript.ts",
|
|
"composio": false,
|
|
"contents": "import { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\n\nconst run = promisify(execFile);\n\n// Runs an AppleScript via osascript. `--` ends osascript option parsing; extra\n// values then become `on run {\u2026}` parameters and are never parsed as source.\nexport async function runAppleScript(script: string, args: string[] = []): Promise<string> {\n const { stdout } = await run(\"osascript\", [\"-e\", script, \"--\", ...args], {\n maxBuffer: 1024 * 1024 * 16,\n timeout: 60_000,\n });\n return stdout;\n}\n"
|
|
},
|
|
{
|
|
"path": "imessage/index.ts",
|
|
"composio": true,
|
|
"contents": "import { experimental_createToolkit } from \"@composio/core\";\nimport { findContact } from \"./find-contact\";\nimport { recallContact, rememberContact } from \"./memory-tools\";\nimport { readMessages } from \"./read-messages\";\nimport { sendMessage } from \"./send-message\";\n\n// A self-contained Composio custom toolkit for local iMessage on macOS:\n// send, find contacts, read messages, and per-contact memory. Framework-agnostic\nexport function createImessageToolkit() {\n return experimental_createToolkit(\"IMESSAGE\", {\n name: \"iMessage\",\n description:\n \"Send and read iMessages, look up contacts, and remember things about them, locally on the user's Mac.\",\n tools: [sendMessage, findContact, readMessages, recallContact, rememberContact],\n });\n}\n"
|
|
},
|
|
{
|
|
"path": "composio.ts",
|
|
"composio": true,
|
|
"contents": "import { Composio } from '@composio/core';\nimport { EveProvider, requireApprovalForTools } from '@composio/experimental/eve';\nimport { createImessageToolkit } from './imessage';\n\nexport const composio = new Composio({\n provider: new EveProvider({\n needsApproval: requireApprovalForTools('LOCAL_IMESSAGE_SEND'),\n }),\n});\n\nexport const session = composio.sessions.create('user_123', {\n experimental: { customToolkits: [createImessageToolkit()] },\n});\n"
|
|
},
|
|
{
|
|
"path": "agent/tools/composio.ts",
|
|
"composio": true,
|
|
"contents": "import { defineComposioTools } from '@composio/experimental/eve';\nimport { session } from '../../composio';\n\nexport default defineComposioTools(session);\n"
|
|
},
|
|
{
|
|
"path": "agent/agent.ts",
|
|
"composio": false,
|
|
"contents": "import { defineAgent } from \"eve\";\n\nexport default defineAgent({\n model: \"google/gemini-2.5-flash\",\n});\n"
|
|
}
|
|
]
|