flux7-memory 0.5.0__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.
- flux7_memory-0.5.0/.gitignore +34 -0
- flux7_memory-0.5.0/PKG-INFO +26 -0
- flux7_memory-0.5.0/README.md +15 -0
- flux7_memory-0.5.0/pyproject.toml +19 -0
- flux7_memory-0.5.0/src/mem7/__init__.py +4 -0
- flux7_memory-0.5.0/src/mem7/client.py +226 -0
- flux7_memory-0.5.0/tests/__init__.py +0 -0
- flux7_memory-0.5.0/tests/test_client.py +281 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Build / OS
|
|
2
|
+
*.exe
|
|
3
|
+
*.dll
|
|
4
|
+
*.so
|
|
5
|
+
*.dylib
|
|
6
|
+
/mem7
|
|
7
|
+
.DS_Store
|
|
8
|
+
|
|
9
|
+
# IDE & local context
|
|
10
|
+
.claude
|
|
11
|
+
CLAUDE.md
|
|
12
|
+
.idea/
|
|
13
|
+
.vscode/
|
|
14
|
+
*.swp
|
|
15
|
+
*.swo
|
|
16
|
+
|
|
17
|
+
# Internal / strategic docs (not for public repo)
|
|
18
|
+
docs/internal/
|
|
19
|
+
|
|
20
|
+
# Go
|
|
21
|
+
vendor/
|
|
22
|
+
|
|
23
|
+
# Environment
|
|
24
|
+
.env
|
|
25
|
+
.env.*
|
|
26
|
+
|
|
27
|
+
# Debug / test artifacts
|
|
28
|
+
*.debug
|
|
29
|
+
*.test
|
|
30
|
+
|
|
31
|
+
# Internal bench harness (not for public repo)
|
|
32
|
+
/bench/
|
|
33
|
+
__pycache__/
|
|
34
|
+
dist/
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flux7-memory
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Python SDK for mem7 — governed memory substrate for AI agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/KTCrisis/flux7-memory
|
|
6
|
+
Project-URL: Repository, https://github.com/KTCrisis/flux7-memory
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# flux7-memory
|
|
13
|
+
|
|
14
|
+
Python client for [mem7](https://github.com/KTCrisis/flux7-memory) — governed memory substrate for AI agents.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install flux7-memory
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from mem7 import Mem7
|
|
22
|
+
|
|
23
|
+
m = Mem7("http://localhost:9070", token="my-token")
|
|
24
|
+
m.store("deploy.decision", "approved by ops lead", tags=["decision"], agent="supervisor")
|
|
25
|
+
results = m.search("deployment approval", limit=5)
|
|
26
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# flux7-memory
|
|
2
|
+
|
|
3
|
+
Python client for [mem7](https://github.com/KTCrisis/flux7-memory) — governed memory substrate for AI agents.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install flux7-memory
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from mem7 import Mem7
|
|
11
|
+
|
|
12
|
+
m = Mem7("http://localhost:9070", token="my-token")
|
|
13
|
+
m.store("deploy.decision", "approved by ops lead", tags=["decision"], agent="supervisor")
|
|
14
|
+
results = m.search("deployment approval", limit=5)
|
|
15
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flux7-memory"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "Python SDK for mem7 — governed memory substrate for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["requests>=2.28"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/KTCrisis/flux7-memory"
|
|
16
|
+
Repository = "https://github.com/KTCrisis/flux7-memory"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/mem7"]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""mem7 Python SDK — governed memory substrate for AI agents.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from mem7 import Mem7
|
|
6
|
+
|
|
7
|
+
m = Mem7("http://localhost:9070", token="my-token")
|
|
8
|
+
m.store("user.prefs", "prefers dark mode", tags=["user"])
|
|
9
|
+
results = m.search("dark mode", limit=5)
|
|
10
|
+
memories = m.context("dark mode", limit=5) # structured JSON
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Memory:
|
|
23
|
+
key: str
|
|
24
|
+
value: str
|
|
25
|
+
tags: list[str] = field(default_factory=list)
|
|
26
|
+
agent: str = ""
|
|
27
|
+
updated: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Mem7Error(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Mem7:
|
|
35
|
+
def __init__(self, url: str, token: str = "", timeout: int = 30) -> None:
|
|
36
|
+
self._url = url.rstrip("/")
|
|
37
|
+
self._token = token
|
|
38
|
+
self._timeout = timeout
|
|
39
|
+
self._session = requests.Session()
|
|
40
|
+
if token:
|
|
41
|
+
self._session.headers["Authorization"] = f"Bearer {token}"
|
|
42
|
+
self._session.headers["Content-Type"] = "application/json"
|
|
43
|
+
self._req_id = 0
|
|
44
|
+
|
|
45
|
+
def _call(self, tool: str, arguments: dict[str, Any]) -> Any:
|
|
46
|
+
self._req_id += 1
|
|
47
|
+
payload = {
|
|
48
|
+
"jsonrpc": "2.0",
|
|
49
|
+
"id": self._req_id,
|
|
50
|
+
"method": "tools/call",
|
|
51
|
+
"params": {"name": tool, "arguments": arguments},
|
|
52
|
+
}
|
|
53
|
+
resp = self._session.post(
|
|
54
|
+
f"{self._url}/rpc", json=payload, timeout=self._timeout
|
|
55
|
+
)
|
|
56
|
+
resp.raise_for_status()
|
|
57
|
+
data = resp.json()
|
|
58
|
+
if "error" in data and data["error"]:
|
|
59
|
+
raise Mem7Error(data["error"].get("message", str(data["error"])))
|
|
60
|
+
result = data.get("result", {})
|
|
61
|
+
if result.get("isError"):
|
|
62
|
+
text = result.get("content", [{}])[0].get("text", "unknown error")
|
|
63
|
+
raise Mem7Error(text)
|
|
64
|
+
content = result.get("content", [])
|
|
65
|
+
if content:
|
|
66
|
+
return content[0].get("text", "")
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
# ── Core tools ───────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def store(
|
|
72
|
+
self,
|
|
73
|
+
key: str,
|
|
74
|
+
value: str,
|
|
75
|
+
*,
|
|
76
|
+
tags: list[str] | None = None,
|
|
77
|
+
agent: str = "",
|
|
78
|
+
ttl: int = 0,
|
|
79
|
+
) -> str:
|
|
80
|
+
args: dict[str, Any] = {"key": key, "value": value}
|
|
81
|
+
if tags:
|
|
82
|
+
args["tags"] = tags
|
|
83
|
+
if agent:
|
|
84
|
+
args["agent"] = agent
|
|
85
|
+
if ttl > 0:
|
|
86
|
+
args["ttl"] = ttl
|
|
87
|
+
return self._call("memory_store", args)
|
|
88
|
+
|
|
89
|
+
def recall(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
key: str = "",
|
|
93
|
+
tags: list[str] | None = None,
|
|
94
|
+
agent: str = "",
|
|
95
|
+
limit: int = 10,
|
|
96
|
+
) -> str:
|
|
97
|
+
args: dict[str, Any] = {"limit": limit}
|
|
98
|
+
if key:
|
|
99
|
+
args["key"] = key
|
|
100
|
+
if tags:
|
|
101
|
+
args["tags"] = tags
|
|
102
|
+
if agent:
|
|
103
|
+
args["agent"] = agent
|
|
104
|
+
return self._call("memory_recall", args)
|
|
105
|
+
|
|
106
|
+
def search(
|
|
107
|
+
self,
|
|
108
|
+
query: str,
|
|
109
|
+
*,
|
|
110
|
+
mode: str = "natural",
|
|
111
|
+
tags: list[str] | None = None,
|
|
112
|
+
agent: str = "",
|
|
113
|
+
limit: int = 10,
|
|
114
|
+
include_neighbors: bool = False,
|
|
115
|
+
neighbor_radius: int = 1,
|
|
116
|
+
since: str = "",
|
|
117
|
+
until: str = "",
|
|
118
|
+
) -> str:
|
|
119
|
+
args: dict[str, Any] = {"query": query, "mode": mode, "limit": limit}
|
|
120
|
+
if tags:
|
|
121
|
+
args["tags"] = tags
|
|
122
|
+
if agent:
|
|
123
|
+
args["agent"] = agent
|
|
124
|
+
if include_neighbors:
|
|
125
|
+
args["include_neighbors"] = True
|
|
126
|
+
args["neighbor_radius"] = neighbor_radius
|
|
127
|
+
if since:
|
|
128
|
+
args["since"] = since
|
|
129
|
+
if until:
|
|
130
|
+
args["until"] = until
|
|
131
|
+
return self._call("memory_search", args)
|
|
132
|
+
|
|
133
|
+
def context(
|
|
134
|
+
self,
|
|
135
|
+
query: str,
|
|
136
|
+
*,
|
|
137
|
+
mode: str = "natural",
|
|
138
|
+
tags: list[str] | None = None,
|
|
139
|
+
agent: str = "",
|
|
140
|
+
limit: int = 10,
|
|
141
|
+
include_neighbors: bool = False,
|
|
142
|
+
neighbor_radius: int = 1,
|
|
143
|
+
since: str = "",
|
|
144
|
+
until: str = "",
|
|
145
|
+
) -> list[Memory]:
|
|
146
|
+
args: dict[str, Any] = {"query": query, "mode": mode, "limit": limit}
|
|
147
|
+
if tags:
|
|
148
|
+
args["tags"] = tags
|
|
149
|
+
if agent:
|
|
150
|
+
args["agent"] = agent
|
|
151
|
+
if include_neighbors:
|
|
152
|
+
args["include_neighbors"] = True
|
|
153
|
+
args["neighbor_radius"] = neighbor_radius
|
|
154
|
+
if since:
|
|
155
|
+
args["since"] = since
|
|
156
|
+
if until:
|
|
157
|
+
args["until"] = until
|
|
158
|
+
raw = self._call("memory_context", args)
|
|
159
|
+
items = json.loads(raw) if raw else []
|
|
160
|
+
return [
|
|
161
|
+
Memory(
|
|
162
|
+
key=it.get("key", ""),
|
|
163
|
+
value=it.get("value", ""),
|
|
164
|
+
tags=it.get("tags") or [],
|
|
165
|
+
agent=it.get("agent", ""),
|
|
166
|
+
updated=it.get("updated", ""),
|
|
167
|
+
)
|
|
168
|
+
for it in items
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
def get(self, path: str, *, from_line: int = 0, to_line: int = 0) -> str:
|
|
172
|
+
args: dict[str, Any] = {"path": path}
|
|
173
|
+
if from_line > 0:
|
|
174
|
+
args["from_line"] = from_line
|
|
175
|
+
if to_line > 0:
|
|
176
|
+
args["to_line"] = to_line
|
|
177
|
+
return self._call("memory_get", args)
|
|
178
|
+
|
|
179
|
+
def list(
|
|
180
|
+
self, *, tags: list[str] | None = None, agent: str = ""
|
|
181
|
+
) -> str:
|
|
182
|
+
args: dict[str, Any] = {}
|
|
183
|
+
if tags:
|
|
184
|
+
args["tags"] = tags
|
|
185
|
+
if agent:
|
|
186
|
+
args["agent"] = agent
|
|
187
|
+
return self._call("memory_list", args)
|
|
188
|
+
|
|
189
|
+
def forget(self, *, key: str = "", tags: list[str] | None = None) -> str:
|
|
190
|
+
args: dict[str, Any] = {}
|
|
191
|
+
if key:
|
|
192
|
+
args["key"] = key
|
|
193
|
+
if tags:
|
|
194
|
+
args["tags"] = tags
|
|
195
|
+
return self._call("memory_forget", args)
|
|
196
|
+
|
|
197
|
+
# ── Convenience ──────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def health(self) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
resp = self._session.get(
|
|
202
|
+
f"{self._url}/healthz", timeout=self._timeout
|
|
203
|
+
)
|
|
204
|
+
return resp.status_code == 200
|
|
205
|
+
except requests.RequestException:
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
def context_block(
|
|
209
|
+
self,
|
|
210
|
+
query: str,
|
|
211
|
+
*,
|
|
212
|
+
limit: int = 10,
|
|
213
|
+
**kwargs: Any,
|
|
214
|
+
) -> str:
|
|
215
|
+
"""Search and return a formatted text block ready for LLM prompt injection."""
|
|
216
|
+
memories = self.context(query, limit=limit, **kwargs)
|
|
217
|
+
if not memories:
|
|
218
|
+
return ""
|
|
219
|
+
parts = []
|
|
220
|
+
for m in memories:
|
|
221
|
+
header = f"[{m.key}]" if m.key else ""
|
|
222
|
+
if header:
|
|
223
|
+
parts.append(f"{header}\n{m.value}")
|
|
224
|
+
else:
|
|
225
|
+
parts.append(m.value)
|
|
226
|
+
return "\n\n".join(parts)
|
|
File without changes
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Tests for the mem7 Python SDK client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from mem7 import Mem7, Memory
|
|
10
|
+
from mem7.client import Mem7Error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _rpc_ok(text: str = "") -> dict:
|
|
14
|
+
return {
|
|
15
|
+
"jsonrpc": "2.0",
|
|
16
|
+
"id": 1,
|
|
17
|
+
"result": {
|
|
18
|
+
"content": [{"type": "text", "text": text}],
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _rpc_tool_error(msg: str) -> dict:
|
|
24
|
+
return {
|
|
25
|
+
"jsonrpc": "2.0",
|
|
26
|
+
"id": 1,
|
|
27
|
+
"result": {
|
|
28
|
+
"isError": True,
|
|
29
|
+
"content": [{"type": "text", "text": msg}],
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _rpc_error(code: int, msg: str) -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"jsonrpc": "2.0",
|
|
37
|
+
"id": 1,
|
|
38
|
+
"error": {"code": code, "message": msg},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def client():
|
|
44
|
+
return Mem7("http://localhost:9070", token="test-token")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestInit:
|
|
48
|
+
def test_url_trailing_slash_stripped(self):
|
|
49
|
+
m = Mem7("http://localhost:9070/")
|
|
50
|
+
assert m._url == "http://localhost:9070"
|
|
51
|
+
|
|
52
|
+
def test_auth_header_set(self):
|
|
53
|
+
m = Mem7("http://localhost:9070", token="abc")
|
|
54
|
+
assert m._session.headers["Authorization"] == "Bearer abc"
|
|
55
|
+
|
|
56
|
+
def test_no_auth_header_without_token(self):
|
|
57
|
+
m = Mem7("http://localhost:9070")
|
|
58
|
+
assert "Authorization" not in m._session.headers
|
|
59
|
+
|
|
60
|
+
def test_content_type_set(self):
|
|
61
|
+
m = Mem7("http://localhost:9070")
|
|
62
|
+
assert m._session.headers["Content-Type"] == "application/json"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class TestCall:
|
|
66
|
+
def test_successful_call(self, client):
|
|
67
|
+
mock_resp = MagicMock()
|
|
68
|
+
mock_resp.json.return_value = _rpc_ok("done")
|
|
69
|
+
mock_resp.raise_for_status = MagicMock()
|
|
70
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
71
|
+
result = client._call("memory_store", {"key": "k", "value": "v"})
|
|
72
|
+
assert result == "done"
|
|
73
|
+
call_args = mock_post.call_args
|
|
74
|
+
assert call_args[0][0] == "http://localhost:9070/rpc"
|
|
75
|
+
payload = call_args[1]["json"]
|
|
76
|
+
assert payload["method"] == "tools/call"
|
|
77
|
+
assert payload["params"]["name"] == "memory_store"
|
|
78
|
+
|
|
79
|
+
def test_rpc_error_raises(self, client):
|
|
80
|
+
mock_resp = MagicMock()
|
|
81
|
+
mock_resp.json.return_value = _rpc_error(-32601, "method not found")
|
|
82
|
+
mock_resp.raise_for_status = MagicMock()
|
|
83
|
+
with patch.object(client._session, "post", return_value=mock_resp):
|
|
84
|
+
with pytest.raises(Mem7Error, match="method not found"):
|
|
85
|
+
client._call("bad_method", {})
|
|
86
|
+
|
|
87
|
+
def test_tool_error_raises(self, client):
|
|
88
|
+
mock_resp = MagicMock()
|
|
89
|
+
mock_resp.json.return_value = _rpc_tool_error("unknown tool: nope")
|
|
90
|
+
mock_resp.raise_for_status = MagicMock()
|
|
91
|
+
with patch.object(client._session, "post", return_value=mock_resp):
|
|
92
|
+
with pytest.raises(Mem7Error, match="unknown tool"):
|
|
93
|
+
client._call("nope", {})
|
|
94
|
+
|
|
95
|
+
def test_empty_result(self, client):
|
|
96
|
+
mock_resp = MagicMock()
|
|
97
|
+
mock_resp.json.return_value = {"jsonrpc": "2.0", "id": 1, "result": {}}
|
|
98
|
+
mock_resp.raise_for_status = MagicMock()
|
|
99
|
+
with patch.object(client._session, "post", return_value=mock_resp):
|
|
100
|
+
assert client._call("something", {}) == ""
|
|
101
|
+
|
|
102
|
+
def test_request_id_increments(self, client):
|
|
103
|
+
mock_resp = MagicMock()
|
|
104
|
+
mock_resp.json.return_value = _rpc_ok("")
|
|
105
|
+
mock_resp.raise_for_status = MagicMock()
|
|
106
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
107
|
+
client._call("a", {})
|
|
108
|
+
client._call("b", {})
|
|
109
|
+
ids = [c[1]["json"]["id"] for c in mock_post.call_args_list]
|
|
110
|
+
assert ids == [1, 2]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestStore:
|
|
114
|
+
def test_minimal(self, client):
|
|
115
|
+
mock_resp = MagicMock()
|
|
116
|
+
mock_resp.json.return_value = _rpc_ok("created")
|
|
117
|
+
mock_resp.raise_for_status = MagicMock()
|
|
118
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
119
|
+
result = client.store("k", "v")
|
|
120
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
121
|
+
assert args == {"key": "k", "value": "v"}
|
|
122
|
+
assert result == "created"
|
|
123
|
+
|
|
124
|
+
def test_all_params(self, client):
|
|
125
|
+
mock_resp = MagicMock()
|
|
126
|
+
mock_resp.json.return_value = _rpc_ok("created")
|
|
127
|
+
mock_resp.raise_for_status = MagicMock()
|
|
128
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
129
|
+
client.store("k", "v", tags=["a"], agent="bot", ttl=3600)
|
|
130
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
131
|
+
assert args == {"key": "k", "value": "v", "tags": ["a"], "agent": "bot", "ttl": 3600}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class TestSearch:
|
|
135
|
+
def test_minimal(self, client):
|
|
136
|
+
mock_resp = MagicMock()
|
|
137
|
+
mock_resp.json.return_value = _rpc_ok("results")
|
|
138
|
+
mock_resp.raise_for_status = MagicMock()
|
|
139
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
140
|
+
client.search("dark mode")
|
|
141
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
142
|
+
assert args["query"] == "dark mode"
|
|
143
|
+
assert args["mode"] == "natural"
|
|
144
|
+
assert args["limit"] == 10
|
|
145
|
+
|
|
146
|
+
def test_with_neighbors(self, client):
|
|
147
|
+
mock_resp = MagicMock()
|
|
148
|
+
mock_resp.json.return_value = _rpc_ok("results")
|
|
149
|
+
mock_resp.raise_for_status = MagicMock()
|
|
150
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
151
|
+
client.search("q", include_neighbors=True, neighbor_radius=3)
|
|
152
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
153
|
+
assert args["include_neighbors"] is True
|
|
154
|
+
assert args["neighbor_radius"] == 3
|
|
155
|
+
|
|
156
|
+
def test_with_time_range(self, client):
|
|
157
|
+
mock_resp = MagicMock()
|
|
158
|
+
mock_resp.json.return_value = _rpc_ok("results")
|
|
159
|
+
mock_resp.raise_for_status = MagicMock()
|
|
160
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
161
|
+
client.search("q", since="2026-01-01", until="2026-05-01")
|
|
162
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
163
|
+
assert args["since"] == "2026-01-01"
|
|
164
|
+
assert args["until"] == "2026-05-01"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class TestContext:
|
|
168
|
+
def test_returns_memory_objects(self, client):
|
|
169
|
+
items = [
|
|
170
|
+
{"key": "user.prefs", "value": "dark mode", "tags": ["user"], "agent": "bot", "updated": "2026-05-07T18:00:00Z"},
|
|
171
|
+
{"key": "user.role", "value": "engineer", "tags": [], "agent": "", "updated": "2026-05-07T17:00:00Z"},
|
|
172
|
+
]
|
|
173
|
+
mock_resp = MagicMock()
|
|
174
|
+
mock_resp.json.return_value = _rpc_ok(json.dumps(items))
|
|
175
|
+
mock_resp.raise_for_status = MagicMock()
|
|
176
|
+
with patch.object(client._session, "post", return_value=mock_resp):
|
|
177
|
+
memories = client.context("prefs")
|
|
178
|
+
assert len(memories) == 2
|
|
179
|
+
assert isinstance(memories[0], Memory)
|
|
180
|
+
assert memories[0].key == "user.prefs"
|
|
181
|
+
assert memories[0].value == "dark mode"
|
|
182
|
+
assert memories[0].tags == ["user"]
|
|
183
|
+
assert memories[0].agent == "bot"
|
|
184
|
+
assert memories[0].updated == "2026-05-07T18:00:00Z"
|
|
185
|
+
assert memories[1].tags == []
|
|
186
|
+
|
|
187
|
+
def test_empty_result(self, client):
|
|
188
|
+
mock_resp = MagicMock()
|
|
189
|
+
mock_resp.json.return_value = _rpc_ok("")
|
|
190
|
+
mock_resp.raise_for_status = MagicMock()
|
|
191
|
+
with patch.object(client._session, "post", return_value=mock_resp):
|
|
192
|
+
memories = client.context("nothing")
|
|
193
|
+
assert memories == []
|
|
194
|
+
|
|
195
|
+
def test_calls_memory_context_tool(self, client):
|
|
196
|
+
mock_resp = MagicMock()
|
|
197
|
+
mock_resp.json.return_value = _rpc_ok("[]")
|
|
198
|
+
mock_resp.raise_for_status = MagicMock()
|
|
199
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
200
|
+
client.context("q")
|
|
201
|
+
assert mock_post.call_args[1]["json"]["params"]["name"] == "memory_context"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class TestContextBlock:
|
|
205
|
+
def test_formats_memories(self, client):
|
|
206
|
+
items = [
|
|
207
|
+
{"key": "a.b", "value": "hello world", "tags": [], "agent": "", "updated": ""},
|
|
208
|
+
{"key": "c.d", "value": "foo bar", "tags": [], "agent": "", "updated": ""},
|
|
209
|
+
]
|
|
210
|
+
mock_resp = MagicMock()
|
|
211
|
+
mock_resp.json.return_value = _rpc_ok(json.dumps(items))
|
|
212
|
+
mock_resp.raise_for_status = MagicMock()
|
|
213
|
+
with patch.object(client._session, "post", return_value=mock_resp):
|
|
214
|
+
block = client.context_block("q")
|
|
215
|
+
assert "[a.b]" in block
|
|
216
|
+
assert "hello world" in block
|
|
217
|
+
assert "[c.d]" in block
|
|
218
|
+
|
|
219
|
+
def test_empty_returns_empty_string(self, client):
|
|
220
|
+
mock_resp = MagicMock()
|
|
221
|
+
mock_resp.json.return_value = _rpc_ok("")
|
|
222
|
+
mock_resp.raise_for_status = MagicMock()
|
|
223
|
+
with patch.object(client._session, "post", return_value=mock_resp):
|
|
224
|
+
assert client.context_block("nothing") == ""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TestOtherTools:
|
|
228
|
+
def test_recall(self, client):
|
|
229
|
+
mock_resp = MagicMock()
|
|
230
|
+
mock_resp.json.return_value = _rpc_ok("recalled")
|
|
231
|
+
mock_resp.raise_for_status = MagicMock()
|
|
232
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
233
|
+
client.recall(key="k", tags=["t"], agent="a", limit=5)
|
|
234
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
235
|
+
assert args == {"key": "k", "tags": ["t"], "agent": "a", "limit": 5}
|
|
236
|
+
|
|
237
|
+
def test_get(self, client):
|
|
238
|
+
mock_resp = MagicMock()
|
|
239
|
+
mock_resp.json.return_value = _rpc_ok("content")
|
|
240
|
+
mock_resp.raise_for_status = MagicMock()
|
|
241
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
242
|
+
client.get("user.prefs", from_line=1, to_line=10)
|
|
243
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
244
|
+
assert args == {"path": "user.prefs", "from_line": 1, "to_line": 10}
|
|
245
|
+
|
|
246
|
+
def test_list(self, client):
|
|
247
|
+
mock_resp = MagicMock()
|
|
248
|
+
mock_resp.json.return_value = _rpc_ok("2 memories")
|
|
249
|
+
mock_resp.raise_for_status = MagicMock()
|
|
250
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
251
|
+
client.list(tags=["user"])
|
|
252
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
253
|
+
assert args == {"tags": ["user"]}
|
|
254
|
+
|
|
255
|
+
def test_forget(self, client):
|
|
256
|
+
mock_resp = MagicMock()
|
|
257
|
+
mock_resp.json.return_value = _rpc_ok("removed")
|
|
258
|
+
mock_resp.raise_for_status = MagicMock()
|
|
259
|
+
with patch.object(client._session, "post", return_value=mock_resp) as mock_post:
|
|
260
|
+
client.forget(key="k")
|
|
261
|
+
args = mock_post.call_args[1]["json"]["params"]["arguments"]
|
|
262
|
+
assert args == {"key": "k"}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class TestHealth:
|
|
266
|
+
def test_healthy(self, client):
|
|
267
|
+
mock_resp = MagicMock()
|
|
268
|
+
mock_resp.status_code = 200
|
|
269
|
+
with patch.object(client._session, "get", return_value=mock_resp):
|
|
270
|
+
assert client.health() is True
|
|
271
|
+
|
|
272
|
+
def test_unhealthy(self, client):
|
|
273
|
+
mock_resp = MagicMock()
|
|
274
|
+
mock_resp.status_code = 500
|
|
275
|
+
with patch.object(client._session, "get", return_value=mock_resp):
|
|
276
|
+
assert client.health() is False
|
|
277
|
+
|
|
278
|
+
def test_connection_error(self, client):
|
|
279
|
+
import requests
|
|
280
|
+
with patch.object(client._session, "get", side_effect=requests.ConnectionError):
|
|
281
|
+
assert client.health() is False
|