muninn-python 0.1.0__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.
muninn/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """MuninnDB Python SDK - Async client for cognitive memory database."""
2
+
3
+ from .client import MuninnClient
4
+ from .errors import (
5
+ MuninnAuthError,
6
+ MuninnConflict,
7
+ MuninnConnectionError,
8
+ MuninnError,
9
+ MuninnNotFound,
10
+ MuninnServerError,
11
+ MuninnTimeoutError,
12
+ )
13
+ from .types import (
14
+ ActivateRequest,
15
+ ActivateResponse,
16
+ ActivationItem,
17
+ BriefSentence,
18
+ CoherenceResult,
19
+ Push,
20
+ ReadResponse,
21
+ StatResponse,
22
+ WriteRequest,
23
+ WriteResponse,
24
+ )
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ # LangChain integration — only imported if langchain-core is installed.
29
+ # Usage: from muninn.langchain import MuninnDBMemory
30
+ def __getattr__(name: str):
31
+ if name == "MuninnDBMemory":
32
+ from .langchain import MuninnDBMemory
33
+ return MuninnDBMemory
34
+ raise AttributeError(f"module 'muninn' has no attribute {name!r}")
35
+
36
+ __all__ = [
37
+ "MuninnClient",
38
+ "MuninnError",
39
+ "MuninnAuthError",
40
+ "MuninnConnectionError",
41
+ "MuninnNotFound",
42
+ "MuninnConflict",
43
+ "MuninnServerError",
44
+ "MuninnTimeoutError",
45
+ "WriteRequest",
46
+ "WriteResponse",
47
+ "ActivateRequest",
48
+ "ActivateResponse",
49
+ "ActivationItem",
50
+ "BriefSentence",
51
+ "ReadResponse",
52
+ "StatResponse",
53
+ "CoherenceResult",
54
+ "Push",
55
+ # Optional (requires langchain-core):
56
+ "MuninnDBMemory",
57
+ ]
muninn/client.py ADDED
@@ -0,0 +1,499 @@
1
+ """Async MuninnDB client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import random
8
+
9
+ import httpx
10
+
11
+ from .errors import (
12
+ MuninnAuthError,
13
+ MuninnConnectionError,
14
+ MuninnConflict,
15
+ MuninnError,
16
+ MuninnNotFound,
17
+ MuninnServerError,
18
+ MuninnTimeoutError,
19
+ )
20
+ from .sse import SSEStream
21
+ from .types import (
22
+ ActivateResponse,
23
+ ActivationItem,
24
+ BriefSentence,
25
+ CoherenceResult,
26
+ ReadResponse,
27
+ StatResponse,
28
+ WriteResponse,
29
+ )
30
+
31
+
32
+ class MuninnClient:
33
+ """Async client for MuninnDB REST API.
34
+
35
+ The client uses httpx for async HTTP and supports automatic retry with
36
+ exponential backoff for transient failures.
37
+
38
+ Usage:
39
+ async with MuninnClient("http://localhost:8476") as client:
40
+ eng_id = await client.write(
41
+ vault="default",
42
+ concept="memory concept",
43
+ content="memory content"
44
+ )
45
+ results = await client.activate(
46
+ vault="default",
47
+ context=["search query"]
48
+ )
49
+ async for push in client.subscribe(vault="default"):
50
+ print(f"New engram: {push.engram_id}")
51
+ break
52
+
53
+ Args:
54
+ base_url: Base URL of MuninnDB server (default: http://localhost:8476)
55
+ token: Optional Bearer token for authentication
56
+ timeout: Request timeout in seconds (default: 5.0)
57
+ max_retries: Maximum retry attempts for transient errors (default: 3)
58
+ retry_backoff: Initial backoff multiplier for retries (default: 0.5)
59
+ max_connections: Max concurrent connections (default: 20)
60
+ keepalive_connections: Max keepalive connections (default: 10)
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ base_url: str = "http://localhost:8476",
66
+ token: str | None = None,
67
+ timeout: float = 5.0,
68
+ max_retries: int = 3,
69
+ retry_backoff: float = 0.5,
70
+ max_connections: int = 20,
71
+ keepalive_connections: int = 10,
72
+ ):
73
+ self._base_url = base_url.rstrip("/")
74
+ self._token = token
75
+ self._timeout = timeout
76
+ self._max_retries = max_retries
77
+ self._retry_backoff = retry_backoff
78
+ self._max_connections = max_connections
79
+ self._keepalive_connections = keepalive_connections
80
+ self._http: httpx.AsyncClient | None = None
81
+
82
+ async def __aenter__(self):
83
+ """Enter async context."""
84
+ self._http = httpx.AsyncClient(
85
+ base_url=self._base_url,
86
+ timeout=self._timeout,
87
+ limits=httpx.Limits(
88
+ max_connections=self._max_connections,
89
+ max_keepalive_connections=self._keepalive_connections,
90
+ ),
91
+ headers=self._default_headers(),
92
+ )
93
+ return self
94
+
95
+ async def __aexit__(self, *args):
96
+ """Exit async context."""
97
+ if self._http:
98
+ await self._http.aclose()
99
+
100
+ async def write(
101
+ self,
102
+ vault: str = "default",
103
+ concept: str = "",
104
+ content: str = "",
105
+ tags: list[str] | None = None,
106
+ confidence: float = 0.9,
107
+ stability: float = 0.5,
108
+ ) -> str:
109
+ """Write an engram to the database.
110
+
111
+ Args:
112
+ vault: Vault name (default: "default")
113
+ concept: Concept/title for this engram
114
+ content: Main content/body
115
+ tags: Optional list of tags for categorization
116
+ confidence: Confidence score 0-1 (default: 0.9)
117
+ stability: Stability score 0-1 (default: 0.5)
118
+
119
+ Returns:
120
+ ULID string ID of the created engram
121
+
122
+ Raises:
123
+ MuninnError: If write fails
124
+ """
125
+ body = {
126
+ "vault": vault,
127
+ "concept": concept,
128
+ "content": content,
129
+ "confidence": confidence,
130
+ "stability": stability,
131
+ }
132
+ if tags:
133
+ body["tags"] = tags
134
+
135
+ response = await self._request("POST", "/api/engrams", json=body)
136
+ return response.get("id", "")
137
+
138
+ async def activate(
139
+ self,
140
+ vault: str = "default",
141
+ context: list[str] | None = None,
142
+ max_results: int = 10,
143
+ threshold: float = 0.1,
144
+ max_hops: int = 0,
145
+ include_why: bool = False,
146
+ brief_mode: str = "auto",
147
+ ) -> ActivateResponse:
148
+ """Activate memory using semantic search and graph traversal.
149
+
150
+ Args:
151
+ vault: Vault name (default: "default")
152
+ context: List of query terms/context
153
+ max_results: Max results to return (default: 10)
154
+ threshold: Min activation score threshold (default: 0.1)
155
+ max_hops: Max graph hops to traverse (default: 0)
156
+ include_why: Include reasoning/why field (default: False)
157
+ brief_mode: Brief extraction mode - "auto", "extractive", "abstractive" (default: "auto")
158
+
159
+ Returns:
160
+ ActivateResponse with activations and optional brief
161
+
162
+ Raises:
163
+ MuninnError: If activation fails
164
+ """
165
+ if context is None:
166
+ context = []
167
+
168
+ body = {
169
+ "vault": vault,
170
+ "context": context,
171
+ "max_results": max_results,
172
+ "threshold": threshold,
173
+ "max_hops": max_hops,
174
+ "include_why": include_why,
175
+ "brief_mode": brief_mode,
176
+ }
177
+
178
+ response = await self._request("POST", "/api/activate", json=body)
179
+
180
+ # Support both snake_case (new) and PascalCase (old) field names for
181
+ # backward compatibility with servers that have not yet been updated.
182
+ raw_activations = response.get("activations") or response.get("Activations") or []
183
+ activations = [
184
+ ActivationItem(
185
+ id=item.get("id") or item.get("ID", ""),
186
+ concept=item.get("concept") or item.get("Concept", ""),
187
+ content=item.get("content") or item.get("Content", ""),
188
+ score=item.get("score") or item.get("Score", 0.0),
189
+ confidence=item.get("confidence") or item.get("Confidence", 0.0),
190
+ why=item.get("why") or item.get("Why"),
191
+ hop_path=item.get("hop_path") or item.get("HopPath"),
192
+ dormant=item.get("dormant") or item.get("Dormant", False),
193
+ )
194
+ for item in raw_activations
195
+ ]
196
+
197
+ brief = None
198
+ raw_brief = response.get("brief") or response.get("Brief")
199
+ if raw_brief:
200
+ brief = [
201
+ BriefSentence(
202
+ engram_id=sent.get("engram_id") or sent.get("EngramID", ""),
203
+ text=sent.get("text") or sent.get("Text", ""),
204
+ score=sent.get("score") or sent.get("Score", 0.0),
205
+ )
206
+ for sent in raw_brief
207
+ ]
208
+
209
+ return ActivateResponse(
210
+ query_id=response.get("query_id") or response.get("QueryID", ""),
211
+ total_found=response.get("total_found") or response.get("TotalFound", 0),
212
+ activations=activations,
213
+ latency_ms=response.get("latency_ms") or response.get("LatencyMs", 0.0),
214
+ brief=brief,
215
+ )
216
+
217
+ async def read(self, id: str, vault: str = "default") -> ReadResponse:
218
+ """Read a specific engram by ID.
219
+
220
+ Args:
221
+ id: Engram ULID
222
+ vault: Vault name (default: "default")
223
+
224
+ Returns:
225
+ ReadResponse with engram details
226
+
227
+ Raises:
228
+ MuninnNotFound: If engram doesn't exist
229
+ MuninnError: If read fails
230
+ """
231
+ response = await self._request("GET", f"/api/engrams/{id}", params={"vault": vault})
232
+
233
+ coherence = response.get("coherence")
234
+ return ReadResponse(
235
+ id=response.get("id", ""),
236
+ concept=response.get("concept", ""),
237
+ content=response.get("content", ""),
238
+ confidence=response.get("confidence", 0.0),
239
+ relevance=response.get("relevance", 0.0),
240
+ stability=response.get("stability", 0.0),
241
+ access_count=response.get("access_count", 0),
242
+ tags=response.get("tags", []),
243
+ state=response.get("state", ""),
244
+ created_at=response.get("created_at", 0),
245
+ updated_at=response.get("updated_at", 0),
246
+ last_access=response.get("last_access"),
247
+ )
248
+
249
+ async def forget(self, id: str, vault: str = "default", hard: bool = False) -> bool:
250
+ """Delete an engram (soft or hard delete).
251
+
252
+ Args:
253
+ id: Engram ULID
254
+ vault: Vault name (default: "default")
255
+ hard: If True, hard delete (cannot recover). If False, soft delete (default: False)
256
+
257
+ Returns:
258
+ True if deletion successful
259
+
260
+ Raises:
261
+ MuninnNotFound: If engram doesn't exist
262
+ MuninnError: If deletion fails
263
+ """
264
+ if hard:
265
+ await self._request(
266
+ "POST",
267
+ f"/api/engrams/{id}/forget",
268
+ params={"vault": vault, "hard": "true"},
269
+ )
270
+ else:
271
+ await self._request(
272
+ "DELETE",
273
+ f"/api/engrams/{id}",
274
+ params={"vault": vault},
275
+ )
276
+ return True
277
+
278
+ async def link(
279
+ self,
280
+ source_id: str,
281
+ target_id: str,
282
+ vault: str = "default",
283
+ rel_type: int = 5,
284
+ weight: float = 1.0,
285
+ ) -> bool:
286
+ """Create an association/link between two engrams.
287
+
288
+ Args:
289
+ source_id: Source engram ULID
290
+ target_id: Target engram ULID
291
+ vault: Vault name (default: "default")
292
+ rel_type: Relationship type code (default: 5)
293
+ weight: Link weight/strength (default: 1.0)
294
+
295
+ Returns:
296
+ True if link created successfully
297
+
298
+ Raises:
299
+ MuninnError: If link creation fails
300
+ """
301
+ body = {
302
+ "vault": vault,
303
+ "source_id": source_id,
304
+ "target_id": target_id,
305
+ "rel_type": rel_type,
306
+ "weight": weight,
307
+ }
308
+ await self._request("POST", "/api/link", json=body)
309
+ return True
310
+
311
+ async def stats(self) -> StatResponse:
312
+ """Get database statistics including coherence scores.
313
+
314
+ Returns:
315
+ StatResponse with engram count, vault count, storage bytes, and coherence
316
+
317
+ Raises:
318
+ MuninnError: If stats request fails
319
+ """
320
+ response = await self._request("GET", "/api/stats")
321
+
322
+ coherence = None
323
+ if response.get("coherence"):
324
+ coherence = {
325
+ vault_name: CoherenceResult(
326
+ score=data.get("score", 0.0),
327
+ orphan_ratio=data.get("orphan_ratio", 0.0),
328
+ contradiction_density=data.get("contradiction_density", 0.0),
329
+ duplication_pressure=data.get("duplication_pressure", 0.0),
330
+ decay_variance=data.get("decay_variance", 0.0),
331
+ total_engrams=data.get("total_engrams", 0),
332
+ )
333
+ for vault_name, data in response["coherence"].items()
334
+ }
335
+
336
+ return StatResponse(
337
+ engram_count=response.get("engram_count", 0),
338
+ vault_count=response.get("vault_count", 0),
339
+ storage_bytes=response.get("storage_bytes", 0),
340
+ coherence=coherence,
341
+ )
342
+
343
+ def subscribe(
344
+ self,
345
+ vault: str = "default",
346
+ push_on_write: bool = True,
347
+ threshold: float = 0.0,
348
+ ) -> SSEStream:
349
+ """Subscribe to vault events via Server-Sent Events (SSE).
350
+
351
+ This returns an async iterable that yields Push events when engrams are
352
+ written to the vault. The stream automatically reconnects on network errors.
353
+
354
+ Usage:
355
+ stream = client.subscribe(vault="default")
356
+ async for push in stream:
357
+ print(f"New engram: {push.engram_id}")
358
+ if condition:
359
+ await stream.close()
360
+
361
+ Args:
362
+ vault: Vault to subscribe to (default: "default")
363
+ push_on_write: Emit push events on new writes (default: True)
364
+ threshold: Min activation threshold for push events (default: 0.0)
365
+
366
+ Returns:
367
+ SSEStream async iterable
368
+
369
+ Raises:
370
+ MuninnError: If subscription fails
371
+ """
372
+ params = {
373
+ "vault": vault,
374
+ "push_on_write": str(push_on_write).lower(),
375
+ }
376
+ if threshold:
377
+ params["threshold"] = str(threshold)
378
+
379
+ return SSEStream(self, "/api/subscribe", params)
380
+
381
+ async def health(self) -> bool:
382
+ """Check if MuninnDB server is healthy.
383
+
384
+ Returns:
385
+ True if server responds with 200 OK
386
+
387
+ Raises:
388
+ MuninnError: If health check fails
389
+ """
390
+ try:
391
+ response = await self._request("GET", "/health")
392
+ return response.get("status") == "ok"
393
+ except MuninnError:
394
+ return False
395
+
396
+ async def _request(self, method: str, path: str, **kwargs) -> dict:
397
+ """Make an HTTP request with automatic retry logic.
398
+
399
+ Retries on transient errors (502, 503, 504, connection/read errors).
400
+ Does not retry on 4xx errors. Uses exponential backoff with jitter.
401
+
402
+ Args:
403
+ method: HTTP method (GET, POST, DELETE, etc)
404
+ path: URL path relative to base_url
405
+ **kwargs: Additional arguments to pass to httpx
406
+
407
+ Returns:
408
+ Parsed JSON response as dict
409
+
410
+ Raises:
411
+ MuninnAuthError: 401 Unauthorized
412
+ MuninnNotFound: 404 Not Found
413
+ MuninnConflict: 409 Conflict
414
+ MuninnServerError: 5xx errors
415
+ MuninnTimeoutError: Request timeout
416
+ MuninnConnectionError: Connection error
417
+ MuninnError: Other HTTP errors
418
+ """
419
+ if not self._http:
420
+ raise MuninnError("Client not initialized. Use 'async with' context manager.")
421
+
422
+ attempt = 0
423
+ while attempt <= self._max_retries:
424
+ try:
425
+ response = await self._http.request(method, path, **kwargs)
426
+ self._raise_for_status(response)
427
+ return response.json()
428
+
429
+ except (httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError) as e:
430
+ if attempt >= self._max_retries:
431
+ raise MuninnConnectionError(f"Connection failed: {str(e)}")
432
+ await self._backoff(attempt)
433
+ attempt += 1
434
+
435
+ except httpx.ReadTimeout as e:
436
+ if attempt >= self._max_retries:
437
+ raise MuninnTimeoutError(f"Request timeout: {str(e)}")
438
+ await self._backoff(attempt)
439
+ attempt += 1
440
+
441
+ except httpx.HTTPStatusError as e:
442
+ # Don't retry on 4xx (except certain ones), do retry on 5xx
443
+ if 500 <= e.response.status_code < 600:
444
+ if attempt >= self._max_retries:
445
+ self._raise_for_status(e.response)
446
+ await self._backoff(attempt)
447
+ attempt += 1
448
+ else:
449
+ self._raise_for_status(e.response)
450
+
451
+ except MuninnError:
452
+ raise
453
+
454
+ raise MuninnError("Max retries exceeded")
455
+
456
+ async def _backoff(self, attempt: int):
457
+ """Wait with exponential backoff + jitter.
458
+
459
+ Args:
460
+ attempt: Attempt number (0-indexed)
461
+ """
462
+ delay = self._retry_backoff * (2 ** attempt) + random.uniform(0, 0.1)
463
+ await asyncio.sleep(delay)
464
+
465
+ def _default_headers(self) -> dict:
466
+ """Build default request headers."""
467
+ headers = {"Content-Type": "application/json"}
468
+ if self._token:
469
+ headers["Authorization"] = f"Bearer {self._token}"
470
+ return headers
471
+
472
+ def _raise_for_status(self, response: httpx.Response):
473
+ """Convert httpx response to appropriate MuninnError.
474
+
475
+ Args:
476
+ response: httpx Response object
477
+
478
+ Raises:
479
+ Appropriate MuninnError subclass
480
+ """
481
+ if response.status_code == 401:
482
+ raise MuninnAuthError(
483
+ "Authentication required. Provide token= parameter to MuninnClient.",
484
+ 401,
485
+ )
486
+ elif response.status_code == 404:
487
+ raise MuninnNotFound(f"Not found: {response.text}", 404)
488
+ elif response.status_code == 409:
489
+ raise MuninnConflict(f"Conflict: {response.text}", 409)
490
+ elif 500 <= response.status_code < 600:
491
+ raise MuninnServerError(
492
+ f"Server error {response.status_code}: {response.text}",
493
+ response.status_code,
494
+ )
495
+ elif response.status_code >= 400:
496
+ raise MuninnError(
497
+ f"Client error {response.status_code}: {response.text}",
498
+ response.status_code,
499
+ )
muninn/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ """MuninnDB error types."""
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class MuninnError(Exception):
7
+ """Base exception for all MuninnDB errors."""
8
+
9
+ def __init__(self, message: str, status_code: Optional[int] = None):
10
+ super().__init__(message)
11
+ self.status_code = status_code
12
+
13
+
14
+ class MuninnConnectionError(MuninnError):
15
+ """Connection-related errors (network, SSL, DNS)."""
16
+
17
+ pass
18
+
19
+
20
+ class MuninnAuthError(MuninnError):
21
+ """Authentication failed (401 Unauthorized)."""
22
+
23
+ pass
24
+
25
+
26
+ class MuninnNotFound(MuninnError):
27
+ """Resource not found (404 Not Found)."""
28
+
29
+ pass
30
+
31
+
32
+ class MuninnConflict(MuninnError):
33
+ """Request conflict (409 Conflict)."""
34
+
35
+ pass
36
+
37
+
38
+ class MuninnServerError(MuninnError):
39
+ """Server error (5xx)."""
40
+
41
+ pass
42
+
43
+
44
+ class MuninnTimeoutError(MuninnError):
45
+ """Request timeout."""
46
+
47
+ pass