nornweave 0.1.2__py3-none-any.whl → 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -253,38 +253,88 @@ class ResendAdapter(EmailProvider):
253
253
  result: dict[str, Any] = response.json()
254
254
  return result
255
255
 
256
- async def fetch_attachment_content(self, email_id: str, attachment_id: str) -> bytes:
256
+ async def fetch_attachment_content(
257
+ self, email_id: str, attachment_id: str, *, inbound: bool = True
258
+ ) -> bytes:
257
259
  """Fetch attachment content from Resend API.
258
260
 
261
+ The Resend API returns attachment metadata with a download_url.
262
+ This method fetches the metadata, then downloads the actual content.
263
+
264
+ See:
265
+ - Inbound: https://resend.com/docs/api-reference/emails/retrieve-received-email-attachment
266
+ - Outbound: https://resend.com/docs/api-reference/emails/retrieve-email-attachment
267
+
259
268
  Args:
260
269
  email_id: Resend email ID
261
270
  attachment_id: Attachment ID from webhook/email data
271
+ inbound: If True, use receiving endpoint; if False, use sending endpoint
262
272
 
263
273
  Returns:
264
274
  Attachment binary content
265
275
  """
266
- url = f"{self._api_url}/emails/receiving/{email_id}/attachments/{attachment_id}"
276
+ # Step 1: Get attachment metadata (contains download_url)
277
+ # Use different endpoints for inbound vs outbound emails
278
+ if inbound:
279
+ metadata_url = (
280
+ f"{self._api_url}/emails/receiving/{email_id}/attachments/{attachment_id}"
281
+ )
282
+ else:
283
+ metadata_url = f"{self._api_url}/emails/{email_id}/attachments/{attachment_id}"
267
284
 
268
- logger.debug("Fetching attachment %s from email %s", attachment_id, email_id)
285
+ logger.debug("Fetching attachment metadata %s from email %s", attachment_id, email_id)
269
286
 
270
287
  async with httpx.AsyncClient() as client:
271
288
  response = await client.get(
272
- url,
289
+ metadata_url,
273
290
  headers={
274
291
  "Authorization": f"Bearer {self._api_key}",
275
292
  },
276
- timeout=60.0,
293
+ timeout=30.0,
277
294
  )
278
295
 
279
296
  if response.status_code != 200:
280
297
  logger.error(
281
- "Resend API error fetching attachment: %s - %s",
298
+ "Resend API error fetching attachment metadata: %s - %s",
282
299
  response.status_code,
283
300
  response.text,
284
301
  )
285
302
  response.raise_for_status()
286
303
 
287
- return response.content
304
+ metadata = response.json()
305
+ download_url = metadata.get("download_url")
306
+
307
+ if not download_url:
308
+ raise ValueError(f"No download_url in attachment metadata: {metadata}")
309
+
310
+ logger.debug(
311
+ "Got attachment metadata: filename=%s, size=%s, downloading from %s",
312
+ metadata.get("filename"),
313
+ metadata.get("size"),
314
+ download_url[:50] + "..." if len(download_url) > 50 else download_url,
315
+ )
316
+
317
+ # Step 2: Download actual content from the CDN URL
318
+ content_response = await client.get(
319
+ download_url,
320
+ timeout=60.0,
321
+ follow_redirects=True,
322
+ )
323
+
324
+ if content_response.status_code != 200:
325
+ logger.error(
326
+ "Error downloading attachment content: %s - %s",
327
+ content_response.status_code,
328
+ content_response.text[:200] if content_response.text else "No content",
329
+ )
330
+ content_response.raise_for_status()
331
+
332
+ logger.debug(
333
+ "Downloaded attachment content: %d bytes",
334
+ len(content_response.content),
335
+ )
336
+
337
+ return content_response.content
288
338
 
289
339
  def parse_inbound_webhook(self, payload: dict[str, Any]) -> InboundMessage:
290
340
  """Parse Resend inbound webhook payload into standardized InboundMessage.
nornweave/core/config.py CHANGED
@@ -3,7 +3,7 @@
3
3
  from functools import lru_cache
4
4
  from typing import Literal
5
5
 
6
- from pydantic import Field
6
+ from pydantic import Field, model_validator
7
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
8
8
 
9
9
 
@@ -146,6 +146,44 @@ class Settings(BaseSettings):
146
146
  description="Maximum number of attachments per message",
147
147
  )
148
148
 
149
+ # -------------------------------------------------------------------------
150
+ # LLM Thread Summarization Configuration
151
+ # -------------------------------------------------------------------------
152
+ llm_provider: Literal["openai", "anthropic", "gemini"] | None = Field(
153
+ default=None,
154
+ alias="LLM_PROVIDER",
155
+ description="LLM provider for thread summarization. None = feature disabled.",
156
+ )
157
+ llm_api_key: str = Field(
158
+ default="",
159
+ alias="LLM_API_KEY",
160
+ description="API key for the selected LLM provider",
161
+ )
162
+ llm_model: str = Field(
163
+ default="",
164
+ alias="LLM_MODEL",
165
+ description="Model override (auto-selected per provider if empty)",
166
+ )
167
+ llm_summary_prompt: str = Field(
168
+ default=(
169
+ "You are an email thread summarizer. Given a chronological email conversation, "
170
+ "produce a concise summary that captures:\n"
171
+ "- Key topics discussed\n"
172
+ "- Decisions made or actions agreed upon\n"
173
+ "- Open questions or pending items\n"
174
+ "- Current status of the conversation\n\n"
175
+ "Keep the summary under 300 words. Use bullet points for clarity.\n"
176
+ "Do not include greetings, sign-offs, or meta-commentary."
177
+ ),
178
+ alias="LLM_SUMMARY_PROMPT",
179
+ description="System prompt for thread summarization",
180
+ )
181
+ llm_daily_token_limit: int = Field(
182
+ default=1_000_000,
183
+ alias="LLM_DAILY_TOKEN_LIMIT",
184
+ description="Max tokens per day for summarization (0 = unlimited)",
185
+ )
186
+
149
187
  # -------------------------------------------------------------------------
150
188
  # Content Extraction Configuration (Talon)
151
189
  # -------------------------------------------------------------------------
@@ -165,6 +203,17 @@ class Settings(BaseSettings):
165
203
  description="Return original content if extraction fails",
166
204
  )
167
205
 
206
+ @model_validator(mode="after")
207
+ def validate_llm_config(self) -> Settings:
208
+ """Validate LLM configuration: API key is required when provider is set."""
209
+ if self.llm_provider is not None and not self.llm_api_key:
210
+ msg = (
211
+ f"LLM_API_KEY is required when LLM_PROVIDER is set to '{self.llm_provider}'. "
212
+ "Set LLM_API_KEY in your environment or .env file."
213
+ )
214
+ raise ValueError(msg)
215
+ return self
216
+
168
217
 
169
218
  @lru_cache
170
219
  def get_settings() -> Settings:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
  from dataclasses import dataclass, field
5
- from datetime import datetime
5
+ from datetime import date, datetime
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from nornweave.models.attachment import AttachmentDisposition, SendAttachment
@@ -252,6 +252,31 @@ class StorageInterface(ABC):
252
252
  """Search messages by content (ILIKE/LIKE on content_clean and content_raw)."""
253
253
  ...
254
254
 
255
+ @abstractmethod
256
+ async def search_messages_advanced(
257
+ self,
258
+ *,
259
+ inbox_id: str | None = None,
260
+ thread_id: str | None = None,
261
+ query: str | None = None,
262
+ limit: int = 50,
263
+ offset: int = 0,
264
+ ) -> tuple[list[Message], int]:
265
+ """
266
+ Search messages with flexible filters.
267
+
268
+ Args:
269
+ inbox_id: Filter by inbox (optional)
270
+ thread_id: Filter by thread (optional)
271
+ query: Text search across subject, text, from_address, attachment filenames
272
+ limit: Maximum results to return
273
+ offset: Pagination offset
274
+
275
+ Returns:
276
+ Tuple of (messages, total_count)
277
+ """
278
+ ...
279
+
255
280
  # -------------------------------------------------------------------------
256
281
  # Event methods (Phase 3 webhooks)
257
282
  # -------------------------------------------------------------------------
@@ -291,6 +316,8 @@ class StorageInterface(ABC):
291
316
  content_id: str | None = None,
292
317
  storage_path: str | None = None,
293
318
  storage_backend: str | None = None,
319
+ content_hash: str | None = None,
320
+ content: bytes | None = None,
294
321
  ) -> str:
295
322
  """Create attachment record. Returns attachment ID."""
296
323
  ...
@@ -305,6 +332,28 @@ class StorageInterface(ABC):
305
332
  """List attachments for a message."""
306
333
  ...
307
334
 
335
+ @abstractmethod
336
+ async def list_attachments_for_thread(
337
+ self,
338
+ thread_id: str,
339
+ *,
340
+ limit: int = 100,
341
+ offset: int = 0,
342
+ ) -> list[dict[str, Any]]:
343
+ """List attachments for all messages in a thread."""
344
+ ...
345
+
346
+ @abstractmethod
347
+ async def list_attachments_for_inbox(
348
+ self,
349
+ inbox_id: str,
350
+ *,
351
+ limit: int = 100,
352
+ offset: int = 0,
353
+ ) -> list[dict[str, Any]]:
354
+ """List attachments for all messages in an inbox."""
355
+ ...
356
+
308
357
  @abstractmethod
309
358
  async def delete_attachment(self, attachment_id: str) -> bool:
310
359
  """Delete attachment. Returns True if deleted."""
@@ -331,6 +380,19 @@ class StorageInterface(ABC):
331
380
  """Get thread by normalized subject within time window (for subject-based threading)."""
332
381
  ...
333
382
 
383
+ # -------------------------------------------------------------------------
384
+ # LLM Token Usage methods
385
+ # -------------------------------------------------------------------------
386
+ @abstractmethod
387
+ async def get_token_usage(self, usage_date: date) -> int:
388
+ """Get total tokens used for a given date. Returns 0 if no record exists."""
389
+ ...
390
+
391
+ @abstractmethod
392
+ async def record_token_usage(self, usage_date: date, tokens: int) -> None:
393
+ """Record token usage for a given date. Creates or increments the daily counter."""
394
+ ...
395
+
334
396
 
335
397
  class EmailProvider(ABC):
336
398
  """Abstract email provider (BYOP). Implementations: Mailgun, SES, SendGrid, Resend."""
@@ -173,25 +173,36 @@ class NornWeaveClient:
173
173
 
174
174
  async def list_messages(
175
175
  self,
176
- inbox_id: str,
176
+ inbox_id: str | None = None,
177
+ thread_id: str | None = None,
178
+ q: str | None = None,
177
179
  limit: int = 50,
178
180
  offset: int = 0,
179
181
  ) -> dict[str, Any]:
180
- """List messages for an inbox.
182
+ """List and search messages with flexible filters.
183
+
184
+ At least one of inbox_id or thread_id must be provided.
181
185
 
182
186
  Args:
183
- inbox_id: The inbox ID.
187
+ inbox_id: Filter by inbox ID (optional).
188
+ thread_id: Filter by thread ID (optional).
189
+ q: Text search query (optional).
184
190
  limit: Maximum number of messages to return.
185
191
  offset: Number of messages to skip.
186
192
 
187
193
  Returns:
188
- List response with messages.
194
+ List response with messages, count, and total.
189
195
  """
190
196
  client = await self._get_client()
191
- response = await client.get(
192
- "/v1/messages",
193
- params={"inbox_id": inbox_id, "limit": limit, "offset": offset},
194
- )
197
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
198
+ if inbox_id:
199
+ params["inbox_id"] = inbox_id
200
+ if thread_id:
201
+ params["thread_id"] = thread_id
202
+ if q:
203
+ params["q"] = q
204
+
205
+ response = await client.get("/v1/messages", params=params)
195
206
  response.raise_for_status()
196
207
  return cast("dict[str, Any]", response.json())
197
208
 
@@ -294,3 +305,121 @@ class NornWeaveClient:
294
305
  if not messages:
295
306
  return None
296
307
  return cast("dict[str, Any]", messages[-1])
308
+
309
+ # -------------------------------------------------------------------------
310
+ # Attachment Operations
311
+ # -------------------------------------------------------------------------
312
+
313
+ async def list_attachments(
314
+ self,
315
+ message_id: str | None = None,
316
+ thread_id: str | None = None,
317
+ inbox_id: str | None = None,
318
+ limit: int = 100,
319
+ offset: int = 0,
320
+ ) -> dict[str, Any]:
321
+ """List attachments filtered by message, thread, or inbox.
322
+
323
+ Args:
324
+ message_id: Filter by message ID.
325
+ thread_id: Filter by thread ID.
326
+ inbox_id: Filter by inbox ID.
327
+ limit: Maximum number of attachments to return.
328
+ offset: Number of attachments to skip.
329
+
330
+ Returns:
331
+ List response with attachment metadata.
332
+ """
333
+ client = await self._get_client()
334
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
335
+ if message_id:
336
+ params["message_id"] = message_id
337
+ elif thread_id:
338
+ params["thread_id"] = thread_id
339
+ elif inbox_id:
340
+ params["inbox_id"] = inbox_id
341
+
342
+ response = await client.get("/v1/attachments", params=params)
343
+ response.raise_for_status()
344
+ return cast("dict[str, Any]", response.json())
345
+
346
+ async def get_attachment(self, attachment_id: str) -> dict[str, Any]:
347
+ """Get attachment metadata.
348
+
349
+ Args:
350
+ attachment_id: The attachment ID.
351
+
352
+ Returns:
353
+ Attachment metadata with download_url.
354
+ """
355
+ client = await self._get_client()
356
+ response = await client.get(f"/v1/attachments/{attachment_id}")
357
+ response.raise_for_status()
358
+ return cast("dict[str, Any]", response.json())
359
+
360
+ async def get_attachment_content(
361
+ self,
362
+ attachment_id: str,
363
+ response_format: str = "base64",
364
+ ) -> dict[str, Any]:
365
+ """Get attachment content.
366
+
367
+ Args:
368
+ attachment_id: The attachment ID.
369
+ response_format: Response format - "binary" or "base64" (default: base64).
370
+
371
+ Returns:
372
+ For base64: {"content": "...", "content_type": "...", "filename": "..."}
373
+ For binary: raw bytes (handled by httpx)
374
+ """
375
+ client = await self._get_client()
376
+ response = await client.get(
377
+ f"/v1/attachments/{attachment_id}/content",
378
+ params={"format": response_format},
379
+ )
380
+ response.raise_for_status()
381
+ return cast("dict[str, Any]", response.json())
382
+
383
+ async def send_message_with_attachments(
384
+ self,
385
+ inbox_id: str,
386
+ to: list[str],
387
+ subject: str,
388
+ body: str,
389
+ attachments: list[dict[str, str]],
390
+ reply_to_thread_id: str | None = None,
391
+ ) -> dict[str, Any]:
392
+ """Send an outbound message with attachments.
393
+
394
+ Args:
395
+ inbox_id: The inbox ID to send from.
396
+ to: List of recipient email addresses.
397
+ subject: Email subject.
398
+ body: Markdown body content.
399
+ attachments: List of attachment dicts with filename, content_type, content (base64).
400
+ reply_to_thread_id: Thread ID if this is a reply.
401
+
402
+ Returns:
403
+ Send response with message_id, thread_id, status.
404
+ """
405
+ client = await self._get_client()
406
+ payload: dict[str, Any] = {
407
+ "inbox_id": inbox_id,
408
+ "to": to,
409
+ "subject": subject,
410
+ "body": body,
411
+ "attachments": [
412
+ {
413
+ "filename": att["filename"],
414
+ "content_type": att["content_type"],
415
+ "content_base64": att["content"],
416
+ }
417
+ for att in attachments
418
+ ],
419
+ }
420
+ if reply_to_thread_id:
421
+ payload["reply_to_thread_id"] = reply_to_thread_id
422
+
423
+ response = await client.post("/v1/messages", json=payload)
424
+ response.raise_for_status()
425
+ return cast("dict[str, Any]", response.json())
@@ -52,6 +52,7 @@ async def get_recent_threads(client: NornWeaveClient, inbox_id: str) -> str:
52
52
  "last_message_at": thread.get("last_message_at"),
53
53
  "message_count": message_count,
54
54
  "participants": participants,
55
+ "summary": thread.get("summary"),
55
56
  }
56
57
  )
57
58
 
@@ -114,6 +115,14 @@ def format_thread_markdown(thread: dict[str, Any]) -> str:
114
115
  lines.append(f"## Thread: {subject}")
115
116
  lines.append("")
116
117
 
118
+ # Include summary if available
119
+ summary = thread.get("summary")
120
+ if summary:
121
+ lines.append("## Summary")
122
+ lines.append("")
123
+ lines.append(summary)
124
+ lines.append("")
125
+
117
126
  messages = thread.get("messages", [])
118
127
  if not messages:
119
128
  lines.append("*No messages in this thread.*")
@@ -15,12 +15,21 @@ from fastmcp import FastMCP
15
15
 
16
16
  from nornweave.huginn.client import NornWeaveClient
17
17
  from nornweave.huginn.resources import get_recent_threads, get_thread_content
18
- from nornweave.muninn.tools import create_inbox, search_email, send_email, wait_for_reply
18
+ from nornweave.muninn.tools import (
19
+ create_inbox,
20
+ get_attachment_content,
21
+ list_attachments,
22
+ list_messages,
23
+ search_email,
24
+ send_email,
25
+ send_email_with_attachments,
26
+ wait_for_reply,
27
+ )
19
28
 
20
29
  # Create the FastMCP server
21
30
  mcp = FastMCP(
22
31
  name="nornweave",
23
- version="0.1.2",
32
+ version="0.1.3",
24
33
  instructions="Email capabilities for AI agents - create inboxes, send emails, search messages",
25
34
  )
26
35
 
@@ -115,21 +124,66 @@ async def tool_send_email(
115
124
 
116
125
 
117
126
  @mcp.tool()
118
- async def tool_search_email(query: str, inbox_id: str, limit: int = 10) -> dict[str, Any]:
119
- """Search for emails.
127
+ async def tool_search_email(
128
+ query: str,
129
+ inbox_id: str | None = None,
130
+ thread_id: str | None = None,
131
+ limit: int = 10,
132
+ offset: int = 0,
133
+ ) -> dict[str, Any]:
134
+ """Search for emails with flexible filters.
120
135
 
121
- Find relevant messages in an inbox by query text.
136
+ Find relevant messages by query text. At least one of inbox_id or thread_id must be provided.
122
137
 
123
138
  Args:
124
- query: Search query (e.g., "invoice", "meeting request")
125
- inbox_id: Inbox to search in
139
+ query: Search query (searches subject, body, sender, attachment filenames)
140
+ inbox_id: Filter by inbox ID (optional)
141
+ thread_id: Filter by thread ID (optional)
126
142
  limit: Maximum number of results (default: 10, max: 100)
143
+ offset: Pagination offset (default: 0)
127
144
 
128
145
  Returns:
129
- Search results with matching messages containing id, thread_id, content, created_at.
146
+ Search results with matching messages containing full email metadata.
130
147
  """
131
148
  client = _get_client()
132
- return await search_email(client, query=query, inbox_id=inbox_id, limit=min(limit, 100))
149
+ return await search_email(
150
+ client,
151
+ query=query,
152
+ inbox_id=inbox_id,
153
+ thread_id=thread_id,
154
+ limit=min(limit, 100),
155
+ offset=offset,
156
+ )
157
+
158
+
159
+ @mcp.tool()
160
+ async def tool_list_messages(
161
+ inbox_id: str | None = None,
162
+ thread_id: str | None = None,
163
+ limit: int = 50,
164
+ offset: int = 0,
165
+ ) -> dict[str, Any]:
166
+ """List messages with flexible filters.
167
+
168
+ List messages from an inbox or thread. At least one of inbox_id or thread_id must be provided.
169
+
170
+ Args:
171
+ inbox_id: Filter by inbox ID (optional)
172
+ thread_id: Filter by thread ID (optional)
173
+ limit: Maximum number of results (default: 50, max: 100)
174
+ offset: Pagination offset (default: 0)
175
+
176
+ Returns:
177
+ List of messages with full email metadata including subject, from_address, to_addresses, text, etc.
178
+ """
179
+ client = _get_client()
180
+ return await list_messages(
181
+ client,
182
+ inbox_id=inbox_id,
183
+ thread_id=thread_id,
184
+ limit=min(limit, 100),
185
+ offset=offset,
186
+ )
133
187
 
134
188
 
135
189
  @mcp.tool()
@@ -155,6 +209,91 @@ async def tool_wait_for_reply(thread_id: str, timeout_seconds: int = 300) -> dic
155
209
  return await wait_for_reply(client, thread_id=thread_id, timeout_seconds=timeout_seconds)
156
210
 
157
211
 
212
+ @mcp.tool()
213
+ async def tool_list_attachments(
214
+ message_id: str | None = None,
215
+ thread_id: str | None = None,
216
+ inbox_id: str | None = None,
217
+ limit: int = 100,
218
+ ) -> dict[str, Any]:
219
+ """List attachments for a message, thread, or inbox.
220
+
221
+ Retrieve metadata for attachments. Exactly one filter must be provided.
222
+
223
+ Args:
224
+ message_id: Filter by message ID
225
+ thread_id: Filter by thread ID (all messages in thread)
226
+ inbox_id: Filter by inbox ID (all messages in inbox)
227
+ limit: Maximum number of results (default: 100)
228
+
229
+ Returns:
230
+ List of attachment metadata with id, message_id, filename, content_type, size.
231
+ """
232
+ client = _get_client()
233
+ return await list_attachments(
234
+ client,
235
+ message_id=message_id,
236
+ thread_id=thread_id,
237
+ inbox_id=inbox_id,
238
+ limit=limit,
239
+ )
240
+
241
+
242
+ @mcp.tool()
243
+ async def tool_get_attachment_content(attachment_id: str) -> dict[str, Any]:
244
+ """Get attachment content as base64.
245
+
246
+ Download the binary content of an attachment, returned as base64-encoded data.
247
+
248
+ Args:
249
+ attachment_id: The attachment ID to retrieve
250
+
251
+ Returns:
252
+ Attachment content with base64-encoded data, content_type, and filename.
253
+ """
254
+ client = _get_client()
255
+ return await get_attachment_content(client, attachment_id=attachment_id)
256
+
257
+
258
+ @mcp.tool()
259
+ async def tool_send_email_with_attachments(
260
+ inbox_id: str,
261
+ recipient: str,
262
+ subject: str,
263
+ body: str,
264
+ attachments: list[dict[str, str]],
265
+ thread_id: str | None = None,
266
+ ) -> dict[str, Any]:
267
+ """Send an email with attachments.
268
+
269
+ Send an email with one or more file attachments.
270
+
271
+ Args:
272
+ inbox_id: The inbox ID to send from
273
+ recipient: Email address to send to
274
+ subject: Email subject line
275
+ body: Email body in Markdown format
276
+ attachments: List of attachments, each with:
277
+ - filename: Name of the file (e.g., "report.pdf")
278
+ - content_type: MIME type (e.g., "application/pdf")
279
+ - content: Base64-encoded file content
280
+ thread_id: Optional thread ID if this is a reply
281
+
282
+ Returns:
283
+ Send response with message_id, thread_id, and status.
284
+ """
285
+ client = _get_client()
286
+ return await send_email_with_attachments(
287
+ client,
288
+ inbox_id=inbox_id,
289
+ recipient=recipient,
290
+ subject=subject,
291
+ body=body,
292
+ attachments=attachments,
293
+ thread_id=thread_id,
294
+ )
295
+
296
+
158
297
  # -----------------------------------------------------------------------------
159
298
  # Server Entry Points
160
299
  # -----------------------------------------------------------------------------
@@ -34,6 +34,7 @@ class ThreadItem(BaseModel):
34
34
  recipients: list[str] = Field(default_factory=list, description="Recipients in thread")
35
35
  subject: str | None = Field(None, description="Subject of thread")
36
36
  preview: str | None = Field(None, description="Text preview of last message in thread")
37
+ summary: str | None = Field(None, description="LLM-generated thread summary")
37
38
  attachments: list[AttachmentMeta] | None = Field(None, description="Attachments in thread")
38
39
  last_message_id: str | None = Field(None, description="ID of last message in thread")
39
40
  message_count: int = Field(0, description="Number of messages in thread")
@@ -75,6 +76,7 @@ class Thread(BaseModel):
75
76
  recipients: list[str] = Field(default_factory=list, description="Recipients in thread")
76
77
  subject: str | None = Field(None, description="Subject of thread")
77
78
  preview: str | None = Field(None, description="Text preview of last message in thread")
79
+ summary: str | None = Field(None, description="LLM-generated thread summary")
78
80
  attachments: list[AttachmentMeta] | None = Field(None, description="All attachments in thread")
79
81
  last_message_id: str | None = Field(None, description="ID of last message in thread")
80
82
  message_count: int = Field(0, description="Number of messages in thread")