Explorar el Código

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

feat(imessage): conversational format for chat_messages
russell-coleman hace 5 meses
padre
commit
548bfa8375
Se han modificado 2 ficheros con 81 adiciones y 20 borrados
  1. 1 1
      external_plugins/imessage/README.md
  2. 80 19
      external_plugins/imessage/server.ts

+ 1 - 1
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
 

+ 80 - 19
external_plugins/imessage/server.ts

@@ -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: