From 5bb2f725dac136cb44c029b2b96ad56ac1740ae8 Mon Sep 17 00:00:00 2001 From: patp Date: Tue, 1 Sep 2026 14:00:21 -0400 Subject: [PATCH] messages_fetch: report the conversation each message belongs to Messages the user sends from another device are synced to chat.db without a sender handle, so until now most of the user's own messages came back with sender "me" and nothing to tie them to a conversation; a client could not thread them or tell who they were sent to. Each message now carries an "isPartOf" Conversation with the chat identifier, its display name (group chats) and its participants. Chats are looked up once per call and shared by their messages. Relies on Message.chatID and ChatPredicate.id from Madrid 0.5.0 (mattt/Madrid#18), which the project already depends on. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WYn7auXKTFrqS1PSR6rQKn --- App/Services/Messages.swift | 38 ++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/App/Services/Messages.swift b/App/Services/Messages.swift index d53bdf52..e1376055 100644 --- a/App/Services/Messages.swift +++ b/App/Services/Messages.swift @@ -51,7 +51,8 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { var tools: [Tool] { Tool( name: "messages_fetch", - description: "Fetch messages from the Messages app", + description: + "Fetch messages from the Messages app. Each message names the conversation it belongs to (isPartOf), with its participants.", inputSchema: .object( properties: [ "participants": .array( @@ -145,6 +146,10 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { predicate: .and(predicates), limit: max(limit ?? defaultLimit, 1024) ) + + // Each chat is looked up once and shared by all of its messages. + var conversations: [Chat.ID: [String: Value]] = [:] + for message in try db.fetch(request) { guard messages.count < (limit ?? defaultLimit) else { break } guard !message.text.isEmpty else { continue } @@ -185,6 +190,13 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { object["dateRead"] = .string(readAt.formatted(.iso8601)) } + if let chatID = message.chatID { + if conversations[chatID] == nil { + conversations[chatID] = try self.conversation(for: chatID, in: db) + } + object["isPartOf"] = conversations[chatID].map { .object($0) } + } + messages.append(object) } @@ -197,6 +209,30 @@ final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { } } + /// Describes the chat a message belongs to: + /// its identifier, its display name (group chats) and its participants. + private func conversation( + for chatID: Chat.ID, + in db: iMessage.Database + ) throws -> [String: Value] { + var conversation: [String: Value] = [ + "@type": "Conversation", + "@id": .string(chatID.rawValue), + ] + + let request = FetchRequest(predicate: .id(chatID), limit: 1) + if let chat = try db.fetch(request).first { + if let name = chat.displayName, !name.isEmpty { + conversation["name"] = .string(name) + } + conversation["participant"] = .array( + chat.participants.map { .object(["@id": .string($0.rawValue)]) } + ) + } + + return conversation + } + private var canAccessDatabaseAtDefaultPath: Bool { return FileManager.default.isReadableFile(atPath: messagesDatabasePath) }