From 144a3ea66ef751dbe6fedc3710c3fbcfaa39e28f Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 21 May 2026 21:25:25 -0500 Subject: [PATCH] feat(ai): Add getRecentMessages and getUnreadSummary App Functions Phase 3 of App Functions integration: read-only message history functions that enable 'catch me up' voice queries via system AI. New functions: - getRecentMessages: Retrieve recent messages with optional contact filter and configurable limit (1-50, default 20) - getUnreadSummary: Per-contact unread breakdown excluding muted contacts, sorted by most recent Implementation details: - KMP interface + sealed result types in core:data - Android @AppFunction declarations with @AppFunctionSerializable models - Fuzzy name resolution for contact filtering - Channel name resolution for broadcast contacts - Tests for contact-not-found and empty unread scenarios Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../app/ai/appfunctions/AppFunctionModels.kt | 53 ++++++ .../ai/appfunctions/MeshtasticAppFunctions.kt | 85 ++++++++++ .../core/data/ai/AiFunctionProvider.kt | 29 ++++ .../core/data/ai/AiFunctionProviderImpl.kt | 158 ++++++++++++++++++ .../core/data/ai/AiFunctionResult.kt | 57 +++++++ .../data/ai/AiFunctionProviderImplTest.kt | 32 ++++ specs/20260521-091500-app-functions/spec.md | 81 ++++++++- 7 files changed, 490 insertions(+), 5 deletions(-) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt index f3f2f5e90..fc479dcf6 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt @@ -180,3 +180,56 @@ data class GetMeshMetricsResponse( val meshUptimeSeconds: Long, val channelUtilizationPercent: Int?, ) + +/** + * Response containing recent messages from the mesh network. + * + * @property messages List of recent messages ordered by most recent first. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class GetRecentMessagesResponse(val messages: List) + +/** + * Information about a single mesh message. + * + * @property senderName Display name of the message sender. + * @property text The message text content. + * @property contactName Name of the channel or contact the message belongs to. + * @property receivedTime Timestamp when the message was received (ms since epoch). + * @property fromLocal True if this message was sent by the local user. + * @property read True if this message has been read by the user. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class MessageInfo( + val senderName: String, + val text: String, + val contactName: String, + val receivedTime: Long, + val fromLocal: Boolean, + val read: Boolean, +) + +/** + * Response containing a summary of unread messages across all contacts. + * + * @property totalUnreadCount Total number of unread messages across all non-muted contacts. + * @property contacts Per-contact breakdown of unread messages, sorted by most recent. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class GetUnreadSummaryResponse(val totalUnreadCount: Int, val contacts: List) + +/** + * Unread message details for a single contact or channel. + * + * @property name Display name of the contact or channel. + * @property unreadCount Number of unread messages from this contact. + * @property lastMessagePreview Preview text of the most recent message (up to 100 chars), or null if unavailable. + * @property lastMessageTime Timestamp of the most recent message (ms since epoch), or null if unavailable. + */ +@AppFunctionSerializable(isDescribedByKDoc = true) +data class ContactUnreadInfo( + val name: String, + val unreadCount: Int, + val lastMessagePreview: String?, + val lastMessageTime: Long?, +) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt index 3a90f810f..ccabddc06 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/MeshtasticAppFunctions.kt @@ -325,4 +325,89 @@ class MeshtasticAppFunctions(private val provider: AiFunctionProvider) { throw AppFunctionInvalidArgumentException(result.reason) } } + + /** + * Retrieve recent messages received over the Meshtastic mesh radio network. + * + * Returns a list of recent messages from the local message history. Messages are stored locally and do not require + * an active mesh connection. Useful for catching up on conversations or reviewing recent communications. + * + * @param context The app function invocation context provided by the system. + * @param contactName Optional name of a node or channel to filter messages from. If omitted, returns messages from + * all contacts sorted by most recent. + * @param limit Maximum number of messages to return (1–50). Defaults to 20. + * @return A [GetRecentMessagesResponse] containing the list of recent messages. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getRecentMessages( + context: AppFunctionContext, + contactName: String? = null, + limit: Int = AiFunctionProvider.DEFAULT_MESSAGE_LIMIT, + ): GetRecentMessagesResponse { + val result = + try { + provider.getRecentMessages(contactName, limit) + } catch (_: TimeoutCancellationException) { + throw AppFunctionInvalidArgumentException("Request timed out. Try again or reduce the message limit.") + } + return when (result) { + is org.meshtastic.core.data.ai.GetRecentMessagesResult.Success -> + GetRecentMessagesResponse( + messages = + result.messages.map { msg -> + MessageInfo( + senderName = msg.senderName, + text = msg.text, + contactName = msg.contactName, + receivedTime = msg.receivedTime, + fromLocal = msg.fromLocal, + read = msg.read, + ) + }, + ) + + is org.meshtastic.core.data.ai.GetRecentMessagesResult.ContactNotFound -> + throw AppFunctionInvalidArgumentException(result.message) + + is org.meshtastic.core.data.ai.GetRecentMessagesResult.Error -> + throw AppFunctionInvalidArgumentException(result.reason) + } + } + + /** + * Get a summary of unread messages across all Meshtastic mesh contacts. + * + * Returns the total unread count and a per-contact breakdown showing who sent unread messages, how many are unread, + * and a preview of the last message. Muted contacts are excluded. Does not require an active mesh connection. + * + * @param context The app function invocation context provided by the system. + * @return A [GetUnreadSummaryResponse] with the total unread count and per-contact details. + */ + @AppFunction(isDescribedByKDoc = true) + suspend fun getUnreadSummary(context: AppFunctionContext): GetUnreadSummaryResponse { + val result = + try { + provider.getUnreadSummary() + } catch (_: TimeoutCancellationException) { + throw AppFunctionInvalidArgumentException("Request timed out. Try again.") + } + return when (result) { + is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Success -> + GetUnreadSummaryResponse( + totalUnreadCount = result.summary.totalUnreadCount, + contacts = + result.summary.contacts.map { contact -> + ContactUnreadInfo( + name = contact.name, + unreadCount = contact.unreadCount, + lastMessagePreview = contact.lastMessagePreview, + lastMessageTime = contact.lastMessageTime, + ) + }, + ) + + is org.meshtastic.core.data.ai.GetUnreadSummaryResult.Error -> + throw AppFunctionInvalidArgumentException(result.reason) + } + } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt index 912bab4bb..4829e53e1 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProvider.kt @@ -80,4 +80,33 @@ interface AiFunctionProvider { * @return Success with mesh metrics, or failure if not connected. */ suspend fun getMeshMetrics(): GetMeshMetricsResult + + /** + * Get recent messages from the mesh network. + * + * Messages are returned from the local cache — an active radio connection is not required. + * + * @param contactName Optional contact/channel name to filter by. Uses fuzzy matching. + * @param limit Maximum number of messages to return (default 20, max 50). + * @return Success with list of messages, or failure if contact not found. + */ + suspend fun getRecentMessages( + contactName: String? = null, + limit: Int = DEFAULT_MESSAGE_LIMIT, + ): GetRecentMessagesResult + + /** + * Get a summary of unread messages grouped by contact. + * + * Returns the total unread count and a per-contact breakdown with the last message preview. Muted contacts are + * excluded. + * + * @return Unread summary with per-contact breakdown. + */ + suspend fun getUnreadSummary(): GetUnreadSummaryResult + + companion object { + const val DEFAULT_MESSAGE_LIMIT = 20 + const val MAX_MESSAGE_LIMIT = 50 + } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt index 3d968eecf..d5ffed7fc 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt @@ -22,6 +22,7 @@ import org.koin.core.annotation.Single import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.usecase.SendMessageUseCase @@ -33,11 +34,13 @@ import kotlin.time.Duration.Companion.seconds * Implementation of [AiFunctionProvider] that bridges AI function invocations to existing Meshtastic repositories and * use cases. */ +@Suppress("TooManyFunctions") @Single(binds = [AiFunctionProvider::class]) class AiFunctionProviderImpl( private val serviceRepository: ServiceRepository, private val nodeRepository: NodeRepository, private val radioConfigRepository: RadioConfigRepository, + private val packetRepository: PacketRepository, private val sendMessageUseCase: SendMessageUseCase, private val fuzzyNameResolver: FuzzyNameResolver, private val rateLimiter: RateLimiter, @@ -293,6 +296,159 @@ class AiFunctionProviderImpl( } } + @Suppress("ReturnCount", "TooGenericExceptionCaught") + override suspend fun getRecentMessages(contactName: String?, limit: Int): GetRecentMessagesResult = + withTimeout(OPERATION_TIMEOUT) { + try { + val effectiveLimit = limit.coerceIn(1, AiFunctionProvider.MAX_MESSAGE_LIMIT) + + // Resolve contact key if a name filter is provided + val contactKey = + if (contactName != null) { + resolveContactKeyForRead(contactName) + ?: return@withTimeout GetRecentMessagesResult.ContactNotFound( + "Contact not found: $contactName", + ) + } else { + null + } + + val messages = + if (contactKey != null) { + // Fetch messages from a specific contact + packetRepository + .getMessagesFrom( + contact = contactKey, + limit = effectiveLimit, + includeFiltered = false, + getNode = { userId -> nodeRepository.getNode(userId ?: "") }, + ) + .first() + } else { + // Fetch recent messages across all contacts + val contacts = packetRepository.getContacts().first() + contacts.keys + .flatMap { key -> + packetRepository + .getMessagesFrom( + contact = key, + limit = MESSAGES_PER_CONTACT, + includeFiltered = false, + getNode = { userId -> nodeRepository.getNode(userId ?: "") }, + ) + .first() + } + .sortedByDescending { it.receivedTime } + .take(effectiveLimit) + } + + val channelSet = radioConfigRepository.channelSetFlow.first() + val summaries = + messages.map { msg -> + MessageSummary( + senderName = msg.node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${msg.node.num}", + text = msg.text, + contactName = resolveContactDisplayName(msg, channelSet), + receivedTime = msg.receivedTime, + fromLocal = msg.fromLocal, + read = msg.read, + ) + } + + GetRecentMessagesResult.Success(summaries) + } catch (ex: Exception) { + if (ex is CancellationException) throw ex + GetRecentMessagesResult.Error("Failed to retrieve messages: ${ex.message}") + } + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun getUnreadSummary(): GetUnreadSummaryResult = withTimeout(OPERATION_TIMEOUT) { + try { + val contacts = packetRepository.getContacts().first() + val settings = packetRepository.getContactSettings().first() + val channelSet = radioConfigRepository.channelSetFlow.first() + val nodeMap = nodeRepository.nodeDBbyNum.first() + + val nonMutedContacts = contacts.filter { (key, _) -> settings[key]?.isMuted != true } + + val contactUnreads = + nonMutedContacts.mapNotNull { (contactKey, lastPacket) -> + val unreadCount = packetRepository.getUnreadCount(contactKey) + if (unreadCount <= 0) return@mapNotNull null + + val isBroadcast = lastPacket.to == DataPacket.ID_BROADCAST + val displayName = + if (isBroadcast) { + val channelIndex = contactKey.firstOrNull()?.digitToIntOrNull() ?: 0 + channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" } + ?: "Channel $channelIndex" + } else { + val userId = lastPacket.from ?: "" + val node = nodeMap.values.find { it.user.id == userId } + node?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Unknown" + } + + ContactUnread( + name = displayName, + unreadCount = unreadCount, + lastMessagePreview = lastPacket.text?.take(MESSAGE_PREVIEW_MAX_LENGTH), + lastMessageTime = lastPacket.time.takeIf { it > 0 }, + ) + } + + val totalUnread = contactUnreads.sumOf { it.unreadCount } + + GetUnreadSummaryResult.Success( + UnreadSummary( + totalUnreadCount = totalUnread, + contacts = contactUnreads.sortedByDescending { it.lastMessageTime }, + ), + ) + } catch (ex: Exception) { + if (ex is CancellationException) throw ex + GetUnreadSummaryResult.Error("Failed to retrieve unread summary: ${ex.message}") + } + } + + /** + * Resolve a contact name (node or channel) to a contact key for reading messages. Returns null if the name cannot + * be resolved. + */ + @Suppress("ReturnCount") + private suspend fun resolveContactKeyForRead(name: String): String? { + // Try node name first + when (val nodeResult = fuzzyNameResolver.resolveNodeName(name)) { + is NodeNameResult.Found -> { + val channelIndex = DataPacket.PKC_CHANNEL_INDEX + return "${channelIndex}${nodeResult.userId}" + } + + is NodeNameResult.Ambiguous -> return null + + is NodeNameResult.NotFound -> { + /* fall through to channel */ + } + } + + // Try channel name + return when (val channelResult = fuzzyNameResolver.resolveChannelName(name)) { + is ChannelNameResult.Found -> "${channelResult.channelIndex}${DataPacket.ID_BROADCAST}" + is ChannelNameResult.Ambiguous -> null + is ChannelNameResult.NotFound -> null + } + } + + private fun resolveContactDisplayName( + msg: org.meshtastic.core.model.Message, + channelSet: org.meshtastic.proto.ChannelSet, + ): String { + // For broadcast messages, use channel name + val channelIndex = msg.node.channel + return channelSet.settings.getOrNull(channelIndex)?.name?.ifBlank { "Channel $channelIndex" } + ?: "Channel $channelIndex" + } + @Suppress("ReturnCount") private suspend fun resolveContactKey(recipientName: String?, channelName: String?): ResolvedContact? { // Direct message to a specific node @@ -352,6 +508,8 @@ class AiFunctionProviderImpl( private const val HEALTH_SCORE_ONLINE_RATIO = 50 private const val HEALTH_SCORE_DEGRADED = 10 private const val HEALTH_SCORE_MAX = 100 + private const val MESSAGES_PER_CONTACT = 5 + private const val MESSAGE_PREVIEW_MAX_LENGTH = 100 /** Standard Meshtastic message payload limit (bytes). */ const val MAX_MESSAGE_LENGTH = 237 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt index c92c50e8b..87619c73e 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt @@ -208,3 +208,60 @@ data class MeshMetrics( /** Estimated channel utilization percentage (0-100), or null if unavailable. */ val channelUtilizationPercent: Int?, ) + +/** Result of a [AiFunctionProvider.getRecentMessages] invocation. */ +sealed class GetRecentMessagesResult { + /** Successfully retrieved recent messages. */ + data class Success(val messages: List) : GetRecentMessagesResult() + + /** The specified contact was not found via fuzzy matching. */ + data class ContactNotFound(val message: String) : GetRecentMessagesResult() + + /** An error occurred retrieving messages. */ + data class Error(val reason: String) : GetRecentMessagesResult() +} + +/** Summary of a single mesh message suitable for AI consumption. */ +data class MessageSummary( + /** Display name of the message sender. */ + val senderName: String, + /** The message text content. */ + val text: String, + /** Channel or contact name this message belongs to. */ + val contactName: String, + /** When the message was received (milliseconds since epoch). */ + val receivedTime: Long, + /** Whether this message was sent by the local user. */ + val fromLocal: Boolean, + /** Whether this message has been read. */ + val read: Boolean, +) + +/** Result of a [AiFunctionProvider.getUnreadSummary] invocation. */ +sealed class GetUnreadSummaryResult { + /** Successfully retrieved unread summary. */ + data class Success(val summary: UnreadSummary) : GetUnreadSummaryResult() + + /** An error occurred retrieving unread summary. */ + data class Error(val reason: String) : GetUnreadSummaryResult() +} + +/** Unread message summary across all contacts. */ +data class UnreadSummary( + /** Total number of unread messages across all contacts. */ + val totalUnreadCount: Int, + /** Per-contact breakdown of unread messages (excludes muted contacts). */ + val contacts: List, +) + +/** Unread info for a single contact or channel. */ +data class ContactUnread( + /** Display name of the contact or channel. */ + val name: String, + /** Number of unread messages from this contact. */ + val unreadCount: Int, + /** Preview of the last message text, or null if none. */ + val lastMessagePreview: String?, + /** Timestamp of the last message (milliseconds since epoch), or null if none. */ + val lastMessageTime: Long?, +) diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt index 11f1933ce..5b87e6513 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.test.runTest import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.Node import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.ServiceRepository import org.meshtastic.core.repository.usecase.SendMessageUseCase @@ -46,6 +47,7 @@ class AiFunctionProviderImplTest { private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill) private val sendMessageUseCase: SendMessageUseCase = mock(MockMode.autofill) private val fuzzyNameResolver = FuzzyNameResolver(nodeRepository, radioConfigRepository) + private val packetRepository: PacketRepository = mock(MockMode.autofill) private val clock = TestClock(Instant.fromEpochSeconds(1_700_000_000)) private val rateLimiter = RateLimiter(clock) @@ -55,6 +57,7 @@ class AiFunctionProviderImplTest { radioConfigRepository = radioConfigRepository, sendMessageUseCase = sendMessageUseCase, fuzzyNameResolver = fuzzyNameResolver, + packetRepository = packetRepository, rateLimiter = rateLimiter, clock = clock, ) @@ -233,6 +236,35 @@ class AiFunctionProviderImplTest { val result = provider.sendMessage("hello", null, null) assertIs(result) } + + // --- getRecentMessages tests --- + + @Test + fun getRecentMessages_contact_not_found() = runTest { + val nodeMap = MutableStateFlow(emptyMap()) + every { nodeRepository.nodeDBbyNum } returns nodeMap + every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet()) + + val provider = createProvider() + val result = provider.getRecentMessages("NonExistent", 10) + assertIs(result) + } + + // --- getUnreadSummary tests --- + + @Test + fun getUnreadSummary_returns_empty_when_no_unread() = runTest { + every { packetRepository.getContacts() } returns flowOf(emptyMap()) + every { packetRepository.getContactSettings() } returns flowOf(emptyMap()) + every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet()) + every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap()) + + val provider = createProvider() + val result = provider.getUnreadSummary() + assertIs(result) + assertEquals(0, result.summary.totalUnreadCount) + assertEquals(0, result.summary.contacts.size) + } } private class TestClock(var currentTime: Instant) : Clock { diff --git a/specs/20260521-091500-app-functions/spec.md b/specs/20260521-091500-app-functions/spec.md index 467e858ce..13f17a2b9 100644 --- a/specs/20260521-091500-app-functions/spec.md +++ b/specs/20260521-091500-app-functions/spec.md @@ -229,14 +229,85 @@ androidApp/ (Google flavor) 1. **Exact rate limit values**: Is 5 messages/60 seconds the right threshold, or should it align with a specific radio duty-cycle calculation? 2. **Background invocation**: Can App Functions be invoked when the app is in the background but the service is running? (Likely yes, since `AppFunctionService` runs in the app process) -## Future Considerations (Phase 2+) +## Future Considerations (Phase 3+) -- **listNodes**: "Who's on the mesh?" → return online nodes with names and last-heard -- **getRecentMessages**: "Any new messages?" → return unread messages with sender/text/time - **getNodePosition**: "Where is Jake?" → return GPS coordinates (gated by privacy settings) - **Waypoint management**: Create/delete waypoints via AI - **Traceroute**: "Can I reach node X?" → invoke traceroute and return hop count -- **Channel info**: "What channels am I on?" → list configured channels -- **Device telemetry**: "How's my radio's battery?" → return device metrics - **Location sharing**: "Share my location on the mesh" → trigger position broadcast +- **requestTraceroute**: Non-destructive route diagnostic via fuzzy node name +- **sendQuickChat**: Voice-triggered pre-configured message shortcuts +- **findNearbyNodes**: Location-aware proximity query sorted by distance +- **requestNodePosition**: Ask a specific node to share its GPS coordinates - **Desktop/iOS parity**: Implement `AiFunctionProvider` via local MCP server (Desktop) or App Intents (iOS) + +--- + +## Phase 3: Message History Functions + +### User Story 6 - Read Recent Messages via AI (Priority: P1) + +As a user returning from an activity, I want to ask "What messages did I miss?" and get a summary of recent mesh messages without opening the app. + +**Why this priority**: "Catch me up" is the #1 voice query pattern for communication apps. Mesh messages arrive asynchronously during hikes/outdoor activities where the phone is pocketed. + +**Independent Test**: Invoke `getRecentMessages` and confirm returned messages match the app's message list. + +**Acceptance Scenarios**: + +1. **Given** the device is connected and messages exist, **When** the AI invokes `getRecentMessages` without filters, **Then** the most recent messages (up to limit) are returned with sender name, text, channel, and timestamp +2. **Given** a contactName is provided, **When** the AI invokes `getRecentMessages` with that filter, **Then** only messages from that contact/channel are returned +3. **Given** no messages exist, **When** the AI invokes `getRecentMessages`, **Then** an empty list is returned (not an error) +4. **Given** the device is disconnected, **When** the AI invokes `getRecentMessages`, **Then** cached messages are still returned (message history is local) + +--- + +### User Story 7 - Unread Message Summary via AI (Priority: P1) + +As a user, I want to ask "Do I have unread messages?" and get a per-contact breakdown showing who messaged me and a preview of their last message. + +**Why this priority**: Unread summaries let users decide whether to open the app, reducing unnecessary screen time during outdoor activities. + +**Independent Test**: Invoke `getUnreadSummary` and confirm counts match the app's contact list badges. + +**Acceptance Scenarios**: + +1. **Given** unread messages exist from multiple contacts, **When** the AI invokes `getUnreadSummary`, **Then** the total unread count and per-contact breakdown (name, unread count, last message preview) are returned +2. **Given** no unread messages exist, **When** the AI invokes `getUnreadSummary`, **Then** totalUnreadCount is 0 and the contacts list is empty +3. **Given** a contact has been muted, **When** the AI invokes `getUnreadSummary`, **Then** muted contacts are excluded from the breakdown + +--- + +### Functional Requirements (Phase 3) + +- **FR-012**: `getRecentMessages` MUST return recent messages sorted newest-first, limited to a configurable count (default 20, max 50) +- **FR-013**: `getRecentMessages` MUST support optional `contactName` filter using the existing `FuzzyNameResolver` +- **FR-014**: `getRecentMessages` MUST NOT require an active radio connection (messages are cached locally) +- **FR-015**: `getUnreadSummary` MUST return total unread count and per-contact breakdown with last message preview +- **FR-016**: `getUnreadSummary` MUST exclude muted contacts from the breakdown +- **FR-017**: Both functions MUST respect the 5-second timeout constraint (NFR-002) + +### Architecture Addition + +``` +commonMain/ai/ +├── AiFunctionProvider.kt # + getRecentMessages(), getUnreadSummary() +├── AiFunctionResult.kt # + GetRecentMessagesResult, GetUnreadSummaryResult + +androidApp/ (Google flavor) +├── appfunctions/ +│ ├── MeshtasticAppFunctions.kt # + getRecentMessages, getUnreadSummary +│ └── AppFunctionModels.kt # + MessageInfo, UnreadSummaryResponse, ContactUnreadInfo +``` + +### Data Flow (Message History) + +``` +AiFunctionProvider.getRecentMessages() + ↓ +PacketRepository.getMessagesFrom() / getContacts() + ↓ +NodeRepository (resolve sender names) + ↓ +Return MessageInfo list +```