muninn-python 0.1.0__tar.gz → 0.2.4__tar.gz

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.
@@ -0,0 +1,73 @@
1
+ # Compiled binaries
2
+ /muninn
3
+ /muninndb
4
+ /muninndb-server
5
+ /bench
6
+ /bench-test
7
+ /eval
8
+ /diag
9
+ /muninndb-test
10
+
11
+ # Embed assets — downloaded via `make fetch-assets`, not committed (too large)
12
+ internal/plugin/embed/assets/*.dylib
13
+ internal/plugin/embed/assets/*.so
14
+ internal/plugin/embed/assets/*.dll
15
+ internal/plugin/embed/assets/*.onnx
16
+ internal/plugin/embed/assets/tokenizer.json
17
+
18
+ # Binary artifacts — never commit
19
+ *.muninn
20
+ *.dylib
21
+ *.so
22
+ *.dll
23
+ *.onnx
24
+ *.exe
25
+
26
+ # Local data directories
27
+ muninndb-data/
28
+ **/muninndb-data/
29
+ /tmp/
30
+
31
+ # Dependencies — install locally, never commit
32
+ **/node_modules/
33
+ **/.venv/
34
+ **/venv/
35
+ **/__pycache__/
36
+ *.pyc
37
+ *.bak
38
+
39
+ # macOS
40
+ .DS_Store
41
+
42
+ # Go test cache
43
+ *.test
44
+ *.out
45
+
46
+ # Worktrees
47
+ .worktrees/
48
+
49
+ # Bible eval testdata (downloaded via scripts/eval-bible-setup.sh)
50
+ testdata/bible/kjv.json
51
+ testdata/bible/cross-refs.tsv
52
+ testdata/bible/cross-refs.csv
53
+ testdata/bible/*.muninn
54
+
55
+ # Planning and design docs — not committed
56
+ docs/plans/
57
+
58
+ # Evaluation tools and results — internal development only
59
+ cmd/eval*/
60
+ eval-results/
61
+ scripts/eval-*
62
+ /eval-bible
63
+ /eval-temporal
64
+ /eval-expert
65
+ /eval-paraphrase
66
+ /eval
67
+ sdk/node/node_modules/
68
+ sdk/node/dist/
69
+ sdk/python/dist/
70
+ sdk/python/*.egg-info/
71
+ sdk/muninndb/dist/
72
+ sdk/muninndb/*.egg-info/
73
+ sdk/php/vendor/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: muninn-python
3
- Version: 0.1.0
3
+ Version: 0.2.4
4
4
  Summary: Python SDK for MuninnDB — the cognitive memory database
5
5
  Project-URL: Homepage, https://muninndb.com
6
6
  Project-URL: Repository, https://github.com/scrypster/muninndb
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "muninn-python"
7
- version = "0.1.0"
7
+ version = "0.2.4"
8
8
  description = "Python SDK for MuninnDB — the cognitive memory database"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -1,17 +0,0 @@
1
- # Binaries (root-level only, not cmd/muninndb/ directory)
2
- /muninndb
3
- /muninndb-server
4
-
5
- # Data directories
6
- muninndb-data/
7
- /tmp/
8
-
9
- # macOS
10
- .DS_Store
11
-
12
- # Go test cache
13
- *.test
14
- *.out
15
-
16
- # Worktrees
17
- .worktrees/
@@ -1,57 +0,0 @@
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
- ]
@@ -1,499 +0,0 @@
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
- )
@@ -1,47 +0,0 @@
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
@@ -1,184 +0,0 @@
1
- """LangChain memory integration for MuninnDB.
2
-
3
- Provides MuninnDBMemory, a drop-in replacement for any LangChain memory backend.
4
- Unlike traditional backends (ConversationBufferMemory, etc.), MuninnDB applies
5
- cognitive primitives to retrieved context: relevance decays over time, frequently
6
- recalled memories strengthen, and associations form automatically from co-activation.
7
-
8
- Install:
9
- pip install muninn-python[langchain]
10
-
11
- Usage:
12
- from muninn.langchain import MuninnDBMemory
13
- from langchain.chains import ConversationChain
14
- from langchain_anthropic import ChatAnthropic
15
-
16
- memory = MuninnDBMemory(vault="my-agent")
17
- chain = ConversationChain(llm=ChatAnthropic(model="claude-haiku-4-5-20251001"), memory=memory)
18
- chain.predict(input="What did we discuss about the payment service?")
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- import asyncio
24
- import concurrent.futures
25
- import textwrap
26
- from typing import Any, Dict, List, Optional
27
-
28
- try:
29
- from langchain_core.memory import BaseMemory
30
- except ImportError:
31
- try:
32
- from langchain.memory import BaseMemory # type: ignore[no-redef]
33
- except ImportError as e:
34
- raise ImportError(
35
- "LangChain is required for MuninnDBMemory. "
36
- "Install it with: pip install muninn-python[langchain]"
37
- ) from e
38
-
39
- from .client import MuninnClient
40
- from .types import ActivationItem
41
-
42
-
43
- def _run_sync(coro: Any) -> Any:
44
- """Run an async coroutine synchronously.
45
-
46
- Handles both contexts where there is no event loop (plain scripts, pytest)
47
- and contexts where one is already running (FastAPI, Jupyter, async test runners).
48
- """
49
- try:
50
- asyncio.get_running_loop()
51
- # Already inside an event loop — run in a fresh thread with its own loop.
52
- with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
53
- return pool.submit(asyncio.run, coro).result()
54
- except RuntimeError:
55
- # No running event loop — safe to call asyncio.run() directly.
56
- return asyncio.run(coro)
57
-
58
-
59
- class MuninnDBMemory(BaseMemory):
60
- """LangChain memory backend powered by MuninnDB.
61
-
62
- Each conversation turn is stored as a single engram (human input + AI output).
63
- On every load, MuninnDB activates the most relevant memories for the current
64
- input using semantic similarity, Hebbian association weights, and decay curves —
65
- returning only what is genuinely relevant right now, not a raw chat buffer.
66
-
67
- Attributes:
68
- base_url: MuninnDB server URL (default: http://localhost:8475)
69
- token: Optional Bearer token if MCP auth is enabled
70
- vault: Vault name to store memories in (default: "default")
71
- max_results: Max memories to surface per activation (default: 10)
72
- memory_key: Key injected into chain inputs (default: "history")
73
- input_key: Input dict key holding the human message. Auto-detected
74
- if None (looks for "input", "question", "human_input", etc.)
75
- human_prefix: Prefix for human turns in stored engrams (default: "Human")
76
- ai_prefix: Prefix for AI turns in stored engrams (default: "AI")
77
- return_docs: If True, return ActivationItem objects instead of a string.
78
- Useful when you want to inspect scores or metadata.
79
- """
80
-
81
- base_url: str = "http://localhost:8475"
82
- token: Optional[str] = None
83
- vault: str = "default"
84
- max_results: int = 10
85
- memory_key: str = "history"
86
- input_key: Optional[str] = None
87
- human_prefix: str = "Human"
88
- ai_prefix: str = "AI"
89
- return_docs: bool = False
90
-
91
- class Config:
92
- arbitrary_types_allowed = True
93
-
94
- # ── Public LangChain interface ───────────────────────────────────────────
95
-
96
- @property
97
- def memory_variables(self) -> List[str]:
98
- return [self.memory_key]
99
-
100
- def load_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
101
- """Retrieve relevant memories for the current input (synchronous)."""
102
- return _run_sync(self.aload_memory_variables(inputs))
103
-
104
- async def aload_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
105
- """Retrieve relevant memories for the current input (async)."""
106
- query = self._extract_input(inputs)
107
- if not query:
108
- return {self.memory_key: [] if self.return_docs else ""}
109
-
110
- async with MuninnClient(self.base_url, token=self.token) as client:
111
- result = await client.activate(
112
- vault=self.vault,
113
- context=[query],
114
- max_results=self.max_results,
115
- threshold=0.05,
116
- )
117
-
118
- if self.return_docs:
119
- return {self.memory_key: result.activations}
120
-
121
- return {self.memory_key: self._format_activations(result.activations)}
122
-
123
- def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, Any]) -> None:
124
- """Store the current conversation turn (synchronous)."""
125
- _run_sync(self.asave_context(inputs, outputs))
126
-
127
- async def asave_context(
128
- self, inputs: Dict[str, Any], outputs: Dict[str, Any]
129
- ) -> None:
130
- """Store the current conversation turn (async)."""
131
- human_input = self._extract_input(inputs) or ""
132
- ai_output = self._extract_output(outputs) or ""
133
-
134
- # Concept = first 60 chars of the human turn (readable in the Web UI).
135
- concept = (human_input[:57] + "...") if len(human_input) > 60 else human_input
136
- content = f"{self.human_prefix}: {human_input}\n{self.ai_prefix}: {ai_output}"
137
-
138
- async with MuninnClient(self.base_url, token=self.token) as client:
139
- await client.write(vault=self.vault, concept=concept, content=content)
140
-
141
- def clear(self) -> None:
142
- """No-op: MuninnDB uses natural decay rather than explicit truncation.
143
-
144
- Memories fade on their own as they stop being recalled. If you need
145
- a hard reset, create a new vault or use a different vault name per session.
146
- """
147
-
148
- # ── Internal helpers ────────────────────────────────────────────────────
149
-
150
- def _extract_input(self, inputs: Dict[str, Any]) -> Optional[str]:
151
- """Extract the human message from the chain's input dict."""
152
- if self.input_key:
153
- return str(inputs.get(self.input_key, ""))
154
- for key in ("input", "question", "human_input", "query", "message", "text"):
155
- if key in inputs:
156
- return str(inputs[key])
157
- # Fall back to first string value.
158
- for v in inputs.values():
159
- if isinstance(v, str):
160
- return v
161
- return None
162
-
163
- def _extract_output(self, outputs: Dict[str, Any]) -> Optional[str]:
164
- """Extract the AI response from the chain's output dict."""
165
- for key in ("output", "response", "answer", "text", "result", "generation"):
166
- if key in outputs:
167
- return str(outputs[key])
168
- for v in outputs.values():
169
- if isinstance(v, str):
170
- return v
171
- return None
172
-
173
- def _format_activations(self, activations: List[ActivationItem]) -> str:
174
- """Format activated memories as a context string for the LLM prompt."""
175
- if not activations:
176
- return ""
177
-
178
- lines = ["[Relevant memory context from MuninnDB]"]
179
- for item in activations:
180
- # Wrap long content so it's readable in prompts.
181
- wrapped = textwrap.fill(item.content, width=120, subsequent_indent=" ")
182
- lines.append(f"- {wrapped}")
183
- lines.append("[End of memory context]")
184
- return "\n".join(lines)
@@ -1,122 +0,0 @@
1
- """Async SSE (Server-Sent Events) stream implementation with auto-reconnect."""
2
-
3
- import asyncio
4
- import json
5
-
6
- import httpx
7
-
8
- from .errors import MuninnConnectionError
9
- from .types import Push
10
-
11
-
12
- class SSEStream:
13
- """Async SSE stream with automatic reconnection and Last-Event-ID tracking.
14
-
15
- Usage:
16
- stream = client.subscribe(vault="default")
17
- async for push in stream:
18
- print(push.engram_id)
19
- if should_stop:
20
- await stream.close()
21
- """
22
-
23
- def __init__(self, client_ref, url: str, params: dict):
24
- self._client = client_ref
25
- self._url = url
26
- self._params = params
27
- self._last_event_id: str | None = None
28
- self._closed = False
29
-
30
- async def close(self):
31
- """Close the SSE stream."""
32
- self._closed = True
33
-
34
- def __aiter__(self):
35
- """Iterate over SSE events."""
36
- return self._stream()
37
-
38
- async def _stream(self):
39
- """Internal stream loop with automatic reconnection."""
40
- backoff = 0.5
41
- while not self._closed:
42
- try:
43
- headers = {"Accept": "text/event-stream"}
44
- if self._last_event_id:
45
- headers["Last-Event-ID"] = self._last_event_id
46
- if self._client._token:
47
- headers["Authorization"] = f"Bearer {self._client._token}"
48
-
49
- async with self._client._http.stream(
50
- "GET",
51
- self._url,
52
- params=self._params,
53
- headers=headers,
54
- # connect + write have bounded timeouts; read=None allows
55
- # indefinite streaming without spurious disconnects.
56
- timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=5.0),
57
- ) as resp:
58
- if resp.status_code != 200:
59
- raise httpx.HTTPStatusError(
60
- f"SSE stream failed with {resp.status_code}",
61
- request=resp.request,
62
- response=resp,
63
- )
64
-
65
- backoff = 0.5 # reset on successful connect
66
- event_type = "message"
67
- data_lines = []
68
-
69
- async for line in resp.aiter_lines():
70
- if self._closed:
71
- return
72
-
73
- line = line.strip()
74
-
75
- if line.startswith("event:"):
76
- event_type = line[6:].strip()
77
- elif line.startswith("data:"):
78
- data_lines.append(line[5:].strip())
79
- elif line.startswith("id:"):
80
- self._last_event_id = line[3:].strip()
81
- elif line == "":
82
- # Empty line marks end of event
83
- if data_lines:
84
- data_str = "\n".join(data_lines)
85
- try:
86
- data = json.loads(data_str)
87
- if event_type == "push":
88
- yield Push(
89
- subscription_id=data.get("subscription_id", ""),
90
- trigger=data.get("trigger", ""),
91
- push_number=data.get("push_number", 0),
92
- engram_id=data.get("engram_id"),
93
- at=data.get("at"),
94
- )
95
- # ignore subscribed and other event types
96
- except json.JSONDecodeError:
97
- pass
98
-
99
- event_type = "message"
100
- data_lines = []
101
-
102
- except httpx.HTTPStatusError as e:
103
- # Fatal status codes: don't retry — surface immediately.
104
- if e.response.status_code in (401, 403, 404):
105
- raise MuninnConnectionError(
106
- f"SSE stream failed with {e.response.status_code}: "
107
- f"{e.response.text}"
108
- ) from e
109
- # 5xx and other server errors: backoff and retry.
110
- if self._closed:
111
- return
112
- await asyncio.sleep(min(backoff, 30.0))
113
- backoff = min(backoff * 2, 30.0)
114
- except (
115
- httpx.ConnectError,
116
- httpx.ReadError,
117
- httpx.RemoteProtocolError,
118
- ):
119
- if self._closed:
120
- return
121
- await asyncio.sleep(min(backoff, 30.0))
122
- backoff = min(backoff * 2, 30.0)
@@ -1,132 +0,0 @@
1
- """MuninnDB type definitions."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass, field
6
- from typing import Any, Dict, List, Optional
7
-
8
-
9
- @dataclass
10
- class WriteRequest:
11
- """Request to write an engram."""
12
-
13
- vault: str
14
- concept: str
15
- content: str
16
- tags: Optional[List[str]] = None
17
- confidence: float = 0.9
18
- stability: float = 0.5
19
- embedding: Optional[List[float]] = None
20
- associations: Optional[Dict[str, Any]] = None
21
-
22
-
23
- @dataclass
24
- class WriteResponse:
25
- """Response from writing an engram."""
26
-
27
- id: str
28
- created_at: int
29
-
30
-
31
- @dataclass
32
- class ActivateRequest:
33
- """Request to activate memory."""
34
-
35
- vault: str
36
- context: List[str]
37
- max_results: int = 10
38
- threshold: float = 0.1
39
- max_hops: int = 0
40
- include_why: bool = False
41
- brief_mode: str = "auto"
42
-
43
-
44
- @dataclass
45
- class ActivationItem:
46
- """A single activated memory item."""
47
-
48
- id: str
49
- concept: str
50
- content: str
51
- score: float
52
- confidence: float
53
- score_components: Optional[Dict[str, float]] = None
54
- why: Optional[str] = None
55
- hop_path: Optional[List[str]] = None
56
- dormant: bool = False
57
-
58
-
59
- @dataclass
60
- class BriefSentence:
61
- """A sentence extracted by brief mode."""
62
-
63
- engram_id: str
64
- text: str
65
- score: float
66
-
67
-
68
- @dataclass
69
- class ActivateResponse:
70
- """Response from activating memory."""
71
-
72
- query_id: str
73
- total_found: int
74
- activations: List[ActivationItem]
75
- latency_ms: float = 0.0
76
- brief: Optional[List[BriefSentence]] = None
77
-
78
-
79
- @dataclass
80
- class ReadResponse:
81
- """Response from reading an engram."""
82
-
83
- id: str
84
- concept: str
85
- content: str
86
- confidence: float
87
- relevance: float
88
- stability: float
89
- access_count: int
90
- tags: List[str]
91
- state: str
92
- created_at: int
93
- updated_at: int
94
- last_access: Optional[int] = None
95
- coherence: Optional[Dict[str, "CoherenceResult"]] = None
96
- summary: Optional[str] = None
97
- key_points: Optional[List[str]] = None
98
- memory_type: Optional[int] = None
99
- classification: Optional[int] = None
100
-
101
-
102
- @dataclass
103
- class CoherenceResult:
104
- """Coherence metrics for a vault."""
105
-
106
- score: float
107
- orphan_ratio: float
108
- contradiction_density: float
109
- duplication_pressure: float
110
- decay_variance: float
111
- total_engrams: int
112
-
113
-
114
- @dataclass
115
- class StatResponse:
116
- """Response from stats endpoint."""
117
-
118
- engram_count: int
119
- vault_count: int
120
- storage_bytes: int
121
- coherence: dict[str, CoherenceResult] | None = None
122
-
123
-
124
- @dataclass
125
- class Push:
126
- """SSE push event from subscription."""
127
-
128
- subscription_id: str
129
- trigger: str
130
- push_number: int
131
- engram_id: Optional[str] = None
132
- at: Optional[int] = None
File without changes