thrindex 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: thrindex
3
+ Version: 0.2.1
4
+ Summary: The memory OS for AI agents — persistent memory, semantic search and LLM extraction.
5
+ Author-email: Thrindex <hello@thrindex.com>
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://thrindex.com
8
+ Project-URL: Documentation, https://docs.thrindex.com
9
+ Project-URL: Repository, https://github.com/thrindex/thrindex.git
10
+ Keywords: ai,agents,memory,llm,openai,anthropic,langchain,semantic-search,rag
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ Requires-Dist: httpx>=0.28.0
22
+ Requires-Dist: pydantic>=2.10.0
23
+ Provides-Extra: openai
24
+ Requires-Dist: openai>=1.0.0; extra == "openai"
25
+ Provides-Extra: anthropic
26
+ Requires-Dist: anthropic>=0.20.0; extra == "anthropic"
27
+ Provides-Extra: langchain
28
+ Requires-Dist: langchain-core>=0.2.0; extra == "langchain"
29
+ Provides-Extra: llamaindex
30
+ Requires-Dist: llama-index-core>=0.10.0; extra == "llamaindex"
31
+ Provides-Extra: all
32
+ Requires-Dist: openai>=1.0.0; extra == "all"
33
+ Requires-Dist: anthropic>=0.20.0; extra == "all"
34
+ Requires-Dist: langchain-core>=0.2.0; extra == "all"
35
+ Requires-Dist: llama-index-core>=0.10.0; extra == "all"
36
+ Provides-Extra: dev
37
+ Requires-Dist: ruff>=0.9.0; extra == "dev"
38
+ Requires-Dist: mypy>=1.13.0; extra == "dev"
39
+ Requires-Dist: pytest>=8.3.0; extra == "dev"
40
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
41
+ Requires-Dist: anyio[trio]>=4.0.0; extra == "dev"
@@ -0,0 +1,79 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "thrindex"
7
+ version = "0.2.1"
8
+ description = "The memory OS for AI agents — persistent memory, semantic search and LLM extraction."
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Thrindex", email = "hello@thrindex.com" }]
13
+ keywords = ["ai", "agents", "memory", "llm", "openai", "anthropic", "langchain", "semantic-search", "rag"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ ]
24
+ dependencies = [
25
+ "httpx>=0.28.0",
26
+ "pydantic>=2.10.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://thrindex.com"
31
+ Documentation = "https://docs.thrindex.com"
32
+ Repository = "https://github.com/thrindex/thrindex.git"
33
+
34
+ [project.optional-dependencies]
35
+ openai = [
36
+ "openai>=1.0.0",
37
+ ]
38
+ anthropic = [
39
+ "anthropic>=0.20.0",
40
+ ]
41
+ langchain = [
42
+ "langchain-core>=0.2.0",
43
+ ]
44
+ llamaindex = [
45
+ "llama-index-core>=0.10.0",
46
+ ]
47
+ all = [
48
+ "openai>=1.0.0",
49
+ "anthropic>=0.20.0",
50
+ "langchain-core>=0.2.0",
51
+ "llama-index-core>=0.10.0",
52
+ ]
53
+ dev = [
54
+ "ruff>=0.9.0",
55
+ "mypy>=1.13.0",
56
+ "pytest>=8.3.0",
57
+ "pytest-asyncio>=0.24.0",
58
+ "anyio[trio]>=4.0.0",
59
+ ]
60
+
61
+ [tool.setuptools.packages.find]
62
+ where = ["."]
63
+ include = ["thrindex*"]
64
+
65
+ [tool.ruff]
66
+ line-length = 100
67
+ target-version = "py310"
68
+
69
+ [tool.ruff.lint]
70
+ select = ["E", "F", "I", "UP"]
71
+ ignore = []
72
+
73
+ [tool.mypy]
74
+ strict = true
75
+ python_version = "3.10"
76
+
77
+ [tool.pytest.ini_options]
78
+ testpaths = ["tests"]
79
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,317 @@
1
+ """Unit tests for the async thrindex SDK client.
2
+
3
+ Mirrors the sync client tests (test_client.py) but exercises AsyncClient.
4
+ Uses httpx.MockTransport via pytest-asyncio — no real network calls made.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections import deque
11
+ from typing import Any
12
+
13
+ import httpx
14
+ import pytest
15
+
16
+ import thrindex
17
+ from thrindex.async_client import AsyncThrindex
18
+ from thrindex.client import AuthError, NotFoundError, RateLimitError, ThrindexError
19
+ from thrindex.models import ListResponse, MemoryDetail
20
+
21
+
22
+ def _transport(*responses: httpx.Response) -> httpx.MockTransport:
23
+ queue: deque[httpx.Response] = deque(responses)
24
+
25
+ def handler(request: httpx.Request) -> httpx.Response:
26
+ if queue:
27
+ return queue.popleft()
28
+ raise httpx.RequestError("no more mock responses")
29
+
30
+ return httpx.MockTransport(handler)
31
+
32
+
33
+ def _json(body: Any, status: int = 200) -> httpx.Response:
34
+ return httpx.Response(
35
+ status_code=status,
36
+ headers={"Content-Type": "application/json"},
37
+ content=json.dumps(body).encode(),
38
+ )
39
+
40
+
41
+ _RECORD = {
42
+ "id": "mem-1",
43
+ "org_id": "org-1",
44
+ "agent_id": "agent-1",
45
+ "user_id": "user-1",
46
+ "level": 0,
47
+ "status": "active",
48
+ "importance": 0.7,
49
+ "confidence": 0.9,
50
+ "source": "agent",
51
+ "created_at": "2026-01-01T00:00:00Z",
52
+ "updated_at": "2026-01-01T00:00:00Z",
53
+ }
54
+
55
+ # ── add() ──────────────────────────────────────────────────────────────────────
56
+
57
+
58
+ async def test_async_add_success() -> None:
59
+ transport = _transport(_json({"id": "mem-123", "status": "queued"}, 202))
60
+ async with AsyncThrindex("mk_live_test", base_url="http://localhost", transport=transport) as client:
61
+ memory_id = await client.add("User likes cats", agent_id="agent-1", user_id="user-1")
62
+ assert memory_id == "mem-123"
63
+
64
+
65
+ async def test_async_add_three_lines() -> None:
66
+ transport = _transport(_json({"id": "abc", "status": "queued"}, 202))
67
+ async with AsyncThrindex("mk_live_test", base_url="http://localhost", transport=transport) as client:
68
+ memory_id = await client.add("fact", agent_id="a", user_id="u")
69
+ assert isinstance(memory_id, str)
70
+
71
+
72
+ async def test_async_add_retries_on_5xx() -> None:
73
+ transport = _transport(
74
+ httpx.Response(500, content=b"error"),
75
+ httpx.Response(503, content=b"unavailable"),
76
+ _json({"id": "mem-ok", "status": "queued"}, 202),
77
+ )
78
+ async with AsyncThrindex(
79
+ "mk_live_test", base_url="http://localhost", transport=transport, max_retries=3
80
+ ) as client:
81
+ memory_id = await client.add("fact", agent_id="a", user_id="u")
82
+ assert memory_id == "mem-ok"
83
+
84
+
85
+ async def test_async_add_raises_after_max_retries() -> None:
86
+ transport = _transport(
87
+ httpx.Response(500, content=b"err"),
88
+ httpx.Response(500, content=b"err"),
89
+ httpx.Response(500, content=b"err"),
90
+ httpx.Response(500, content=b"err"),
91
+ )
92
+ async with AsyncThrindex(
93
+ "mk_live_test", base_url="http://localhost", transport=transport, max_retries=2
94
+ ) as client:
95
+ with pytest.raises(ThrindexError):
96
+ await client.add("fact", agent_id="a", user_id="u")
97
+
98
+
99
+ async def test_async_add_raises_on_401() -> None:
100
+ body = {"error": {"code": "unauthorized", "message": "invalid api key"}}
101
+ async with AsyncThrindex(
102
+ "mk_live_bad_key", base_url="http://localhost", transport=_transport(_json(body, 401))
103
+ ) as client:
104
+ with pytest.raises(AuthError):
105
+ await client.add("fact", agent_id="a", user_id="u")
106
+
107
+
108
+ async def test_async_add_respects_retry_after_on_429() -> None:
109
+ transport = _transport(
110
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"rate limited"),
111
+ _json({"id": "mem-ok", "status": "queued"}, 202),
112
+ )
113
+ async with AsyncThrindex(
114
+ "mk_live_test", base_url="http://localhost", transport=transport, max_retries=2
115
+ ) as client:
116
+ memory_id = await client.add("fact", agent_id="a", user_id="u")
117
+ assert memory_id == "mem-ok"
118
+
119
+
120
+ async def test_async_add_raises_rate_limit_after_exhausted_retries() -> None:
121
+ transport = _transport(
122
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
123
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
124
+ )
125
+ async with AsyncThrindex(
126
+ "mk_live_test", base_url="http://localhost", transport=transport, max_retries=1
127
+ ) as client:
128
+ with pytest.raises(RateLimitError) as exc_info:
129
+ await client.add("fact", agent_id="a", user_id="u")
130
+ assert exc_info.value.status_code == 429
131
+
132
+
133
+ # ── search() ──────────────────────────────────────────────────────────────────
134
+
135
+
136
+ async def test_async_search_success() -> None:
137
+ body = {
138
+ "results": [
139
+ {
140
+ "id": "mem-1",
141
+ "content": "User likes cats",
142
+ "agent_id": "agent-1",
143
+ "user_id": "user-1",
144
+ "level": 0,
145
+ "status": "active",
146
+ "importance": 0.8,
147
+ "confidence": 1.0,
148
+ "score": 0.9,
149
+ "created_at": "2026-01-01T00:00:00Z",
150
+ }
151
+ ],
152
+ "latency_ms": 12,
153
+ "cache_hit": True,
154
+ }
155
+ async with AsyncThrindex(
156
+ "mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
157
+ ) as client:
158
+ results = await client.search("cat preferences", agent_id="agent-1", user_id="user-1")
159
+ assert len(results) == 1
160
+ assert results[0].id == "mem-1"
161
+ assert results[0].score == pytest.approx(0.9)
162
+
163
+
164
+ async def test_async_search_empty() -> None:
165
+ body = {"results": [], "latency_ms": 5, "cache_hit": False}
166
+ async with AsyncThrindex(
167
+ "mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
168
+ ) as client:
169
+ results = await client.search("query", agent_id="a", user_id="u")
170
+ assert results == []
171
+
172
+
173
+ # ── list() ────────────────────────────────────────────────────────────────────
174
+
175
+
176
+ async def test_async_list_success() -> None:
177
+ body = {"results": [_RECORD], "next_cursor": "tok-xyz"}
178
+ async with AsyncThrindex(
179
+ "mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
180
+ ) as client:
181
+ resp = await client.list(agent_id="agent-1", user_id="user-1")
182
+ assert isinstance(resp, ListResponse)
183
+ assert resp.results[0].id == "mem-1"
184
+ assert resp.next_cursor == "tok-xyz"
185
+
186
+
187
+ async def test_async_list_empty() -> None:
188
+ body = {"results": [], "next_cursor": None}
189
+ async with AsyncThrindex(
190
+ "mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
191
+ ) as client:
192
+ resp = await client.list()
193
+ assert resp.results == []
194
+ assert resp.next_cursor is None
195
+
196
+
197
+ # ── batch_add() ───────────────────────────────────────────────────────────────
198
+
199
+
200
+ async def test_async_batch_add() -> None:
201
+ from collections import deque as _deque
202
+
203
+ transport = _transport(
204
+ _json({"id": "id-1", "status": "queued"}, 202),
205
+ _json({"id": "id-2", "status": "queued"}, 202),
206
+ _json({"id": "id-3", "status": "queued"}, 202),
207
+ )
208
+ async with AsyncThrindex(
209
+ "mk_live_test", base_url="http://localhost", transport=transport
210
+ ) as client:
211
+ ids = await client.batch_add(
212
+ [{"content": "a"}, {"content": "b"}, {"content": "c"}],
213
+ agent_id="agent-1",
214
+ user_id="user-1",
215
+ )
216
+ assert ids == ["id-1", "id-2", "id-3"]
217
+
218
+
219
+ # ── get() ─────────────────────────────────────────────────────────────────────
220
+
221
+
222
+ async def test_async_get_returns_memory_detail() -> None:
223
+ body = {**_RECORD, "content": "User prefers dark mode"}
224
+ async with AsyncThrindex(
225
+ "mk_live_test", base_url="http://localhost", transport=_transport(_json(body))
226
+ ) as client:
227
+ detail = await client.get("mem-1")
228
+ assert isinstance(detail, MemoryDetail)
229
+ assert detail.content == "User prefers dark mode"
230
+
231
+
232
+ async def test_async_get_raises_not_found() -> None:
233
+ body = {"error": {"code": "not_found", "message": "memory not found"}}
234
+ async with AsyncThrindex(
235
+ "mk_live_test", base_url="http://localhost", transport=_transport(_json(body, 404))
236
+ ) as client:
237
+ with pytest.raises(NotFoundError):
238
+ await client.get("nonexistent")
239
+
240
+
241
+ # ── delete() ──────────────────────────────────────────────────────────────────
242
+
243
+
244
+ async def test_async_delete_soft() -> None:
245
+ async with AsyncThrindex(
246
+ "mk_live_test",
247
+ base_url="http://localhost",
248
+ transport=_transport(httpx.Response(204)),
249
+ ) as client:
250
+ await client.delete("mem-1") # must not raise
251
+
252
+
253
+ async def test_async_delete_hard() -> None:
254
+ async with AsyncThrindex(
255
+ "mk_live_test",
256
+ base_url="http://localhost",
257
+ transport=_transport(httpx.Response(204)),
258
+ ) as client:
259
+ await client.delete("mem-1", hard=True)
260
+
261
+
262
+ async def test_async_delete_not_found() -> None:
263
+ body = {"error": {"code": "not_found", "message": "memory not found"}}
264
+ async with AsyncThrindex(
265
+ "mk_live_test",
266
+ base_url="http://localhost",
267
+ transport=_transport(_json(body, 404)),
268
+ ) as client:
269
+ with pytest.raises(NotFoundError):
270
+ await client.delete("nonexistent")
271
+
272
+
273
+ async def test_async_delete_raises_rate_limit_after_exhausted_retries() -> None:
274
+ transport = _transport(
275
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
276
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
277
+ )
278
+ async with AsyncThrindex(
279
+ "mk_live_test", base_url="http://localhost", transport=transport, max_retries=1
280
+ ) as client:
281
+ with pytest.raises(RateLimitError) as exc_info:
282
+ await client.delete("mem-1")
283
+ assert exc_info.value.status_code == 429
284
+
285
+
286
+ async def test_async_add_401_message_forwarded() -> None:
287
+ body = {"error": {"code": "forbidden", "message": "missing scope: memory:write"}}
288
+ async with AsyncThrindex(
289
+ "mk_live_readonly_key",
290
+ base_url="http://localhost",
291
+ transport=_transport(_json(body, 401)),
292
+ ) as client:
293
+ with pytest.raises(AuthError) as exc_info:
294
+ await client.add("fact", agent_id="a", user_id="u")
295
+ assert "missing scope" in str(exc_info.value)
296
+
297
+
298
+ # ── context manager ───────────────────────────────────────────────────────────
299
+
300
+
301
+ async def test_async_context_manager() -> None:
302
+ transport = _transport(_json({"id": "x", "status": "queued"}, 202))
303
+ async with AsyncThrindex("mk_live_test", base_url="http://localhost", transport=transport) as c:
304
+ mid = await c.add("test", agent_id="a", user_id="u")
305
+ assert mid == "x"
306
+
307
+
308
+ # ── validation ────────────────────────────────────────────────────────────────
309
+
310
+
311
+ def test_async_empty_api_key_raises() -> None:
312
+ with pytest.raises(ValueError, match="api_key"):
313
+ AsyncThrindex("")
314
+
315
+
316
+ def test_async_client_exported_from_top_level() -> None:
317
+ assert thrindex.AsyncThrindex is AsyncThrindex
@@ -0,0 +1,226 @@
1
+ """Unit tests for the thrindex SDK client.
2
+
3
+ Uses httpx.MockTransport so no real network calls are made.
4
+ Tests: happy path, retry logic, 429 handling, 401/404 errors, batch_add.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections import deque
11
+ from typing import Any
12
+
13
+ import httpx
14
+ import pytest
15
+
16
+ import thrindex
17
+ from thrindex.client import Thrindex, RateLimitError, ThrindexError, AuthError, NotFoundError
18
+
19
+
20
+ def _transport(*responses: httpx.Response) -> httpx.MockTransport:
21
+ """Return a MockTransport that replays the given response sequence."""
22
+ queue: deque[httpx.Response] = deque(responses)
23
+
24
+ def handler(request: httpx.Request) -> httpx.Response:
25
+ if queue:
26
+ return queue.popleft()
27
+ raise httpx.RequestError("no more responses") # test misconfiguration
28
+
29
+ return httpx.MockTransport(handler)
30
+
31
+
32
+ def _json_response(body: Any, status: int = 200) -> httpx.Response:
33
+ return httpx.Response(
34
+ status_code=status,
35
+ headers={"Content-Type": "application/json"},
36
+ content=json.dumps(body).encode(),
37
+ )
38
+
39
+
40
+ # ── add() ──────────────────────────────────────────────────────────────────────
41
+
42
+ def test_add_success() -> None:
43
+ transport = _transport(_json_response({"id": "mem-123", "status": "queued"}, 202))
44
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
45
+ memory_id = client.add("User likes cats", agent_id="agent-1", user_id="user-1")
46
+ assert memory_id == "mem-123"
47
+
48
+
49
+ def test_add_three_lines() -> None:
50
+ """Acceptance criterion: SDK works in three lines."""
51
+ transport = _transport(_json_response({"id": "abc", "status": "queued"}, 202))
52
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
53
+ memory_id = client.add("fact", agent_id="a", user_id="u")
54
+ assert isinstance(memory_id, str)
55
+
56
+
57
+ def test_add_retries_on_5xx() -> None:
58
+ transport = _transport(
59
+ httpx.Response(500, content=b"error"),
60
+ httpx.Response(503, content=b"unavailable"),
61
+ _json_response({"id": "mem-ok", "status": "queued"}, 202),
62
+ )
63
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=3)
64
+ memory_id = client.add("fact", agent_id="a", user_id="u")
65
+ assert memory_id == "mem-ok"
66
+
67
+
68
+ def test_add_raises_after_max_retries() -> None:
69
+ transport = _transport(
70
+ httpx.Response(500, content=b"err"),
71
+ httpx.Response(500, content=b"err"),
72
+ httpx.Response(500, content=b"err"),
73
+ httpx.Response(500, content=b"err"),
74
+ )
75
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=2)
76
+ with pytest.raises(ThrindexError):
77
+ client.add("fact", agent_id="a", user_id="u")
78
+
79
+
80
+ def test_add_raises_on_401() -> None:
81
+ body = {"error": {"code": "unauthorized", "message": "invalid api key"}}
82
+ transport = _transport(_json_response(body, 401))
83
+ client = Thrindex("mk_live_bad_key", base_url="http://localhost", transport=transport)
84
+ with pytest.raises(AuthError):
85
+ client.add("fact", agent_id="a", user_id="u")
86
+
87
+
88
+ def test_add_respects_retry_after_on_429() -> None:
89
+ transport = _transport(
90
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"rate limited"),
91
+ _json_response({"id": "mem-ok", "status": "queued"}, 202),
92
+ )
93
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=2)
94
+ memory_id = client.add("fact", agent_id="a", user_id="u")
95
+ assert memory_id == "mem-ok"
96
+
97
+
98
+ def test_add_raises_rate_limit_after_exhausted_retries() -> None:
99
+ transport = _transport(
100
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
101
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
102
+ )
103
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=1)
104
+ with pytest.raises(RateLimitError) as exc_info:
105
+ client.add("fact", agent_id="a", user_id="u")
106
+ assert exc_info.value.status_code == 429
107
+
108
+
109
+ # ── search() ──────────────────────────────────────────────────────────────────
110
+
111
+ def test_search_success() -> None:
112
+ body = {
113
+ "results": [
114
+ {
115
+ "id": "mem-1",
116
+ "content": "User likes cats",
117
+ "agent_id": "agent-1",
118
+ "user_id": "user-1",
119
+ "level": 0,
120
+ "status": "active",
121
+ "importance": 0.8,
122
+ "confidence": 1.0,
123
+ "score": 0.9,
124
+ "created_at": "2026-01-01T00:00:00Z",
125
+ }
126
+ ],
127
+ "latency_ms": 12,
128
+ "cache_hit": True,
129
+ }
130
+ transport = _transport(_json_response(body))
131
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
132
+ results = client.search("cat preferences", agent_id="agent-1", user_id="user-1")
133
+ assert len(results) == 1
134
+ assert results[0].id == "mem-1"
135
+ assert results[0].score == pytest.approx(0.9)
136
+
137
+
138
+ def test_search_three_lines() -> None:
139
+ """Acceptance criterion: search works in three lines."""
140
+ body = {"results": [], "latency_ms": 5, "cache_hit": False}
141
+ transport = _transport(_json_response(body))
142
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
143
+ results = client.search("query", agent_id="a", user_id="u")
144
+ assert isinstance(results, list)
145
+
146
+
147
+ # ── batch_add() ───────────────────────────────────────────────────────────────
148
+
149
+ def test_batch_add() -> None:
150
+ transport = _transport(
151
+ _json_response({"id": "id-1", "status": "queued"}, 202),
152
+ _json_response({"id": "id-2", "status": "queued"}, 202),
153
+ _json_response({"id": "id-3", "status": "queued"}, 202),
154
+ )
155
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
156
+ ids = client.batch_add(
157
+ [{"content": "a"}, {"content": "b"}, {"content": "c"}],
158
+ agent_id="agent-1",
159
+ user_id="user-1",
160
+ )
161
+ assert ids == ["id-1", "id-2", "id-3"]
162
+
163
+
164
+ # ── delete() ──────────────────────────────────────────────────────────────────
165
+
166
+ def test_delete_soft() -> None:
167
+ transport = _transport(httpx.Response(204))
168
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
169
+ client.delete("mem-1") # should not raise
170
+
171
+
172
+ def test_delete_hard() -> None:
173
+ transport = _transport(httpx.Response(204))
174
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
175
+ client.delete("mem-1", hard=True)
176
+
177
+
178
+ def test_delete_not_found() -> None:
179
+ body = {"error": {"code": "not_found", "message": "memory not found"}}
180
+ transport = _transport(_json_response(body, 404))
181
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport)
182
+ with pytest.raises(NotFoundError):
183
+ client.delete("nonexistent")
184
+
185
+
186
+ def test_delete_raises_rate_limit_after_exhausted_retries() -> None:
187
+ transport = _transport(
188
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
189
+ httpx.Response(429, headers={"Retry-After": "0"}, content=b"limited"),
190
+ )
191
+ client = Thrindex("mk_live_test", base_url="http://localhost", transport=transport, max_retries=1)
192
+ with pytest.raises(RateLimitError) as exc_info:
193
+ client.delete("mem-1")
194
+ assert exc_info.value.status_code == 429
195
+
196
+
197
+ def test_add_401_message_forwarded() -> None:
198
+ body = {"error": {"code": "forbidden", "message": "missing scope: memory:write"}}
199
+ transport = _transport(_json_response(body, 401))
200
+ client = Thrindex("mk_live_readonly_key", base_url="http://localhost", transport=transport)
201
+ with pytest.raises(AuthError) as exc_info:
202
+ client.add("fact", agent_id="a", user_id="u")
203
+ assert "missing scope" in str(exc_info.value)
204
+
205
+
206
+ # ── context manager ───────────────────────────────────────────────────────────
207
+
208
+ def test_context_manager() -> None:
209
+ transport = _transport(_json_response({"id": "x", "status": "queued"}, 202))
210
+ with Thrindex("mk_live_test", base_url="http://localhost", transport=transport) as c:
211
+ mid = c.add("test", agent_id="a", user_id="u")
212
+ assert mid == "x"
213
+
214
+
215
+ # ── validation ────────────────────────────────────────────────────────────────
216
+
217
+ def test_empty_api_key_raises() -> None:
218
+ with pytest.raises(ValueError, match="api_key"):
219
+ Thrindex("")
220
+
221
+
222
+ def test_imports() -> None:
223
+ """Public surface: Client and error types importable from top-level."""
224
+ assert thrindex.Thrindex is Thrindex
225
+ assert thrindex.ThrindexError is ThrindexError
226
+ assert thrindex.RateLimitError is RateLimitError