memnos-sdk 1.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.
memnos_sdk/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """memnos-sdk — memory layer for AI agents."""
2
+ from memnos_sdk.client import AsyncMemnosClient, MemnosClient
3
+ from memnos_sdk.models import (
4
+ Memory,
5
+ MemoryType,
6
+ SearchResult,
7
+ HealthStatus,
8
+ CorpusInfo,
9
+ CorpusStatus,
10
+ ConstraintHit,
11
+ CheckResult,
12
+ )
13
+ from memnos_sdk.corpus import AsyncCorpusClient, SyncCorpusClient
14
+ from memnos_sdk.exceptions import (
15
+ MemnosError,
16
+ AuthenticationError,
17
+ NotFoundError,
18
+ ValidationError,
19
+ ServerError,
20
+ ConnectionError,
21
+ )
22
+
23
+ __version__ = "1.1.0"
24
+ SCHEMA_VERSION = "1.0"
25
+
26
+ __all__ = [
27
+ # Clients
28
+ "MemnosClient",
29
+ "AsyncMemnosClient",
30
+ # Memory models
31
+ "Memory",
32
+ "MemoryType",
33
+ "SearchResult",
34
+ "HealthStatus",
35
+ # Corpus models
36
+ "CorpusInfo",
37
+ "CorpusStatus",
38
+ "ConstraintHit",
39
+ "CheckResult",
40
+ # Corpus sub-clients (advanced use)
41
+ "AsyncCorpusClient",
42
+ "SyncCorpusClient",
43
+ # Exceptions
44
+ "MemnosError",
45
+ "AuthenticationError",
46
+ "NotFoundError",
47
+ "ValidationError",
48
+ "ServerError",
49
+ "ConnectionError",
50
+ "__version__",
51
+ "SCHEMA_VERSION",
52
+ ]
memnos_sdk/_http.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from memnos_sdk.exceptions import (
8
+ AuthenticationError,
9
+ ConnectionError,
10
+ NotFoundError,
11
+ ServerError,
12
+ ValidationError,
13
+ )
14
+
15
+
16
+ def _raise_for_status(response: httpx.Response) -> None:
17
+ code = response.status_code
18
+ if code in (401, 403):
19
+ raise AuthenticationError(f"HTTP {code}: {response.text}")
20
+ if code == 404:
21
+ raise NotFoundError(f"HTTP 404: {response.text}")
22
+ if code == 422:
23
+ raise ValidationError(f"HTTP 422: {response.text}")
24
+ if code >= 500:
25
+ raise ServerError(f"HTTP {code}: {response.text}")
26
+ response.raise_for_status()
27
+
28
+
29
+ class _SyncTransport:
30
+ def __init__(self, base_url: str, api_key: str, timeout: float = 30.0) -> None:
31
+ self._client = httpx.Client(
32
+ base_url=base_url,
33
+ headers={"Authorization": f"Bearer {api_key}"},
34
+ timeout=timeout,
35
+ )
36
+
37
+ def get(self, path: str, params: dict[str, Any] | None = None) -> dict:
38
+ try:
39
+ response = self._client.get(path, params=params)
40
+ except httpx.TransportError as exc:
41
+ raise ConnectionError(str(exc)) from exc
42
+ _raise_for_status(response)
43
+ return response.json()
44
+
45
+ def post(self, path: str, json: dict | None = None) -> dict:
46
+ try:
47
+ response = self._client.post(path, json=json)
48
+ except httpx.TransportError as exc:
49
+ raise ConnectionError(str(exc)) from exc
50
+ _raise_for_status(response)
51
+ return response.json()
52
+
53
+ def delete(self, path: str) -> None:
54
+ try:
55
+ response = self._client.delete(path)
56
+ except httpx.TransportError as exc:
57
+ raise ConnectionError(str(exc)) from exc
58
+ _raise_for_status(response)
59
+
60
+ def close(self) -> None:
61
+ self._client.close()
62
+
63
+
64
+ class _AsyncTransport:
65
+ def __init__(self, base_url: str, api_key: str, timeout: float = 30.0) -> None:
66
+ self._client = httpx.AsyncClient(
67
+ base_url=base_url,
68
+ headers={"Authorization": f"Bearer {api_key}"},
69
+ timeout=timeout,
70
+ )
71
+
72
+ async def get(self, path: str, params: dict[str, Any] | None = None) -> dict:
73
+ try:
74
+ response = await self._client.get(path, params=params)
75
+ except httpx.TransportError as exc:
76
+ raise ConnectionError(str(exc)) from exc
77
+ _raise_for_status(response)
78
+ return response.json()
79
+
80
+ async def post(self, path: str, json: dict | None = None) -> dict:
81
+ try:
82
+ response = await self._client.post(path, json=json)
83
+ except httpx.TransportError as exc:
84
+ raise ConnectionError(str(exc)) from exc
85
+ _raise_for_status(response)
86
+ return response.json()
87
+
88
+ async def delete(self, path: str) -> None:
89
+ try:
90
+ response = await self._client.delete(path)
91
+ except httpx.TransportError as exc:
92
+ raise ConnectionError(str(exc)) from exc
93
+ _raise_for_status(response)
94
+
95
+ async def aclose(self) -> None:
96
+ await self._client.aclose()
memnos_sdk/client.py ADDED
@@ -0,0 +1,418 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import threading
5
+ from contextlib import asynccontextmanager, contextmanager
6
+ from datetime import datetime
7
+ from typing import Any, AsyncIterator, Iterator
8
+
9
+ from memnos_sdk._http import _AsyncTransport, _SyncTransport
10
+ from memnos_sdk.corpus import AsyncCorpusClient, SyncCorpusClient
11
+ from memnos_sdk.models import HealthStatus, Memory, MemoryType
12
+
13
+
14
+ def _parse_memory(data: dict) -> Memory:
15
+ raw_type = data.get("memory_type", "fact")
16
+ try:
17
+ mem_type = MemoryType(raw_type)
18
+ except ValueError:
19
+ mem_type = MemoryType.FACT
20
+ return Memory(
21
+ id=data["id"],
22
+ content=data["content"],
23
+ namespace=data["namespace"],
24
+ memory_type=mem_type,
25
+ tags=data.get("tags") or [],
26
+ affects=data.get("affects") or [],
27
+ rationale=data.get("rationale") or "",
28
+ author=data.get("author") or "",
29
+ created_at=data["created_at"],
30
+ score=data.get("score"),
31
+ provenance=data.get("provenance") or {},
32
+ contradiction_warnings=data.get("contradiction_warnings") or [],
33
+ )
34
+
35
+
36
+ def _write_payload(
37
+ content: str,
38
+ namespace: str,
39
+ memory_type: MemoryType | str,
40
+ tags: list[str],
41
+ affects: list[str],
42
+ rationale: str,
43
+ author: str,
44
+ source: str,
45
+ metadata: dict,
46
+ expires_at: datetime | None,
47
+ ) -> dict[str, Any]:
48
+ payload: dict[str, Any] = {
49
+ "content": content,
50
+ "namespace": namespace,
51
+ "memory_type": memory_type.value if isinstance(memory_type, MemoryType) else memory_type,
52
+ "tags": tags,
53
+ "affects": affects,
54
+ "rationale": rationale,
55
+ "author": author,
56
+ "source": source,
57
+ "metadata": metadata,
58
+ }
59
+ if expires_at is not None:
60
+ payload["expires_at"] = expires_at.isoformat()
61
+ return payload
62
+
63
+
64
+ def _search_params(
65
+ query: str,
66
+ namespace: str,
67
+ top_k: int,
68
+ as_of: datetime | None,
69
+ ) -> dict[str, Any]:
70
+ params: dict[str, Any] = {"q": query, "ns": namespace, "top_k": top_k}
71
+ if as_of is not None:
72
+ params["as_of"] = as_of.isoformat()
73
+ return params
74
+
75
+
76
+ class AsyncMemnosClient:
77
+ def __init__(
78
+ self,
79
+ url: str = "http://localhost:8766",
80
+ api_key: str = "",
81
+ timeout: float = 30.0,
82
+ ) -> None:
83
+ self._transport = _AsyncTransport(url, api_key, timeout)
84
+ self.corpus = AsyncCorpusClient(self._transport)
85
+
86
+ async def __aenter__(self) -> "AsyncMemnosClient":
87
+ return self
88
+
89
+ async def __aexit__(self, *_: Any) -> None:
90
+ await self._transport.aclose()
91
+
92
+ async def health(self) -> HealthStatus:
93
+ data = await self._transport.get("/api/v1/admin/health")
94
+ return HealthStatus(
95
+ status=data.get("status", "unknown"),
96
+ arcadedb=data.get("arcadedb", "unknown"),
97
+ version=data.get("version", ""),
98
+ schema_version=data.get("schema_version", "1.0"),
99
+ )
100
+
101
+ async def write(
102
+ self,
103
+ content: str,
104
+ namespace: str,
105
+ *,
106
+ memory_type: MemoryType | str = MemoryType.FACT,
107
+ tags: list[str] = [],
108
+ affects: list[str] = [],
109
+ rationale: str = "",
110
+ author: str = "",
111
+ source: str = "sdk",
112
+ metadata: dict = {},
113
+ expires_at: datetime | None = None,
114
+ ) -> Memory:
115
+ data = await self._transport.post(
116
+ "/api/v1/memory/",
117
+ json=_write_payload(
118
+ content, namespace, memory_type, tags, affects,
119
+ rationale, author, source, metadata, expires_at,
120
+ ),
121
+ )
122
+ return _parse_memory(data)
123
+
124
+ async def search(
125
+ self,
126
+ query: str,
127
+ namespace: str,
128
+ *,
129
+ top_k: int = 10,
130
+ as_of: datetime | None = None,
131
+ ) -> list[Memory]:
132
+ data = await self._transport.get(
133
+ "/api/v1/memory/search",
134
+ params=_search_params(query, namespace, top_k, as_of),
135
+ )
136
+ return [_parse_memory(item) for item in data]
137
+
138
+ async def get(self, memory_id: str) -> Memory:
139
+ data = await self._transport.get(f"/api/v1/memory/{memory_id}")
140
+ return _parse_memory(data)
141
+
142
+ async def delete(self, memory_id: str) -> None:
143
+ await self._transport.delete(f"/api/v1/memory/{memory_id}")
144
+
145
+ async def get_constraints(self, namespace: str) -> list[Memory]:
146
+ """Return all active constraints for a namespace (score=2.0, always governs)."""
147
+ results = await self.search(
148
+ "constraints rules must always", namespace=namespace, top_k=50
149
+ )
150
+ return [m for m in results if m.memory_type == MemoryType.CONSTRAINT]
151
+
152
+ async def get_governing_decisions(
153
+ self, entities: list[str], namespace: str
154
+ ) -> list[Memory]:
155
+ """Return decisions/ADRs whose affects[] overlaps with the given entity names."""
156
+ query = " ".join(entities)
157
+ results = await self.search(query, namespace=namespace, top_k=100)
158
+ entity_set = {e.lower() for e in entities}
159
+ return [
160
+ m for m in results
161
+ if m.memory_type in (MemoryType.DECISION, MemoryType.ADR)
162
+ and entity_set & {a.lower() for a in m.affects}
163
+ ]
164
+
165
+ async def export_namespace(
166
+ self,
167
+ namespace: str,
168
+ *,
169
+ memory_type: str | None = None,
170
+ include_superseded: bool = False,
171
+ ) -> dict:
172
+ """Export all memories in a namespace. Returns the export envelope dict."""
173
+ params: dict[str, Any] = {"ns": namespace, "format": "json"}
174
+ if memory_type:
175
+ params["memory_type"] = memory_type
176
+ if include_superseded:
177
+ params["include_superseded"] = "true"
178
+ return await self._transport.get("/api/v1/admin/export", params=params)
179
+
180
+ async def import_namespace(
181
+ self,
182
+ data: dict,
183
+ *,
184
+ target_namespace: str | None = None,
185
+ ) -> dict:
186
+ """Import memories from an export envelope. Returns {imported, skipped, namespace}."""
187
+ path = "/api/v1/admin/import"
188
+ if target_namespace:
189
+ from urllib.parse import quote
190
+ path = f"{path}?ns={quote(target_namespace, safe='')}"
191
+ return await self._transport.post(path, json=data)
192
+
193
+ async def list_namespaces(self) -> list[str]:
194
+ """Return all configured namespace names."""
195
+ data = await self._transport.get("/api/v1/admin/namespaces")
196
+ if isinstance(data, list):
197
+ return [item["name"] if isinstance(item, dict) else item for item in data]
198
+ return []
199
+
200
+ # ------------------------------------------------------------------
201
+ # Episodes
202
+ # ------------------------------------------------------------------
203
+
204
+ async def create_episode(
205
+ self,
206
+ title: str,
207
+ namespace: str,
208
+ *,
209
+ summary: str = "",
210
+ tags: list[str] = [],
211
+ ) -> dict:
212
+ """Create a named session container Episode. Returns the episode dict."""
213
+ return await self._transport.post(
214
+ "/api/v1/episodes/",
215
+ json={"title": title, "namespace": namespace, "summary": summary, "tags": tags},
216
+ )
217
+
218
+ async def close_episode(self, episode_id: str) -> dict:
219
+ """Mark an Episode as closed (session complete)."""
220
+ return await self._transport.post(f"/api/v1/episodes/{episode_id}/close", json={})
221
+
222
+ async def link_memory_to_episode(self, episode_id: str, memory_id: str) -> dict:
223
+ """Associate a memory with an Episode."""
224
+ return await self._transport.post(
225
+ f"/api/v1/episodes/{episode_id}/memories/{memory_id}", json={}
226
+ )
227
+
228
+ async def get_episode(self, episode_id: str) -> dict:
229
+ """Fetch an Episode with its linked memories."""
230
+ return await self._transport.get(f"/api/v1/episodes/{episode_id}")
231
+
232
+ async def list_episodes(self, namespace: str, *, include_closed: bool = False) -> list[dict]:
233
+ """List Episodes in a namespace."""
234
+ params: dict[str, Any] = {"ns": namespace}
235
+ if include_closed:
236
+ params["include_closed"] = "true"
237
+ data = await self._transport.get("/api/v1/episodes/", params=params)
238
+ return data if isinstance(data, list) else []
239
+
240
+ @asynccontextmanager
241
+ async def session(
242
+ self,
243
+ title: str,
244
+ namespace: str,
245
+ *,
246
+ summary: str = "",
247
+ tags: list[str] = [],
248
+ auto_link: bool = False,
249
+ ) -> AsyncIterator[dict]:
250
+ """Async context manager: open an Episode on enter, close on exit.
251
+
252
+ Usage:
253
+ async with client.session("my task", "org:acme:eng") as episode:
254
+ mem = await client.write("Found the bug", "org:acme:eng")
255
+ # memories written inside are auto-linked when auto_link=True
256
+ """
257
+ episode = await self.create_episode(title, namespace, summary=summary, tags=tags)
258
+ try:
259
+ yield episode
260
+ finally:
261
+ await self.close_episode(episode["id"])
262
+
263
+
264
+ class MemnosClient:
265
+ """Synchronous wrapper around AsyncMemnosClient. Suitable for scripts and non-async code.
266
+
267
+ Usage:
268
+ client = MemnosClient(url="http://localhost:8766", api_key="my-key")
269
+ with client:
270
+ memories = client.search("database decisions", "org:acme:engineering")
271
+ """
272
+
273
+ def __init__(
274
+ self,
275
+ url: str = "http://localhost:8766",
276
+ api_key: str = "",
277
+ timeout: float = 30.0,
278
+ ) -> None:
279
+ self._async_client = AsyncMemnosClient(url=url, api_key=api_key, timeout=timeout)
280
+ self._loop = asyncio.new_event_loop()
281
+ self._lock = threading.Lock()
282
+ self.corpus = SyncCorpusClient(self._async_client.corpus, self._run)
283
+
284
+ def _run(self, coro):
285
+ with self._lock:
286
+ return self._loop.run_until_complete(coro)
287
+
288
+ def __enter__(self) -> "MemnosClient":
289
+ return self
290
+
291
+ def __exit__(self, *_: Any) -> None:
292
+ self.close()
293
+
294
+ def close(self) -> None:
295
+ self._run(self._async_client._transport.aclose())
296
+ self._loop.close()
297
+
298
+ def health(self) -> HealthStatus:
299
+ return self._run(self._async_client.health())
300
+
301
+ def write(
302
+ self,
303
+ content: str,
304
+ namespace: str,
305
+ *,
306
+ memory_type: MemoryType | str = MemoryType.FACT,
307
+ tags: list[str] = [],
308
+ affects: list[str] = [],
309
+ rationale: str = "",
310
+ author: str = "",
311
+ source: str = "sdk",
312
+ metadata: dict = {},
313
+ expires_at: datetime | None = None,
314
+ ) -> Memory:
315
+ return self._run(
316
+ self._async_client.write(
317
+ content, namespace,
318
+ memory_type=memory_type, tags=tags, affects=affects,
319
+ rationale=rationale, author=author, source=source,
320
+ metadata=metadata, expires_at=expires_at,
321
+ )
322
+ )
323
+
324
+ def search(
325
+ self,
326
+ query: str,
327
+ namespace: str,
328
+ *,
329
+ top_k: int = 10,
330
+ as_of: datetime | None = None,
331
+ ) -> list[Memory]:
332
+ return self._run(
333
+ self._async_client.search(query, namespace, top_k=top_k, as_of=as_of)
334
+ )
335
+
336
+ def get(self, memory_id: str) -> Memory:
337
+ return self._run(self._async_client.get(memory_id))
338
+
339
+ def delete(self, memory_id: str) -> None:
340
+ self._run(self._async_client.delete(memory_id))
341
+
342
+ def get_constraints(self, namespace: str) -> list[Memory]:
343
+ return self._run(self._async_client.get_constraints(namespace))
344
+
345
+ def get_governing_decisions(
346
+ self, entities: list[str], namespace: str
347
+ ) -> list[Memory]:
348
+ return self._run(
349
+ self._async_client.get_governing_decisions(entities, namespace)
350
+ )
351
+
352
+ def export_namespace(
353
+ self,
354
+ namespace: str,
355
+ *,
356
+ memory_type: str | None = None,
357
+ include_superseded: bool = False,
358
+ ) -> dict:
359
+ return self._run(
360
+ self._async_client.export_namespace(
361
+ namespace,
362
+ memory_type=memory_type,
363
+ include_superseded=include_superseded,
364
+ )
365
+ )
366
+
367
+ def import_namespace(
368
+ self,
369
+ data: dict,
370
+ *,
371
+ target_namespace: str | None = None,
372
+ ) -> dict:
373
+ return self._run(
374
+ self._async_client.import_namespace(data, target_namespace=target_namespace)
375
+ )
376
+
377
+ def list_namespaces(self) -> list[str]:
378
+ return self._run(self._async_client.list_namespaces())
379
+
380
+ # ------------------------------------------------------------------
381
+ # Episodes (sync wrappers)
382
+ # ------------------------------------------------------------------
383
+
384
+ def create_episode(self, title: str, namespace: str, *, summary: str = "", tags: list[str] = []) -> dict:
385
+ return self._run(self._async_client.create_episode(title, namespace, summary=summary, tags=tags))
386
+
387
+ def close_episode(self, episode_id: str) -> dict:
388
+ return self._run(self._async_client.close_episode(episode_id))
389
+
390
+ def link_memory_to_episode(self, episode_id: str, memory_id: str) -> dict:
391
+ return self._run(self._async_client.link_memory_to_episode(episode_id, memory_id))
392
+
393
+ def get_episode(self, episode_id: str) -> dict:
394
+ return self._run(self._async_client.get_episode(episode_id))
395
+
396
+ def list_episodes(self, namespace: str, *, include_closed: bool = False) -> list[dict]:
397
+ return self._run(self._async_client.list_episodes(namespace, include_closed=include_closed))
398
+
399
+ @contextmanager
400
+ def session(
401
+ self,
402
+ title: str,
403
+ namespace: str,
404
+ *,
405
+ summary: str = "",
406
+ tags: list[str] = [],
407
+ ) -> Iterator[dict]:
408
+ """Sync context manager: open an Episode on enter, close on exit.
409
+
410
+ Usage:
411
+ with client.session("my task", "org:acme:eng") as episode:
412
+ client.write("Found the bug", "org:acme:eng")
413
+ """
414
+ episode = self.create_episode(title, namespace, summary=summary, tags=tags)
415
+ try:
416
+ yield episode
417
+ finally:
418
+ self.close_episode(episode["id"])
memnos_sdk/corpus.py ADDED
@@ -0,0 +1,279 @@
1
+ """
2
+ memnos_sdk.corpus — Corpus sub-client for architecture constraint management.
3
+
4
+ Accessed via ``client.corpus`` on both AsyncMemnosClient and MemnosClient.
5
+ Provides a typed interface to the corpus REST API without requiring callers
6
+ to know endpoint paths or parse raw JSON.
7
+
8
+ Async usage::
9
+
10
+ async with AsyncMemnosClient(url="...", api_key="...") as client:
11
+ corpus = await client.corpus.register(
12
+ name="hdig-platform-architecture",
13
+ source_path="/repos/hdig-platform/docs",
14
+ namespace="org:hc:hdig:architecture",
15
+ watch=True,
16
+ )
17
+ # Wait for initial sync, then check code:
18
+ result = await client.corpus.check(
19
+ corpus_id=corpus.id,
20
+ code=diff_text,
21
+ context="patient-access consent validation filter",
22
+ )
23
+ for hit in result.shall_violations:
24
+ print(f"SHALL violation: {hit.content}")
25
+ print(f" Source: {hit.source_file} | {hit.section}")
26
+
27
+ Sync usage::
28
+
29
+ with MemnosClient(url="...", api_key="...") as client:
30
+ corpora = client.corpus.list()
31
+ result = client.corpus.check(corpus_id=corpora[0].id, code=code)
32
+ print(result.format())
33
+
34
+ CI / GitLab webhook integration::
35
+
36
+ # Register once with watch=True and a webhook_secret:
37
+ corpus = await client.corpus.register(..., watch=True, webhook_secret="secret")
38
+ # Then configure GitLab to POST corpus.sync_url to trigger re-sync on push.
39
+ print(corpus.sync_url(base_url="https://memnos.internal"))
40
+
41
+ Custom connectors::
42
+
43
+ # To use a non-default connector type, pass connector_type:
44
+ corpus = await client.corpus.register(
45
+ name="payments-openapi",
46
+ source_path="/specs/payments-v3.yaml",
47
+ namespace="org:acme:payments:architecture",
48
+ connector_type="openapi", # must be registered in server REGISTRY
49
+ )
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from typing import Any
55
+
56
+ from memnos_sdk.models import CheckResult, ConstraintHit, CorpusInfo, CorpusStatus
57
+
58
+
59
+ def _parse_corpus(data: dict) -> CorpusInfo:
60
+ return CorpusInfo(
61
+ id=data["id"],
62
+ name=data["name"],
63
+ source_path=data["source_path"],
64
+ path_pattern=data.get("path_pattern", "**/*.md"),
65
+ namespace=data["namespace"],
66
+ connector_type=data.get("connector_type", "git-doc"),
67
+ watch=data.get("watch", False),
68
+ status=CorpusStatus(data.get("status", "pending")),
69
+ node_count=data.get("node_count", 0),
70
+ last_sync_sha=data.get("last_sync_sha", ""),
71
+ last_sync_at=data.get("last_sync_at"),
72
+ error_msg=data.get("error_msg", ""),
73
+ created_at=data["created_at"],
74
+ created_by=data.get("created_by", ""),
75
+ )
76
+
77
+
78
+ def _parse_check(data: dict) -> CheckResult:
79
+ hits = [
80
+ ConstraintHit(
81
+ memory_id=c["memory_id"],
82
+ content=c["content"],
83
+ severity=c.get("severity", ""),
84
+ source_file=c.get("source_file", ""),
85
+ section=c.get("section", ""),
86
+ score=float(c.get("score", 0)),
87
+ )
88
+ for c in data.get("constraints", [])
89
+ ]
90
+ return CheckResult(
91
+ corpus_id=data["corpus_id"],
92
+ namespace=data["namespace"],
93
+ constraints=hits,
94
+ )
95
+
96
+
97
+ class AsyncCorpusClient:
98
+ """Async corpus operations. Accessed via ``AsyncMemnosClient.corpus``."""
99
+
100
+ def __init__(self, transport) -> None:
101
+ self._t = transport
102
+
103
+ async def register(
104
+ self,
105
+ name: str,
106
+ source_path: str,
107
+ namespace: str,
108
+ *,
109
+ path_pattern: str = "**/*.md",
110
+ connector_type: str = "git-doc",
111
+ watch: bool = False,
112
+ webhook_secret: str = "",
113
+ ) -> CorpusInfo:
114
+ """Register a corpus source and trigger initial ingestion.
115
+
116
+ The ingestion runs in the background on the server; poll ``get()``
117
+ until ``status == CorpusStatus.READY`` before calling ``check()``.
118
+ """
119
+ data = await self._t.post(
120
+ "/api/v1/corpus/",
121
+ json={
122
+ "name": name,
123
+ "source_path": source_path,
124
+ "namespace": namespace,
125
+ "path_pattern": path_pattern,
126
+ "connector_type": connector_type,
127
+ "watch": watch,
128
+ "webhook_secret": webhook_secret,
129
+ },
130
+ )
131
+ return _parse_corpus(data)
132
+
133
+ async def list(self) -> list[CorpusInfo]:
134
+ """Return all registered corpus sources."""
135
+ data = await self._t.get("/api/v1/corpus/")
136
+ return [_parse_corpus(item) for item in data]
137
+
138
+ async def get(self, corpus_id: str) -> CorpusInfo:
139
+ """Fetch a single corpus by ID."""
140
+ data = await self._t.get(f"/api/v1/corpus/{corpus_id}")
141
+ return _parse_corpus(data)
142
+
143
+ async def sync(self, corpus_id: str) -> CorpusInfo:
144
+ """Trigger a re-sync of corpus nodes from source.
145
+
146
+ Non-blocking: the sync runs in the background. Poll ``get()``
147
+ until ``status == CorpusStatus.READY``.
148
+ """
149
+ data = await self._t.post(f"/api/v1/corpus/{corpus_id}/sync", json={})
150
+ return _parse_corpus(data)
151
+
152
+ async def delete(self, corpus_id: str) -> None:
153
+ """Unregister a corpus. Does not delete the ingested memory nodes."""
154
+ await self._t.delete(f"/api/v1/corpus/{corpus_id}")
155
+
156
+ async def check(
157
+ self,
158
+ corpus_id: str,
159
+ code: str,
160
+ context: str = "",
161
+ *,
162
+ top_k: int = 10,
163
+ ) -> CheckResult:
164
+ """Return architecture constraints relevant to a code snippet.
165
+
166
+ Parameters
167
+ ----------
168
+ corpus_id : ID of the registered corpus
169
+ code : code snippet being reviewed or implemented
170
+ context : free-text description of what the code does and which
171
+ module/component it belongs to, e.g.
172
+ "patient-access consent validation filter"
173
+ top_k : max constraints to return
174
+
175
+ Returns
176
+ -------
177
+ CheckResult with ``.constraints``, ``.shall_violations``,
178
+ ``.should_violations``, and ``.format()`` helper.
179
+
180
+ Example
181
+ -------
182
+ ::
183
+
184
+ result = await client.corpus.check(
185
+ corpus_id=corpus.id,
186
+ code=diff_text,
187
+ context="patient-access consent filter",
188
+ )
189
+ if result.shall_violations:
190
+ raise ArchitectureViolationError(result.format())
191
+ """
192
+ data = await self._t.post(
193
+ f"/api/v1/corpus/{corpus_id}/check",
194
+ json={"code": code, "context": context, "top_k": top_k},
195
+ )
196
+ return _parse_check(data)
197
+
198
+ async def check_all(
199
+ self,
200
+ code: str,
201
+ context: str = "",
202
+ *,
203
+ top_k: int = 10,
204
+ ) -> list[CheckResult]:
205
+ """Check code against ALL registered corpora and return combined results.
206
+
207
+ Useful when a code change may touch multiple modules and you want
208
+ constraints from all relevant corpora without knowing the corpus IDs.
209
+ Only READY corpora are checked; SYNCING/ERROR corpora are skipped.
210
+ """
211
+ corpora = await self.list()
212
+ results = []
213
+ for corpus in corpora:
214
+ if corpus.status != CorpusStatus.READY:
215
+ continue
216
+ result = await self.check(corpus.id, code, context, top_k=top_k)
217
+ if result.constraints:
218
+ results.append(result)
219
+ return results
220
+
221
+
222
+ class SyncCorpusClient:
223
+ """Synchronous corpus operations. Accessed via ``MemnosClient.corpus``."""
224
+
225
+ def __init__(self, async_client: AsyncCorpusClient, run_fn) -> None:
226
+ self._async = async_client
227
+ self._run = run_fn
228
+
229
+ def register(
230
+ self,
231
+ name: str,
232
+ source_path: str,
233
+ namespace: str,
234
+ *,
235
+ path_pattern: str = "**/*.md",
236
+ connector_type: str = "git-doc",
237
+ watch: bool = False,
238
+ webhook_secret: str = "",
239
+ ) -> CorpusInfo:
240
+ return self._run(
241
+ self._async.register(
242
+ name, source_path, namespace,
243
+ path_pattern=path_pattern,
244
+ connector_type=connector_type,
245
+ watch=watch,
246
+ webhook_secret=webhook_secret,
247
+ )
248
+ )
249
+
250
+ def list(self) -> list[CorpusInfo]:
251
+ return self._run(self._async.list())
252
+
253
+ def get(self, corpus_id: str) -> CorpusInfo:
254
+ return self._run(self._async.get(corpus_id))
255
+
256
+ def sync(self, corpus_id: str) -> CorpusInfo:
257
+ return self._run(self._async.sync(corpus_id))
258
+
259
+ def delete(self, corpus_id: str) -> None:
260
+ self._run(self._async.delete(corpus_id))
261
+
262
+ def check(
263
+ self,
264
+ corpus_id: str,
265
+ code: str,
266
+ context: str = "",
267
+ *,
268
+ top_k: int = 10,
269
+ ) -> CheckResult:
270
+ return self._run(self._async.check(corpus_id, code, context, top_k=top_k))
271
+
272
+ def check_all(
273
+ self,
274
+ code: str,
275
+ context: str = "",
276
+ *,
277
+ top_k: int = 10,
278
+ ) -> list[CheckResult]:
279
+ return self._run(self._async.check_all(code, context, top_k=top_k))
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class MemnosError(Exception):
5
+ """Base exception for all memnos SDK errors."""
6
+
7
+
8
+ class AuthenticationError(MemnosError):
9
+ """Raised on HTTP 401 or 403 responses."""
10
+
11
+
12
+ class NotFoundError(MemnosError):
13
+ """Raised on HTTP 404 responses."""
14
+
15
+
16
+ class ValidationError(MemnosError):
17
+ """Raised on HTTP 422 responses."""
18
+
19
+
20
+ class ServerError(MemnosError):
21
+ """Raised on HTTP 5xx responses."""
22
+
23
+
24
+ class ConnectionError(MemnosError):
25
+ """Raised when a network-level failure prevents the request from completing."""
File without changes
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ try:
6
+ from langchain_core.memory import BaseMemory
7
+ from langchain_core.messages import BaseMessage # noqa: F401
8
+ _LANGCHAIN_AVAILABLE = True
9
+ except ImportError:
10
+ _LANGCHAIN_AVAILABLE = False
11
+ BaseMemory = object # type: ignore[assignment,misc]
12
+
13
+ from memnos_sdk.models import MemoryType
14
+
15
+
16
+ class MemnosMemory(BaseMemory):
17
+ """LangChain memory backend backed by memnos.
18
+
19
+ Usage:
20
+ client = MemnosClient(url="...", api_key="...")
21
+ memory = MemnosMemory(client=client, namespace="org:acme", session_id="session-123")
22
+ agent = ConversationChain(llm=..., memory=memory)
23
+ """
24
+
25
+ client: Any
26
+ namespace: str
27
+ session_id: str
28
+ memory_key: str = "history"
29
+ input_key: str = "input"
30
+ output_key: str = "output"
31
+
32
+ def __init__(self, **data: Any) -> None:
33
+ if not _LANGCHAIN_AVAILABLE:
34
+ raise ImportError(
35
+ "langchain-core is required for MemnosMemory. "
36
+ "Install it with: pip install 'memnos-sdk[langchain]'"
37
+ )
38
+ super().__init__(**data)
39
+
40
+ @property
41
+ def memory_variables(self) -> list[str]:
42
+ return [self.memory_key]
43
+
44
+ def load_memory_variables(self, inputs: dict) -> dict:
45
+ memories = self.client.search(
46
+ f"session {self.session_id}",
47
+ self.namespace,
48
+ top_k=20,
49
+ )
50
+ history_lines: list[str] = []
51
+ for m in sorted(memories, key=lambda x: x.created_at):
52
+ if self.session_id in m.tags:
53
+ history_lines.append(m.content)
54
+ return {self.memory_key: "\n".join(history_lines)}
55
+
56
+ def save_context(self, inputs: dict, outputs: dict) -> None:
57
+ user_input = inputs.get(self.input_key, "")
58
+ ai_output = outputs.get(self.output_key, "")
59
+ combined = f"Human: {user_input}\nAI: {ai_output}"
60
+ self.client.write(
61
+ combined,
62
+ self.namespace,
63
+ memory_type=MemoryType.SESSION,
64
+ tags=[self.session_id, "conversation"],
65
+ rationale=f"LangChain session {self.session_id}",
66
+ source="langchain",
67
+ )
68
+
69
+ def clear(self) -> None:
70
+ # memnos uses supersede for lifecycle management, not deletion
71
+ pass
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ try:
6
+ from llama_index.core.readers.base import BaseReader
7
+ from llama_index.core.schema import Document
8
+ _LLAMAINDEX_AVAILABLE = True
9
+ except ImportError:
10
+ _LLAMAINDEX_AVAILABLE = False
11
+ BaseReader = object # type: ignore[assignment,misc]
12
+ Document = None # type: ignore[assignment]
13
+
14
+
15
+ class MemnosReader(BaseReader):
16
+ """LlamaIndex reader that loads memnos memories as Documents.
17
+
18
+ Usage:
19
+ client = MemnosClient(url="...", api_key="...")
20
+ reader = MemnosReader(client=client, namespace="org:acme:engineering")
21
+ documents = reader.load_data(query="database decisions", top_k=10)
22
+ index = VectorStoreIndex.from_documents(documents)
23
+ """
24
+
25
+ def __init__(self, client: Any, namespace: str) -> None:
26
+ if not _LLAMAINDEX_AVAILABLE:
27
+ raise ImportError(
28
+ "llama-index-core is required for MemnosReader. "
29
+ "Install it with: pip install 'memnos-sdk[llamaindex]'"
30
+ )
31
+ self.client = client
32
+ self.namespace = namespace
33
+
34
+ def load_data(self, query: str, top_k: int = 10) -> list:
35
+ memories = self.client.search(query, self.namespace, top_k=top_k)
36
+ documents = []
37
+ for m in memories:
38
+ doc = Document(
39
+ text=m.content,
40
+ metadata={
41
+ "id": m.id,
42
+ "namespace": m.namespace,
43
+ "memory_type": m.memory_type.value,
44
+ "tags": m.tags,
45
+ "affects": m.affects,
46
+ "created_at": m.created_at.isoformat(),
47
+ "score": m.score,
48
+ },
49
+ )
50
+ documents.append(doc)
51
+ return documents
memnos_sdk/models.py ADDED
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class MemoryType(str, Enum):
11
+ FACT = "fact"
12
+ DECISION = "decision"
13
+ CONSTRAINT = "constraint"
14
+ ADR = "adr"
15
+ SESSION = "session"
16
+ EPISODE = "episode"
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Corpus models
21
+ # ---------------------------------------------------------------------------
22
+
23
+ class CorpusStatus(str, Enum):
24
+ PENDING = "pending"
25
+ SYNCING = "syncing"
26
+ READY = "ready"
27
+ ERROR = "error"
28
+
29
+
30
+ class CorpusInfo(BaseModel):
31
+ """Metadata for a registered corpus source."""
32
+ id: str
33
+ name: str
34
+ source_path: str
35
+ path_pattern: str
36
+ namespace: str
37
+ connector_type: str = "git-doc"
38
+ watch: bool
39
+ status: CorpusStatus
40
+ node_count: int
41
+ last_sync_sha: str
42
+ last_sync_at: datetime | None = None
43
+ error_msg: str = ""
44
+ created_at: datetime
45
+ created_by: str = ""
46
+
47
+
48
+ class ConstraintHit(BaseModel):
49
+ """A single constraint node returned by a corpus check."""
50
+ memory_id: str
51
+ content: str
52
+ severity: str # "SHALL" | "SHOULD" | "MAY" | ""
53
+ source_file: str
54
+ section: str
55
+ score: float
56
+
57
+
58
+ class CheckResult(BaseModel):
59
+ """Result of a corpus constraint check against a code snippet."""
60
+ corpus_id: str
61
+ namespace: str
62
+ constraints: list[ConstraintHit]
63
+
64
+ @property
65
+ def shall_violations(self) -> list[ConstraintHit]:
66
+ """Constraints with SHALL severity — highest priority."""
67
+ return [c for c in self.constraints if c.severity == "SHALL"]
68
+
69
+ @property
70
+ def should_violations(self) -> list[ConstraintHit]:
71
+ """Constraints with SHOULD severity."""
72
+ return [c for c in self.constraints if c.severity == "SHOULD"]
73
+
74
+ def format(self) -> str:
75
+ """Human-readable summary for agent prompts."""
76
+ if not self.constraints:
77
+ return f"No constraints found for corpus {self.corpus_id}."
78
+ lines = [f"Corpus: {self.corpus_id} | Namespace: {self.namespace}"]
79
+ lines.append(f"Found {len(self.constraints)} relevant constraint(s):\n")
80
+ for i, c in enumerate(self.constraints, 1):
81
+ sev = f"[{c.severity}] " if c.severity else ""
82
+ lines.append(f"{i}. {sev}{c.content}")
83
+ if c.source_file or c.section:
84
+ src = c.source_file
85
+ lines.append(f" Source: {src}" + (f" | Section: {c.section}" if c.section else ""))
86
+ lines.append(f" Score: {c.score:.3f}")
87
+ lines.append("")
88
+ return "\n".join(lines)
89
+
90
+
91
+ class Memory(BaseModel):
92
+ id: str
93
+ content: str
94
+ namespace: str
95
+ memory_type: MemoryType
96
+ tags: list[str]
97
+ affects: list[str]
98
+ rationale: str
99
+ author: str
100
+ created_at: datetime
101
+ score: float | None = None
102
+ provenance: dict = Field(default_factory=dict)
103
+ contradiction_warnings: list[dict] = Field(default_factory=list)
104
+
105
+
106
+ class SearchResult(BaseModel):
107
+ memories: list[Memory]
108
+ # constraints are returned as Memory objects with memory_type=constraint and score=2.0
109
+ # They are already in the memories list; this model is transparent about that
110
+
111
+
112
+ class HealthStatus(BaseModel):
113
+ status: str
114
+ arcadedb: str
115
+ version: str
116
+ schema_version: str = "1.0"
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: memnos-sdk
3
+ Version: 1.1.0
4
+ Summary: memnos SDK — memory layer for AI agents
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: pydantic>=2.0
8
+ Provides-Extra: langchain
9
+ Requires-Dist: langchain-core>=0.2; extra == "langchain"
10
+ Provides-Extra: llamaindex
11
+ Requires-Dist: llama-index-core>=0.10; extra == "llamaindex"
12
+ Provides-Extra: all
13
+ Requires-Dist: memnos-sdk[langchain,llamaindex]; extra == "all"
@@ -0,0 +1,13 @@
1
+ memnos_sdk/__init__.py,sha256=jNolTbqK0tgV8IpWRXAgfHJccuh8955ZwX4yhmOKWug,1092
2
+ memnos_sdk/_http.py,sha256=R-GuYqC8lKxQZLjjyyjnCFQEp-Jp8TTpW9jAqOJL3E0,3099
3
+ memnos_sdk/client.py,sha256=OK36OUc-W-j-ra9BHIafmJA6bNlBmliB5spwHQEzy30,14074
4
+ memnos_sdk/corpus.py,sha256=Lf8M2bYTV1nFJ0Q31yby6CmeDwjPJ6GLHLPEkJmKoqI,9137
5
+ memnos_sdk/exceptions.py,sha256=6tiv59i-pYp2ZhBp9GqkvK5p-rhjUnNmvKH_LdBgSrI,558
6
+ memnos_sdk/models.py,sha256=ade1v8LaDJ6Agqy0tDq6fSZKf-6Nq_ZFB6vTmmRll7Q,3358
7
+ memnos_sdk/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ memnos_sdk/integrations/langchain.py,sha256=hIp0RbXQlEe3wlsaJInMXU-DOKKQUvw4fIFQqqZcX08,2284
9
+ memnos_sdk/integrations/llamaindex.py,sha256=Na17HuslvJa6PGz_Q735pFVdvxfSVDqqZF30Z4ivKdU,1775
10
+ memnos_sdk-1.1.0.dist-info/METADATA,sha256=ytqKmfbGjInV4rGDym4oC4TPEJtigIMq739skgQDs_c,440
11
+ memnos_sdk-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ memnos_sdk-1.1.0.dist-info/top_level.txt,sha256=2pE0i6SWCUuJQl4NPrGhauh36f5I-XSPEkW6MdKoVzg,11
13
+ memnos_sdk-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ memnos_sdk