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,390 @@
1
+ """Core abstractions: storage and email provider interfaces."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from nornweave.models.attachment import AttachmentDisposition, SendAttachment
9
+
10
+ if TYPE_CHECKING:
11
+ from nornweave.models.event import Event, EventType
12
+ from nornweave.models.inbox import Inbox
13
+ from nornweave.models.message import Message
14
+ from nornweave.models.thread import Thread
15
+
16
+
17
+ @dataclass
18
+ class InboundAttachment:
19
+ """
20
+ Attachment parsed from inbound email webhook.
21
+
22
+ Contains full content for storage and processing.
23
+ """
24
+
25
+ filename: str
26
+ content_type: str
27
+ content: bytes
28
+ size_bytes: int
29
+ disposition: AttachmentDisposition = AttachmentDisposition.ATTACHMENT
30
+ content_id: str | None = None
31
+ provider_id: str | None = None
32
+ provider_url: str | None = None
33
+
34
+ @classmethod
35
+ def from_base64(
36
+ cls,
37
+ filename: str,
38
+ content_type: str,
39
+ content_base64: str,
40
+ *,
41
+ disposition: AttachmentDisposition = AttachmentDisposition.ATTACHMENT,
42
+ content_id: str | None = None,
43
+ ) -> InboundAttachment:
44
+ """Create attachment from base64-encoded content."""
45
+ import base64
46
+
47
+ content = base64.b64decode(content_base64)
48
+ return cls(
49
+ filename=filename,
50
+ content_type=content_type,
51
+ content=content,
52
+ size_bytes=len(content),
53
+ disposition=disposition,
54
+ content_id=content_id,
55
+ )
56
+
57
+
58
+ @dataclass
59
+ class InboundMessage:
60
+ """
61
+ Standardized representation of an inbound email from a provider webhook.
62
+
63
+ Enhanced with full attachment support, threading headers, and verification results.
64
+ """
65
+
66
+ # Envelope data
67
+ from_address: str
68
+ to_address: str
69
+ subject: str
70
+
71
+ # Body content
72
+ body_plain: str
73
+ body_html: str | None = None
74
+ stripped_text: str | None = None
75
+ stripped_html: str | None = None
76
+
77
+ # Threading headers (RFC 5322)
78
+ message_id: str | None = None
79
+ in_reply_to: str | None = None
80
+ references: list[str] = field(default_factory=list)
81
+
82
+ # Metadata
83
+ headers: dict[str, str] = field(default_factory=dict)
84
+ timestamp: datetime = field(default_factory=datetime.utcnow)
85
+
86
+ # Full attachment support
87
+ attachments: list[InboundAttachment] = field(default_factory=list)
88
+ content_id_map: dict[str, str] = field(default_factory=dict)
89
+
90
+ # Provider verification (for audit/spam detection)
91
+ spf_result: str | None = None
92
+ dkim_result: str | None = None
93
+ dmarc_result: str | None = None
94
+
95
+ # CC/BCC support
96
+ cc_addresses: list[str] = field(default_factory=list)
97
+ bcc_addresses: list[str] = field(default_factory=list)
98
+
99
+ @property
100
+ def attachment_count(self) -> int:
101
+ """Get total number of attachments."""
102
+ return len(self.attachments)
103
+
104
+ @property
105
+ def inline_attachments(self) -> list[InboundAttachment]:
106
+ """Get only inline attachments (embedded images, etc.)."""
107
+ return [a for a in self.attachments if a.disposition == AttachmentDisposition.INLINE]
108
+
109
+ @property
110
+ def regular_attachments(self) -> list[InboundAttachment]:
111
+ """Get only regular file attachments."""
112
+ return [a for a in self.attachments if a.disposition == AttachmentDisposition.ATTACHMENT]
113
+
114
+ def get_attachment_by_content_id(self, content_id: str) -> InboundAttachment | None:
115
+ """Find attachment by Content-ID (for cid: URL resolution)."""
116
+ cid = content_id.strip("<>")
117
+ for attachment in self.attachments:
118
+ if attachment.content_id and attachment.content_id.strip("<>") == cid:
119
+ return attachment
120
+ return None
121
+
122
+ @property
123
+ def total_attachment_size(self) -> int:
124
+ """Get total size of all attachments in bytes."""
125
+ return sum(a.size_bytes for a in self.attachments)
126
+
127
+ def parse_references_string(self, references_str: str | None) -> list[str]:
128
+ """Parse space-separated References header into list."""
129
+ if not references_str:
130
+ return []
131
+ return [ref.strip() for ref in references_str.split() if ref.strip()]
132
+
133
+
134
+ class StorageInterface(ABC):
135
+ """Abstract storage layer (Urdr - The Well). Implementations: Postgres, SQLite."""
136
+
137
+ # -------------------------------------------------------------------------
138
+ # Inbox methods
139
+ # -------------------------------------------------------------------------
140
+ @abstractmethod
141
+ async def create_inbox(self, inbox: Inbox) -> Inbox:
142
+ """Create an inbox. Returns the created inbox with id set."""
143
+ ...
144
+
145
+ @abstractmethod
146
+ async def get_inbox(self, inbox_id: str) -> Inbox | None:
147
+ """Get an inbox by id."""
148
+ ...
149
+
150
+ @abstractmethod
151
+ async def get_inbox_by_email(self, email_address: str) -> Inbox | None:
152
+ """Get an inbox by email address."""
153
+ ...
154
+
155
+ @abstractmethod
156
+ async def delete_inbox(self, inbox_id: str) -> bool:
157
+ """Delete an inbox. Returns True if deleted."""
158
+ ...
159
+
160
+ @abstractmethod
161
+ async def list_inboxes(
162
+ self,
163
+ *,
164
+ limit: int = 50,
165
+ offset: int = 0,
166
+ ) -> list[Inbox]:
167
+ """List all inboxes."""
168
+ ...
169
+
170
+ # -------------------------------------------------------------------------
171
+ # Thread methods
172
+ # -------------------------------------------------------------------------
173
+ @abstractmethod
174
+ async def create_thread(self, thread: Thread) -> Thread:
175
+ """Create a thread."""
176
+ ...
177
+
178
+ @abstractmethod
179
+ async def get_thread(self, thread_id: str) -> Thread | None:
180
+ """Get a thread by id."""
181
+ ...
182
+
183
+ @abstractmethod
184
+ async def get_thread_by_participant_hash(
185
+ self,
186
+ inbox_id: str,
187
+ participant_hash: str,
188
+ ) -> Thread | None:
189
+ """Get a thread by inbox and participant hash (Phase 2 threading)."""
190
+ ...
191
+
192
+ @abstractmethod
193
+ async def update_thread(self, thread: Thread) -> Thread:
194
+ """Update a thread (e.g. last_message_at)."""
195
+ ...
196
+
197
+ @abstractmethod
198
+ async def list_threads_for_inbox(
199
+ self,
200
+ inbox_id: str,
201
+ *,
202
+ limit: int = 20,
203
+ offset: int = 0,
204
+ ) -> list[Thread]:
205
+ """List threads for an inbox, ordered by last_message_at DESC."""
206
+ ...
207
+
208
+ # -------------------------------------------------------------------------
209
+ # Message methods
210
+ # -------------------------------------------------------------------------
211
+ @abstractmethod
212
+ async def create_message(self, message: Message) -> Message:
213
+ """Create a message."""
214
+ ...
215
+
216
+ @abstractmethod
217
+ async def get_message(self, message_id: str) -> Message | None:
218
+ """Get a message by id."""
219
+ ...
220
+
221
+ @abstractmethod
222
+ async def list_messages_for_inbox(
223
+ self,
224
+ inbox_id: str,
225
+ *,
226
+ limit: int = 50,
227
+ offset: int = 0,
228
+ ) -> list[Message]:
229
+ """List messages for an inbox, ordered by created_at."""
230
+ ...
231
+
232
+ @abstractmethod
233
+ async def list_messages_for_thread(
234
+ self,
235
+ thread_id: str,
236
+ *,
237
+ limit: int = 100,
238
+ offset: int = 0,
239
+ ) -> list[Message]:
240
+ """List messages for a thread, ordered by created_at (conversation order)."""
241
+ ...
242
+
243
+ @abstractmethod
244
+ async def search_messages(
245
+ self,
246
+ inbox_id: str,
247
+ query: str,
248
+ *,
249
+ limit: int = 50,
250
+ offset: int = 0,
251
+ ) -> list[Message]:
252
+ """Search messages by content (ILIKE/LIKE on content_clean and content_raw)."""
253
+ ...
254
+
255
+ # -------------------------------------------------------------------------
256
+ # Event methods (Phase 3 webhooks)
257
+ # -------------------------------------------------------------------------
258
+ @abstractmethod
259
+ async def create_event(self, event: Event) -> Event:
260
+ """Create an event. Returns the created event with id set."""
261
+ ...
262
+
263
+ @abstractmethod
264
+ async def get_event(self, event_id: str) -> Event | None:
265
+ """Get an event by id."""
266
+ ...
267
+
268
+ @abstractmethod
269
+ async def list_events(
270
+ self,
271
+ *,
272
+ event_type: EventType | None = None,
273
+ limit: int = 50,
274
+ offset: int = 0,
275
+ ) -> list[Event]:
276
+ """List events, optionally filtered by type, ordered by created_at DESC."""
277
+ ...
278
+
279
+ # -------------------------------------------------------------------------
280
+ # Attachment methods
281
+ # -------------------------------------------------------------------------
282
+ @abstractmethod
283
+ async def create_attachment(
284
+ self,
285
+ message_id: str,
286
+ filename: str,
287
+ content_type: str,
288
+ size_bytes: int,
289
+ *,
290
+ disposition: str = "attachment",
291
+ content_id: str | None = None,
292
+ storage_path: str | None = None,
293
+ storage_backend: str | None = None,
294
+ ) -> str:
295
+ """Create attachment record. Returns attachment ID."""
296
+ ...
297
+
298
+ @abstractmethod
299
+ async def get_attachment(self, attachment_id: str) -> dict[str, Any] | None:
300
+ """Get attachment metadata by ID."""
301
+ ...
302
+
303
+ @abstractmethod
304
+ async def list_attachments_for_message(self, message_id: str) -> list[dict[str, Any]]:
305
+ """List attachments for a message."""
306
+ ...
307
+
308
+ @abstractmethod
309
+ async def delete_attachment(self, attachment_id: str) -> bool:
310
+ """Delete attachment. Returns True if deleted."""
311
+ ...
312
+
313
+ # -------------------------------------------------------------------------
314
+ # Thread lookup methods (for threading algorithm)
315
+ # -------------------------------------------------------------------------
316
+ @abstractmethod
317
+ async def get_message_by_provider_id(
318
+ self, inbox_id: str, provider_message_id: str
319
+ ) -> Message | None:
320
+ """Get message by provider Message-ID header for threading lookups."""
321
+ ...
322
+
323
+ @abstractmethod
324
+ async def get_thread_by_subject(
325
+ self,
326
+ inbox_id: str,
327
+ normalized_subject: str,
328
+ *,
329
+ since: datetime | None = None,
330
+ ) -> Thread | None:
331
+ """Get thread by normalized subject within time window (for subject-based threading)."""
332
+ ...
333
+
334
+
335
+ class EmailProvider(ABC):
336
+ """Abstract email provider (BYOP). Implementations: Mailgun, SES, SendGrid, Resend."""
337
+
338
+ @abstractmethod
339
+ async def send_email(
340
+ self,
341
+ to: list[str],
342
+ subject: str,
343
+ body: str,
344
+ *,
345
+ from_address: str,
346
+ reply_to: str | None = None,
347
+ headers: dict[str, str] | None = None,
348
+ # Threading headers for proper reply threading
349
+ message_id: str | None = None,
350
+ in_reply_to: str | None = None,
351
+ references: list[str] | None = None,
352
+ # CC/BCC support
353
+ cc: list[str] | None = None,
354
+ bcc: list[str] | None = None,
355
+ # Attachment support
356
+ attachments: list[SendAttachment] | None = None,
357
+ # HTML body (alternative to plain text)
358
+ html_body: str | None = None,
359
+ ) -> str:
360
+ """
361
+ Send an email with threading and attachment support.
362
+
363
+ Args:
364
+ to: List of recipient email addresses
365
+ subject: Email subject
366
+ body: Plain text body
367
+ from_address: Sender email address
368
+ reply_to: Reply-to address (optional)
369
+ headers: Custom headers (optional)
370
+ message_id: RFC 5322 Message-ID (generated if None)
371
+ in_reply_to: Parent Message-ID for replies
372
+ references: Chain of ancestor Message-IDs for threading
373
+ cc: Carbon copy recipients
374
+ bcc: Blind carbon copy recipients
375
+ attachments: List of attachments to include
376
+ html_body: HTML body (optional, alternative to plain text)
377
+
378
+ Returns:
379
+ Provider message ID
380
+ """
381
+ ...
382
+
383
+ @abstractmethod
384
+ def parse_inbound_webhook(self, payload: dict[str, Any]) -> InboundMessage:
385
+ """Parse provider webhook payload into a standardized InboundMessage."""
386
+ ...
387
+
388
+ async def setup_inbound_route(self, inbox_address: str) -> None: # noqa: B027
389
+ """Optional: configure provider to route inbound mail to our webhook."""
390
+ pass
@@ -0,0 +1,192 @@
1
+ """Attachment storage backend interface and factory.
2
+
3
+ NornWeave supports multiple storage backends for attachments:
4
+ - Local filesystem (default, good for development)
5
+ - AWS S3 (production)
6
+ - Google Cloud Storage (production)
7
+ - Database blob (simple deployment)
8
+
9
+ Configure via environment variables:
10
+ NORNWEAVE_ATTACHMENT_STORAGE_BACKEND=local|s3|gcs|database
11
+ """
12
+
13
+ import hashlib
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass
16
+ from datetime import timedelta
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from nornweave.core.config import Settings
21
+
22
+
23
+ @dataclass
24
+ class AttachmentMetadata:
25
+ """Metadata stored alongside attachment content."""
26
+
27
+ attachment_id: str
28
+ message_id: str
29
+ filename: str
30
+ content_type: str
31
+ content_disposition: str
32
+ content_id: str | None = None
33
+
34
+
35
+ @dataclass
36
+ class StorageResult:
37
+ """Result of storing an attachment."""
38
+
39
+ storage_key: str
40
+ size_bytes: int
41
+ content_hash: str
42
+ backend: str
43
+
44
+
45
+ class AttachmentStorageBackend(ABC):
46
+ """Abstract interface for attachment storage backends."""
47
+
48
+ @property
49
+ @abstractmethod
50
+ def backend_name(self) -> str:
51
+ """Return the name of this backend."""
52
+ ...
53
+
54
+ @abstractmethod
55
+ async def store(
56
+ self,
57
+ attachment_id: str,
58
+ content: bytes,
59
+ metadata: AttachmentMetadata,
60
+ ) -> StorageResult:
61
+ """
62
+ Store attachment content.
63
+
64
+ Args:
65
+ attachment_id: Unique attachment ID
66
+ content: Binary content to store
67
+ metadata: Attachment metadata
68
+
69
+ Returns:
70
+ StorageResult with storage key and metadata
71
+ """
72
+ ...
73
+
74
+ @abstractmethod
75
+ async def retrieve(self, storage_key: str) -> bytes:
76
+ """
77
+ Retrieve attachment content by storage key.
78
+
79
+ Args:
80
+ storage_key: Key returned from store()
81
+
82
+ Returns:
83
+ Binary content
84
+
85
+ Raises:
86
+ FileNotFoundError: If attachment not found
87
+ """
88
+ ...
89
+
90
+ @abstractmethod
91
+ async def delete(self, storage_key: str) -> bool:
92
+ """
93
+ Delete attachment.
94
+
95
+ Args:
96
+ storage_key: Key returned from store()
97
+
98
+ Returns:
99
+ True if deleted, False if not found
100
+ """
101
+ ...
102
+
103
+ @abstractmethod
104
+ async def get_download_url(
105
+ self,
106
+ storage_key: str,
107
+ expires_in: timedelta = timedelta(hours=1),
108
+ filename: str | None = None,
109
+ ) -> str:
110
+ """
111
+ Generate a download URL for the attachment.
112
+
113
+ Args:
114
+ storage_key: Key returned from store()
115
+ expires_in: How long the URL should be valid
116
+ filename: Optional filename for Content-Disposition header
117
+
118
+ Returns:
119
+ URL string (signed for cloud, API path for local/db)
120
+ """
121
+ ...
122
+
123
+ @abstractmethod
124
+ async def exists(self, storage_key: str) -> bool:
125
+ """
126
+ Check if attachment exists in storage.
127
+
128
+ Args:
129
+ storage_key: Key returned from store()
130
+
131
+ Returns:
132
+ True if exists
133
+ """
134
+ ...
135
+
136
+ @staticmethod
137
+ def compute_hash(content: bytes) -> str:
138
+ """Compute SHA-256 hash of content."""
139
+ return hashlib.sha256(content).hexdigest()
140
+
141
+
142
+ def create_attachment_storage(settings: Settings) -> AttachmentStorageBackend:
143
+ """
144
+ Factory function to create configured storage backend.
145
+
146
+ Args:
147
+ settings: Application settings
148
+
149
+ Returns:
150
+ Configured AttachmentStorageBackend instance
151
+
152
+ Raises:
153
+ ValueError: If unknown backend specified
154
+ """
155
+ from nornweave.storage import (
156
+ DatabaseBlobStorage,
157
+ GCSStorage,
158
+ LocalFilesystemStorage,
159
+ S3Storage,
160
+ )
161
+
162
+ backend = getattr(settings, "attachment_storage_backend", "local").lower()
163
+
164
+ if backend == "local":
165
+ return LocalFilesystemStorage(
166
+ base_path=getattr(settings, "attachment_local_path", "./data/attachments"),
167
+ serve_url_prefix=getattr(settings, "attachment_serve_url_prefix", "/v1/attachments"),
168
+ )
169
+ elif backend == "s3":
170
+ bucket = getattr(settings, "attachment_s3_bucket", None)
171
+ if not bucket:
172
+ raise ValueError("ATTACHMENT_S3_BUCKET required for S3 backend")
173
+ return S3Storage(
174
+ bucket=bucket,
175
+ prefix=getattr(settings, "attachment_s3_prefix", "attachments"),
176
+ region=getattr(settings, "attachment_s3_region", "us-east-1"),
177
+ access_key=getattr(settings, "attachment_s3_access_key", None),
178
+ secret_key=getattr(settings, "attachment_s3_secret_key", None),
179
+ )
180
+ elif backend == "gcs":
181
+ bucket = getattr(settings, "attachment_gcs_bucket", None)
182
+ if not bucket:
183
+ raise ValueError("ATTACHMENT_GCS_BUCKET required for GCS backend")
184
+ return GCSStorage(
185
+ bucket=bucket,
186
+ prefix=getattr(settings, "attachment_gcs_prefix", "attachments"),
187
+ credentials_path=getattr(settings, "attachment_gcs_credentials_path", None),
188
+ )
189
+ elif backend == "database":
190
+ return DatabaseBlobStorage()
191
+ else:
192
+ raise ValueError(f"Unknown storage backend: {backend}")
@@ -0,0 +1,23 @@
1
+ """Shared utilities."""
2
+
3
+ import hashlib
4
+ import re
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Sequence
9
+
10
+
11
+ def slugify(s: str, max_length: int = 64) -> str:
12
+ """Convert string to a safe slug (lowercase, alphanumeric and hyphens)."""
13
+ s = s.lower().strip()
14
+ s = re.sub(r"[^\w\s-]", "", s)
15
+ s = re.sub(r"[-\s]+", "-", s)
16
+ return s[:max_length].strip("-")
17
+
18
+
19
+ def participant_hash(addresses: Sequence[str]) -> str:
20
+ """Stable hash of participant addresses for thread grouping."""
21
+ normalized = sorted(a.lower().strip() for a in addresses if a)
22
+ combined = "|".join(normalized)
23
+ return hashlib.sha256(combined.encode()).hexdigest()[:16]
@@ -0,0 +1,10 @@
1
+ """Huginn: MCP read tools (Thought).
2
+
3
+ This module provides MCP resources (read-only data access) for AI agents.
4
+ Named after Odin's raven of thought.
5
+ """
6
+
7
+ from nornweave.huginn.client import NornWeaveClient
8
+ from nornweave.huginn.config import MCPSettings, get_mcp_settings
9
+
10
+ __all__ = ["MCPSettings", "NornWeaveClient", "get_mcp_settings"]