Merge pull request #1008 from anthropics/russell/imessage-conversational-format

feat(imessage): conversational format for chat_messages
This commit is contained in:
russell-coleman 2026-03-27 14:24:14 -07:00 committed by GitHub
commit 548bfa8375
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 81 additions and 20 deletions

View File

@ -76,7 +76,7 @@ Quick reference: IDs are **handle addresses** (`+15551234567` or `someone@icloud
| Tool | Purpose | | Tool | Purpose |
| --- | --- | | --- | --- |
| `reply` | Send to a chat. `chat_id` + `text`, optional `files` (absolute paths). Auto-chunks text; files send as separate messages. | | `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 ## What you don't get

View File

@ -33,7 +33,8 @@ import { join, basename, sep } from 'path'
const STATIC = process.env.IMESSAGE_ACCESS_MODE === 'static' const STATIC = process.env.IMESSAGE_ACCESS_MODE === 'static'
const APPEND_SIGNATURE = process.env.IMESSAGE_APPEND_SIGNATURE !== 'false' const APPEND_SIGNATURE = process.env.IMESSAGE_APPEND_SIGNATURE !== 'false'
const SIGNATURE = '\nSent by Claude' const SIGNATURE = '\nSent by Claude'
const CHAT_DB = join(homedir(), 'Library', 'Messages', 'chat.db') const CHAT_DB =
process.env.IMESSAGE_DB_PATH ?? join(homedir(), 'Library', 'Messages', 'chat.db')
const STATE_DIR = process.env.IMESSAGE_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'imessage') const STATE_DIR = process.env.IMESSAGE_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'imessage')
const ACCESS_FILE = join(STATE_DIR, 'access.json') const ACCESS_FILE = join(STATE_DIR, 'access.json')
@ -141,6 +142,21 @@ const qChatsForHandle = db.query<{ guid: string }, [string]>(`
WHERE c.style = 45 AND LOWER(h.id) = ? 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 } type AttRow = { filename: string | null; mime_type: string | null; transfer_name: string | null }
const qAttachments = db.query<AttRow, [number]>(` const qAttachments = db.query<AttRow, [number]>(`
SELECT a.filename, a.mime_type, a.transfer_name SELECT a.filename, a.mime_type, a.transfer_name
@ -476,15 +492,43 @@ function messageText(r: Row): string {
return r.text ?? parseAttributedBody(r.attributedBody) ?? '' return r.text ?? parseAttributedBody(r.attributedBody) ?? ''
} }
function renderMsg(r: Row): string { // 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 who = r.is_from_me ? 'me' : (r.handle_id ?? 'unknown')
const ts = appleDate(r.date).toISOString() const atts = r.cache_has_attachments ? ' [attachment]' : ''
const atts = r.cache_has_attachments ? ' +att' : ''
// Tool results are newline-joined; a multi-line message would forge // Tool results are newline-joined; a multi-line message would forge
// adjacent rows. chat_messages is allowlist-scoped, but a configured group // adjacent rows. chat_messages is allowlist-scoped, but a configured group
// can still have untrusted members. // can still have untrusted members.
const text = messageText(r).replace(/[\r\n]+/g, ' ⏎ ') const text = messageText(r).replace(/[\r\n]+/g, ' ⏎ ')
return `[${ts}] ${who}: ${text} (id: ${r.guid}${atts})` lines.push(`[${hhmm}] ${who}: ${text}${atts}`)
}
return lines.join('\n')
} }
// --- mcp --------------------------------------------------------------------- // --- mcp ---------------------------------------------------------------------
@ -584,14 +628,19 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
{ {
name: 'chat_messages', name: 'chat_messages',
description: 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: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
chat_guid: { type: 'string', description: 'The chat_id from the inbound message.' }, chat_guid: {
limit: { type: 'number', description: 'Max messages (default 20).' }, 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 +688,25 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
return { content: [{ type: 'text', text: sent === 1 ? 'sent' : `sent ${sent} parts` }] } return { content: [{ type: 'text', text: sent === 1 ? 'sent' : `sent ${sent} parts` }] }
} }
case 'chat_messages': { case 'chat_messages': {
const guid = args.chat_guid as string const guid = args.chat_guid as string | undefined
const limit = (args.limit as number) ?? 20 const limit = Math.min((args.limit as number) ?? 100, 500)
if (!allowedChatGuids().has(guid)) { 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`) throw new Error(`chat ${guid} is not allowlisted — add via /imessage:access`)
} }
const rows = qHistory.all(guid, limit).reverse() if (targets.length === 0) {
const out = rows.length === 0 ? '(no messages)' : rows.map(renderMsg).join('\n') 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 }] } return { content: [{ type: 'text', text: out }] }
} }
default: default: