Compare commits

..

3 Commits

Author SHA1 Message Date
Russell Coleman
a1ffbcc771 feat(imessage): add IMESSAGE_DB_PATH env var for testing
Mirrors the existing IMESSAGE_STATE_DIR override. Lets a mock sqlite
chat.db stand in for ~/Library/Messages/chat.db so chat_messages can be
tested without macOS + Full Disk Access + real iMessage history.
2026-03-27 13:45:56 -07:00
Russell Coleman
28f7434384 feat(imessage): conversational format for chat_messages
Reformat chat_messages output from flat per-message lines to grouped
conversation threads. Each thread gets a header labelling it DM or Group
with its participant list, date-separator lines when the calendar day
rolls over, and [HH:MM] local-time stamps instead of full ISO.

chat_guid is now optional — omit to dump every allowlisted chat at once
for a quick multi-thread overview. Default limit raised 20→100 per chat,
capped at 500.

New queries: qChatParticipants (handle list per chat) and qChatInfo
(display_name + style to distinguish DM/group). renderMsg replaced by
conversationHeader + renderConversation.
2026-03-25 16:50:00 -07:00
Noah Zweben
b10b583de2 Add flint plugin to marketplace (#974) 2026-03-24 23:43:42 +00:00
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 |
| --- | --- |
| `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

View File

@@ -33,7 +33,8 @@ import { join, basename, sep } from 'path'
const STATIC = process.env.IMESSAGE_ACCESS_MODE === 'static'
const APPEND_SIGNATURE = process.env.IMESSAGE_APPEND_SIGNATURE !== 'false'
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 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) = ?
`)
// 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<AttRow, [number]>(`
SELECT a.filename, a.mime_type, a.transfer_name
@@ -476,15 +492,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 +628,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 +688,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: