nornweave 0.1.2__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.
Files changed (80) hide show
  1. nornweave/__init__.py +3 -0
  2. nornweave/adapters/__init__.py +1 -0
  3. nornweave/adapters/base.py +5 -0
  4. nornweave/adapters/mailgun.py +196 -0
  5. nornweave/adapters/resend.py +510 -0
  6. nornweave/adapters/sendgrid.py +492 -0
  7. nornweave/adapters/ses.py +824 -0
  8. nornweave/cli.py +186 -0
  9. nornweave/core/__init__.py +26 -0
  10. nornweave/core/config.py +172 -0
  11. nornweave/core/exceptions.py +25 -0
  12. nornweave/core/interfaces.py +390 -0
  13. nornweave/core/storage.py +192 -0
  14. nornweave/core/utils.py +23 -0
  15. nornweave/huginn/__init__.py +10 -0
  16. nornweave/huginn/client.py +296 -0
  17. nornweave/huginn/config.py +52 -0
  18. nornweave/huginn/resources.py +165 -0
  19. nornweave/huginn/server.py +202 -0
  20. nornweave/models/__init__.py +113 -0
  21. nornweave/models/attachment.py +136 -0
  22. nornweave/models/event.py +275 -0
  23. nornweave/models/inbox.py +33 -0
  24. nornweave/models/message.py +284 -0
  25. nornweave/models/thread.py +172 -0
  26. nornweave/muninn/__init__.py +14 -0
  27. nornweave/muninn/tools.py +207 -0
  28. nornweave/search/__init__.py +1 -0
  29. nornweave/search/embeddings.py +1 -0
  30. nornweave/search/vector_store.py +1 -0
  31. nornweave/skuld/__init__.py +1 -0
  32. nornweave/skuld/rate_limiter.py +1 -0
  33. nornweave/skuld/scheduler.py +1 -0
  34. nornweave/skuld/sender.py +25 -0
  35. nornweave/skuld/webhooks.py +1 -0
  36. nornweave/storage/__init__.py +20 -0
  37. nornweave/storage/database.py +165 -0
  38. nornweave/storage/gcs.py +144 -0
  39. nornweave/storage/local.py +152 -0
  40. nornweave/storage/s3.py +164 -0
  41. nornweave/urdr/__init__.py +14 -0
  42. nornweave/urdr/adapters/__init__.py +16 -0
  43. nornweave/urdr/adapters/base.py +385 -0
  44. nornweave/urdr/adapters/postgres.py +50 -0
  45. nornweave/urdr/adapters/sqlite.py +51 -0
  46. nornweave/urdr/migrations/env.py +94 -0
  47. nornweave/urdr/migrations/script.py.mako +26 -0
  48. nornweave/urdr/migrations/versions/.gitkeep +0 -0
  49. nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +182 -0
  50. nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +241 -0
  51. nornweave/urdr/orm.py +641 -0
  52. nornweave/verdandi/__init__.py +45 -0
  53. nornweave/verdandi/attachments.py +471 -0
  54. nornweave/verdandi/content.py +420 -0
  55. nornweave/verdandi/headers.py +404 -0
  56. nornweave/verdandi/parser.py +25 -0
  57. nornweave/verdandi/sanitizer.py +9 -0
  58. nornweave/verdandi/threading.py +359 -0
  59. nornweave/yggdrasil/__init__.py +1 -0
  60. nornweave/yggdrasil/app.py +86 -0
  61. nornweave/yggdrasil/dependencies.py +190 -0
  62. nornweave/yggdrasil/middleware/__init__.py +1 -0
  63. nornweave/yggdrasil/middleware/auth.py +1 -0
  64. nornweave/yggdrasil/middleware/logging.py +1 -0
  65. nornweave/yggdrasil/routes/__init__.py +1 -0
  66. nornweave/yggdrasil/routes/v1/__init__.py +1 -0
  67. nornweave/yggdrasil/routes/v1/inboxes.py +124 -0
  68. nornweave/yggdrasil/routes/v1/messages.py +200 -0
  69. nornweave/yggdrasil/routes/v1/search.py +84 -0
  70. nornweave/yggdrasil/routes/v1/threads.py +142 -0
  71. nornweave/yggdrasil/routes/webhooks/__init__.py +1 -0
  72. nornweave/yggdrasil/routes/webhooks/mailgun.py +136 -0
  73. nornweave/yggdrasil/routes/webhooks/resend.py +344 -0
  74. nornweave/yggdrasil/routes/webhooks/sendgrid.py +15 -0
  75. nornweave/yggdrasil/routes/webhooks/ses.py +15 -0
  76. nornweave-0.1.2.dist-info/METADATA +324 -0
  77. nornweave-0.1.2.dist-info/RECORD +80 -0
  78. nornweave-0.1.2.dist-info/WHEEL +4 -0
  79. nornweave-0.1.2.dist-info/entry_points.txt +5 -0
  80. nornweave-0.1.2.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,296 @@
1
+ """HTTP client for NornWeave API.
2
+
3
+ This module provides an async HTTP client for the MCP server to communicate
4
+ with the NornWeave REST API.
5
+ """
6
+
7
+ from typing import Any, cast
8
+
9
+ import httpx
10
+
11
+ from nornweave.huginn.config import get_mcp_settings
12
+
13
+
14
+ class NornWeaveClient:
15
+ """Async HTTP client for NornWeave REST API."""
16
+
17
+ def __init__(
18
+ self,
19
+ api_url: str | None = None,
20
+ api_key: str | None = None,
21
+ ) -> None:
22
+ """Initialize the client.
23
+
24
+ Args:
25
+ api_url: Base URL for the NornWeave API. Defaults to NORNWEAVE_API_URL env var.
26
+ api_key: API key for authentication. Defaults to NORNWEAVE_API_KEY env var.
27
+ """
28
+ settings = get_mcp_settings()
29
+ self.api_url = (api_url or settings.api_url).rstrip("/")
30
+ self.api_key = api_key or settings.api_key
31
+ self._client: httpx.AsyncClient | None = None
32
+
33
+ async def _get_client(self) -> httpx.AsyncClient:
34
+ """Get or create the HTTP client."""
35
+ if self._client is None:
36
+ headers: dict[str, str] = {}
37
+ if self.api_key:
38
+ headers["X-API-Key"] = self.api_key
39
+ self._client = httpx.AsyncClient(
40
+ base_url=self.api_url,
41
+ headers=headers,
42
+ timeout=30.0,
43
+ )
44
+ return self._client
45
+
46
+ async def close(self) -> None:
47
+ """Close the HTTP client."""
48
+ if self._client is not None:
49
+ await self._client.aclose()
50
+ self._client = None
51
+
52
+ async def __aenter__(self) -> NornWeaveClient:
53
+ """Enter async context."""
54
+ return self
55
+
56
+ async def __aexit__(self, *args: object) -> None:
57
+ """Exit async context."""
58
+ await self.close()
59
+
60
+ # -------------------------------------------------------------------------
61
+ # Inbox Operations
62
+ # -------------------------------------------------------------------------
63
+
64
+ async def create_inbox(self, name: str, email_username: str) -> dict[str, Any]:
65
+ """Create a new inbox.
66
+
67
+ Args:
68
+ name: Display name for the inbox.
69
+ email_username: Local part of the email address.
70
+
71
+ Returns:
72
+ Created inbox with id, email_address, name.
73
+ """
74
+ client = await self._get_client()
75
+ response = await client.post(
76
+ "/v1/inboxes",
77
+ json={"name": name, "email_username": email_username},
78
+ )
79
+ response.raise_for_status()
80
+ return cast("dict[str, Any]", response.json())
81
+
82
+ async def get_inbox(self, inbox_id: str) -> dict[str, Any]:
83
+ """Get an inbox by ID.
84
+
85
+ Args:
86
+ inbox_id: The inbox ID.
87
+
88
+ Returns:
89
+ Inbox data.
90
+ """
91
+ client = await self._get_client()
92
+ response = await client.get(f"/v1/inboxes/{inbox_id}")
93
+ response.raise_for_status()
94
+ return cast("dict[str, Any]", response.json())
95
+
96
+ async def list_inboxes(self, limit: int = 50, offset: int = 0) -> dict[str, Any]:
97
+ """List all inboxes.
98
+
99
+ Args:
100
+ limit: Maximum number of inboxes to return.
101
+ offset: Number of inboxes to skip.
102
+
103
+ Returns:
104
+ List response with items and count.
105
+ """
106
+ client = await self._get_client()
107
+ response = await client.get(
108
+ "/v1/inboxes",
109
+ params={"limit": limit, "offset": offset},
110
+ )
111
+ response.raise_for_status()
112
+ return cast("dict[str, Any]", response.json())
113
+
114
+ # -------------------------------------------------------------------------
115
+ # Thread Operations
116
+ # -------------------------------------------------------------------------
117
+
118
+ async def get_thread(self, thread_id: str) -> dict[str, Any]:
119
+ """Get a thread with messages.
120
+
121
+ Args:
122
+ thread_id: The thread ID.
123
+
124
+ Returns:
125
+ Thread data with messages in LLM-ready format.
126
+ """
127
+ client = await self._get_client()
128
+ response = await client.get(f"/v1/threads/{thread_id}")
129
+ response.raise_for_status()
130
+ return cast("dict[str, Any]", response.json())
131
+
132
+ async def list_threads(
133
+ self,
134
+ inbox_id: str,
135
+ limit: int = 20,
136
+ offset: int = 0,
137
+ ) -> dict[str, Any]:
138
+ """List threads for an inbox.
139
+
140
+ Args:
141
+ inbox_id: The inbox ID.
142
+ limit: Maximum number of threads to return.
143
+ offset: Number of threads to skip.
144
+
145
+ Returns:
146
+ List response with thread summaries.
147
+ """
148
+ client = await self._get_client()
149
+ response = await client.get(
150
+ "/v1/threads",
151
+ params={"inbox_id": inbox_id, "limit": limit, "offset": offset},
152
+ )
153
+ response.raise_for_status()
154
+ return cast("dict[str, Any]", response.json())
155
+
156
+ # -------------------------------------------------------------------------
157
+ # Message Operations
158
+ # -------------------------------------------------------------------------
159
+
160
+ async def get_message(self, message_id: str) -> dict[str, Any]:
161
+ """Get a message by ID.
162
+
163
+ Args:
164
+ message_id: The message ID.
165
+
166
+ Returns:
167
+ Message data.
168
+ """
169
+ client = await self._get_client()
170
+ response = await client.get(f"/v1/messages/{message_id}")
171
+ response.raise_for_status()
172
+ return cast("dict[str, Any]", response.json())
173
+
174
+ async def list_messages(
175
+ self,
176
+ inbox_id: str,
177
+ limit: int = 50,
178
+ offset: int = 0,
179
+ ) -> dict[str, Any]:
180
+ """List messages for an inbox.
181
+
182
+ Args:
183
+ inbox_id: The inbox ID.
184
+ limit: Maximum number of messages to return.
185
+ offset: Number of messages to skip.
186
+
187
+ Returns:
188
+ List response with messages.
189
+ """
190
+ client = await self._get_client()
191
+ response = await client.get(
192
+ "/v1/messages",
193
+ params={"inbox_id": inbox_id, "limit": limit, "offset": offset},
194
+ )
195
+ response.raise_for_status()
196
+ return cast("dict[str, Any]", response.json())
197
+
198
+ async def send_message(
199
+ self,
200
+ inbox_id: str,
201
+ to: list[str],
202
+ subject: str,
203
+ body: str,
204
+ reply_to_thread_id: str | None = None,
205
+ ) -> dict[str, Any]:
206
+ """Send an outbound message.
207
+
208
+ Args:
209
+ inbox_id: The inbox ID to send from.
210
+ to: List of recipient email addresses.
211
+ subject: Email subject.
212
+ body: Markdown body content.
213
+ reply_to_thread_id: Thread ID if this is a reply.
214
+
215
+ Returns:
216
+ Send response with message_id, thread_id, status.
217
+ """
218
+ client = await self._get_client()
219
+ payload: dict[str, Any] = {
220
+ "inbox_id": inbox_id,
221
+ "to": to,
222
+ "subject": subject,
223
+ "body": body,
224
+ }
225
+ if reply_to_thread_id:
226
+ payload["reply_to_thread_id"] = reply_to_thread_id
227
+
228
+ response = await client.post("/v1/messages", json=payload)
229
+ response.raise_for_status()
230
+ return cast("dict[str, Any]", response.json())
231
+
232
+ # -------------------------------------------------------------------------
233
+ # Search Operations
234
+ # -------------------------------------------------------------------------
235
+
236
+ async def search_messages(
237
+ self,
238
+ query: str,
239
+ inbox_id: str,
240
+ limit: int = 50,
241
+ offset: int = 0,
242
+ ) -> dict[str, Any]:
243
+ """Search messages by content.
244
+
245
+ Args:
246
+ query: Search query.
247
+ inbox_id: Inbox to search in.
248
+ limit: Maximum number of results.
249
+ offset: Number of results to skip.
250
+
251
+ Returns:
252
+ Search response with matching messages.
253
+ """
254
+ client = await self._get_client()
255
+ response = await client.post(
256
+ "/v1/search",
257
+ json={
258
+ "query": query,
259
+ "inbox_id": inbox_id,
260
+ "limit": limit,
261
+ "offset": offset,
262
+ },
263
+ )
264
+ response.raise_for_status()
265
+ return cast("dict[str, Any]", response.json())
266
+
267
+ # -------------------------------------------------------------------------
268
+ # Polling for wait_for_reply
269
+ # -------------------------------------------------------------------------
270
+
271
+ async def get_thread_message_count(self, thread_id: str) -> int:
272
+ """Get the current message count for a thread.
273
+
274
+ Args:
275
+ thread_id: The thread ID.
276
+
277
+ Returns:
278
+ Number of messages in the thread.
279
+ """
280
+ thread = await self.get_thread(thread_id)
281
+ return len(thread.get("messages", []))
282
+
283
+ async def get_latest_message(self, thread_id: str) -> dict[str, Any] | None:
284
+ """Get the latest message in a thread.
285
+
286
+ Args:
287
+ thread_id: The thread ID.
288
+
289
+ Returns:
290
+ The latest message or None if thread is empty.
291
+ """
292
+ thread = await self.get_thread(thread_id)
293
+ messages = thread.get("messages", [])
294
+ if not messages:
295
+ return None
296
+ return cast("dict[str, Any]", messages[-1])
@@ -0,0 +1,52 @@
1
+ """MCP server configuration.
2
+
3
+ Configuration is loaded from environment variables:
4
+ - NORNWEAVE_API_URL: Base URL for the NornWeave REST API (default: http://localhost:8000)
5
+ - NORNWEAVE_API_KEY: API key for authentication (optional)
6
+ """
7
+
8
+ from functools import lru_cache
9
+
10
+ from pydantic import Field
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+
14
+ class MCPSettings(BaseSettings):
15
+ """MCP server configuration from environment."""
16
+
17
+ model_config = SettingsConfigDict(
18
+ env_file=".env",
19
+ env_file_encoding="utf-8",
20
+ case_sensitive=False,
21
+ extra="ignore",
22
+ )
23
+
24
+ # API connection
25
+ api_url: str = Field(
26
+ default="http://localhost:8000",
27
+ alias="NORNWEAVE_API_URL",
28
+ description="Base URL for the NornWeave REST API",
29
+ )
30
+ api_key: str = Field(
31
+ default="",
32
+ alias="NORNWEAVE_API_KEY",
33
+ description="API key for authentication (optional)",
34
+ )
35
+
36
+ # MCP server settings
37
+ mcp_host: str = Field(
38
+ default="0.0.0.0",
39
+ alias="NORNWEAVE_MCP_HOST",
40
+ description="Host to bind MCP server (SSE/HTTP transports)",
41
+ )
42
+ mcp_port: int = Field(
43
+ default=3000,
44
+ alias="NORNWEAVE_MCP_PORT",
45
+ description="Port for MCP server (SSE/HTTP transports)",
46
+ )
47
+
48
+
49
+ @lru_cache
50
+ def get_mcp_settings() -> MCPSettings:
51
+ """Get cached MCP settings instance."""
52
+ return MCPSettings()
@@ -0,0 +1,165 @@
1
+ """MCP resources: email://inbox/{id}/recent, email://thread/{id}.
2
+
3
+ Resources provide read-only data access for AI agents.
4
+ """
5
+
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ import httpx
9
+
10
+ if TYPE_CHECKING:
11
+ from nornweave.huginn.client import NornWeaveClient
12
+
13
+
14
+ async def get_recent_threads(client: NornWeaveClient, inbox_id: str) -> str:
15
+ """Get recent threads for an inbox.
16
+
17
+ Resource URI: email://inbox/{inbox_id}/recent
18
+
19
+ Args:
20
+ client: NornWeave API client.
21
+ inbox_id: The inbox ID.
22
+
23
+ Returns:
24
+ JSON string with thread summaries.
25
+
26
+ Raises:
27
+ Exception: If inbox not found or API error.
28
+ """
29
+ import json
30
+
31
+ try:
32
+ # Get threads (limit to 10 most recent)
33
+ threads_response = await client.list_threads(inbox_id, limit=10)
34
+ threads = threads_response.get("items", [])
35
+
36
+ # Format thread summaries with message count
37
+ summaries = []
38
+ for thread in threads:
39
+ # Get thread details to count messages
40
+ try:
41
+ thread_detail = await client.get_thread(thread["id"])
42
+ message_count = len(thread_detail.get("messages", []))
43
+ participants = _extract_participants(thread_detail)
44
+ except httpx.HTTPStatusError:
45
+ message_count = 0
46
+ participants = []
47
+
48
+ summaries.append(
49
+ {
50
+ "id": thread["id"],
51
+ "subject": thread.get("subject", "(no subject)"),
52
+ "last_message_at": thread.get("last_message_at"),
53
+ "message_count": message_count,
54
+ "participants": participants,
55
+ }
56
+ )
57
+
58
+ return json.dumps(summaries, indent=2, default=str)
59
+
60
+ except httpx.HTTPStatusError as e:
61
+ if e.response.status_code == 404:
62
+ raise Exception(f"Inbox '{inbox_id}' not found") from e
63
+ raise Exception(f"API error: {e.response.status_code}") from e
64
+
65
+
66
+ def _extract_participants(thread_detail: dict[str, Any]) -> list[str]:
67
+ """Extract unique participant email addresses from thread messages."""
68
+ participants: set[str] = set()
69
+ for message in thread_detail.get("messages", []):
70
+ author = message.get("author")
71
+ if author:
72
+ participants.add(author)
73
+ return sorted(participants)
74
+
75
+
76
+ async def get_thread_content(client: NornWeaveClient, thread_id: str) -> str:
77
+ """Get thread content in Markdown format.
78
+
79
+ Resource URI: email://thread/{thread_id}
80
+
81
+ Args:
82
+ client: NornWeave API client.
83
+ thread_id: The thread ID.
84
+
85
+ Returns:
86
+ Markdown-formatted thread content optimized for LLM context.
87
+
88
+ Raises:
89
+ Exception: If thread not found or API error.
90
+ """
91
+ try:
92
+ thread = await client.get_thread(thread_id)
93
+ return format_thread_markdown(thread)
94
+
95
+ except httpx.HTTPStatusError as e:
96
+ if e.response.status_code == 404:
97
+ raise Exception(f"Thread '{thread_id}' not found") from e
98
+ raise Exception(f"API error: {e.response.status_code}") from e
99
+
100
+
101
+ def format_thread_markdown(thread: dict[str, Any]) -> str:
102
+ """Format thread data as Markdown for LLM context.
103
+
104
+ Args:
105
+ thread: Thread data with id, subject, and messages.
106
+
107
+ Returns:
108
+ Markdown-formatted thread content.
109
+ """
110
+ lines: list[str] = []
111
+
112
+ # Thread subject as heading
113
+ subject = thread.get("subject", "(no subject)")
114
+ lines.append(f"## Thread: {subject}")
115
+ lines.append("")
116
+
117
+ messages = thread.get("messages", [])
118
+ if not messages:
119
+ lines.append("*No messages in this thread.*")
120
+ return "\n".join(lines)
121
+
122
+ for i, message in enumerate(messages):
123
+ if i > 0:
124
+ lines.append("---")
125
+ lines.append("")
126
+
127
+ # Message metadata
128
+ author = message.get("author", "unknown")
129
+ timestamp = message.get("timestamp")
130
+ role = message.get("role", "user")
131
+
132
+ # Format date
133
+ date_str = _format_timestamp(timestamp) if timestamp else "unknown date"
134
+
135
+ # Role indicator for context
136
+ role_indicator = "→" if role == "assistant" else "←"
137
+
138
+ lines.append(f"**From:** {author} {role_indicator}")
139
+ lines.append(f"**Date:** {date_str}")
140
+ lines.append("")
141
+
142
+ # Message content
143
+ content = message.get("content", "")
144
+ if content:
145
+ lines.append(content)
146
+ else:
147
+ lines.append("*(empty message)*")
148
+
149
+ lines.append("")
150
+
151
+ return "\n".join(lines)
152
+
153
+
154
+ def _format_timestamp(timestamp: str) -> str:
155
+ """Format ISO timestamp to human-readable format."""
156
+ from datetime import datetime
157
+
158
+ try:
159
+ # Handle ISO format with timezone
160
+ if "T" in timestamp:
161
+ dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
162
+ return dt.strftime("%Y-%m-%d %H:%M")
163
+ return timestamp
164
+ except (ValueError, TypeError):
165
+ return str(timestamp)