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 +52 -0
- memnos_sdk/_http.py +96 -0
- memnos_sdk/client.py +418 -0
- memnos_sdk/corpus.py +279 -0
- memnos_sdk/exceptions.py +25 -0
- memnos_sdk/integrations/__init__.py +0 -0
- memnos_sdk/integrations/langchain.py +71 -0
- memnos_sdk/integrations/llamaindex.py +51 -0
- memnos_sdk/models.py +116 -0
- memnos_sdk-1.1.0.dist-info/METADATA +13 -0
- memnos_sdk-1.1.0.dist-info/RECORD +13 -0
- memnos_sdk-1.1.0.dist-info/WHEEL +5 -0
- memnos_sdk-1.1.0.dist-info/top_level.txt +1 -0
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))
|
memnos_sdk/exceptions.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
memnos_sdk
|