diff --git a/external_plugins/imessage/README.md b/external_plugins/imessage/README.md index 4c246ec..e69a3e2 100644 --- a/external_plugins/imessage/README.md +++ b/external_plugins/imessage/README.md @@ -76,7 +76,7 @@ Quick reference: IDs are **handle addresses** (`+15551234567` or `someone@icloud | Tool | Purpose | | --- | --- | | `reply` | Send to a chat. `chat_id` + `text`, optional `files` (absolute paths). Auto-chunks text; files send as separate messages. | -| `chat_messages` | Fetch recent history from a chat (oldest-first). Reads `chat.db` directly — full native history. Scoped to allowlisted chats. | +| `chat_messages` | Fetch recent history as conversation threads. Each thread is labelled **DM** or **Group** with its participant list, then timestamped messages (oldest-first). Omit `chat_guid` to see every allowlisted chat at once, or pass one to drill in. Default 100 messages per chat. Reads `chat.db` directly — full native history. | ## What you don't get diff --git a/external_plugins/imessage/server.ts b/external_plugins/imessage/server.ts index d63ad71..45e33b1 100644 --- a/external_plugins/imessage/server.ts +++ b/external_plugins/imessage/server.ts @@ -141,6 +141,21 @@ const qChatsForHandle = db.query<{ guid: string }, [string]>(` WHERE c.style = 45 AND LOWER(h.id) = ? `) +// Participants of a chat (other than yourself). For DMs this is one handle; +// for groups it's everyone in chat_handle_join. +const qChatParticipants = db.query<{ id: string }, [string]>(` + SELECT DISTINCT h.id FROM handle h + JOIN chat_handle_join chj ON chj.handle_id = h.ROWID + JOIN chat c ON c.ROWID = chj.chat_id + WHERE c.guid = ? +`) + +// Group-chat display name and style. display_name is NULL for DMs and +// unnamed groups; populated when the user has named the group in Messages. +const qChatInfo = db.query<{ display_name: string | null; style: number }, [string]>(` + SELECT display_name, style FROM chat WHERE guid = ? +`) + type AttRow = { filename: string | null; mime_type: string | null; transfer_name: string | null } const qAttachments = db.query(` SELECT a.filename, a.mime_type, a.transfer_name @@ -476,15 +491,43 @@ function messageText(r: Row): string { return r.text ?? parseAttributedBody(r.attributedBody) ?? '' } -function renderMsg(r: Row): string { - const who = r.is_from_me ? 'me' : (r.handle_id ?? 'unknown') - const ts = appleDate(r.date).toISOString() - const atts = r.cache_has_attachments ? ' +att' : '' - // Tool results are newline-joined; a multi-line message would forge - // adjacent rows. chat_messages is allowlist-scoped, but a configured group - // can still have untrusted members. - const text = messageText(r).replace(/[\r\n]+/g, ' ⏎ ') - return `[${ts}] ${who}: ${text} (id: ${r.guid}${atts})` +// Build a human-readable header for one conversation. Labels DM vs group and +// lists participants so the assistant can tell threads apart at a glance. +function conversationHeader(guid: string): string { + const info = qChatInfo.get(guid) + const participants = qChatParticipants.all(guid).map(p => p.id) + const who = participants.length > 0 ? participants.join(', ') : guid + if (info?.style === 43) { + const name = info.display_name ? `"${info.display_name}" ` : '' + return `=== Group ${name}(${who}) ===` + } + return `=== DM with ${who} ===` +} + +// Render one chat's messages as a conversation block: header, then one line +// per message with a local-time stamp. A date line is inserted whenever the +// calendar day rolls over so long histories stay readable without repeating +// the full date on every row. +function renderConversation(guid: string, rows: Row[]): string { + const lines: string[] = [conversationHeader(guid)] + let lastDay = '' + for (const r of rows) { + const d = appleDate(r.date) + const day = d.toDateString() + if (day !== lastDay) { + lines.push(`-- ${day} --`) + lastDay = day + } + const hhmm = d.toTimeString().slice(0, 5) + const who = r.is_from_me ? 'me' : (r.handle_id ?? 'unknown') + const atts = r.cache_has_attachments ? ' [attachment]' : '' + // Tool results are newline-joined; a multi-line message would forge + // adjacent rows. chat_messages is allowlist-scoped, but a configured group + // can still have untrusted members. + const text = messageText(r).replace(/[\r\n]+/g, ' ⏎ ') + lines.push(`[${hhmm}] ${who}: ${text}${atts}`) + } + return lines.join('\n') } // --- mcp --------------------------------------------------------------------- @@ -584,14 +627,19 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ { name: 'chat_messages', description: - 'Fetch recent messages from an iMessage chat. Reads chat.db directly — full native history. Scoped to allowlisted chats only.', + 'Fetch recent iMessage history as readable conversation threads. Each thread is labelled DM or Group with its participant list, followed by timestamped messages. Omit chat_guid to see all allowlisted chats at once; pass a specific chat_guid to drill into one thread. Reads chat.db directly — full native history, scoped to allowlisted chats only.', inputSchema: { type: 'object', properties: { - chat_guid: { type: 'string', description: 'The chat_id from the inbound message.' }, - limit: { type: 'number', description: 'Max messages (default 20).' }, + chat_guid: { + type: 'string', + description: 'A specific chat_id to read. Omit to read from every allowlisted chat.', + }, + limit: { + type: 'number', + description: 'Max messages per chat (default 100, max 500).', + }, }, - required: ['chat_guid'], }, }, ], @@ -639,13 +687,25 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { return { content: [{ type: 'text', text: sent === 1 ? 'sent' : `sent ${sent} parts` }] } } case 'chat_messages': { - const guid = args.chat_guid as string - const limit = (args.limit as number) ?? 20 - if (!allowedChatGuids().has(guid)) { + const guid = args.chat_guid as string | undefined + const limit = Math.min((args.limit as number) ?? 100, 500) + const allowed = allowedChatGuids() + const targets = guid == null ? [...allowed] : [guid] + if (guid != null && !allowed.has(guid)) { throw new Error(`chat ${guid} is not allowlisted — add via /imessage:access`) } - const rows = qHistory.all(guid, limit).reverse() - const out = rows.length === 0 ? '(no messages)' : rows.map(renderMsg).join('\n') + if (targets.length === 0) { + return { content: [{ type: 'text', text: '(no allowlisted chats — configure via /imessage:access)' }] } + } + const blocks: string[] = [] + for (const g of targets) { + const rows = qHistory.all(g, limit).reverse() + if (rows.length === 0 && guid == null) continue + blocks.push(rows.length === 0 + ? `${conversationHeader(g)}\n(no messages)` + : renderConversation(g, rows)) + } + const out = blocks.length === 0 ? '(no messages)' : blocks.join('\n\n') return { content: [{ type: 'text', text: out }] } } default: