diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml new file mode 100644 index 00000000..9a8a28a7 --- /dev/null +++ b/.github/workflows/publish-python.yml @@ -0,0 +1,51 @@ +name: Publish agentmemory (Python) to PyPI + +# Mirrors .github/workflows/publish.yml (Node) but for the Python REST +# client at packages/python. Triggered by pushing a tag of the form +# python-v0.1.0 — the leading prefix keeps it separate from the Node +# package's release tags. Uses PyPI trusted publishers (OIDC); no API +# token needed. + +on: + push: + tags: + - "python-v*" + workflow_dispatch: + +permissions: + contents: read + id-token: write + +jobs: + build-and-publish: + runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/python + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tooling + run: | + python -m pip install --upgrade pip + python -m pip install build hatchling pytest pytest-asyncio respx httpx + + - name: Run tests + run: python -m pytest tests -q + + - name: Build sdist and wheel + run: python -m build + + - name: Inspect dist + run: ls -lah dist + + - name: Publish to PyPI via trusted publisher + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/python/dist + skip-existing: true diff --git a/.gitignore b/.gitignore index 9a9260b8..6295e776 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,11 @@ package-lock.json pnpm-lock.yaml yarn.lock integrations/hermes/__pycache__/ + +# Python build + cache artifacts (packages/python) +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +packages/python/dist/ +packages/python/build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8266e2ad..84c4bf01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- **`agentmemory-py` — thin Python REST client for the daemon at `:3111`** ([#342](https://github.com/rohitg00/agentmemory/issues/342)). New package at `packages/python/` exposing sync `Client` and async `AsyncClient` with identical surfaces: `remember`, `smart_search`, `memories`, `memory(id)`, `forget`, `semantic`, `procedural`, `relations`, `health`, plus a `request()` escape hatch for endpoints not yet wrapped. Responses are `TypedDict`-shaped (no `pydantic` dep); the only runtime dependency is `httpx`. The plaintext-bearer guard from #315 is ported byte-for-byte: warn-once on stderr when a bearer would cross plaintext HTTP to a non-loopback host, raise `AuthError` instead if `AGENTMEMORY_REQUIRE_HTTPS=1`. Loopback hosts (`localhost` / `127.0.0.1` / `::1`) and `https://` targets stay silent. Build backend is `hatchling`. Tests: 31 passing via `respx`-mocked `httpx`, plus one opt-in live test gated by `AGENTMEMORY_LIVE=1`. PyPI release via trusted publisher on tag `python-v*` (new `.github/workflows/publish-python.yml`). + ## [0.9.12] — 2026-05-13 Four landed PRs since v0.9.11 — one type-correctness fix, one search-quality fix (BM25 unicode + vector-index live-write), one viewer hardening (CSP-clean fonts + load-error surface), and one integrations security hardening (bearer token over plaintext HTTP). diff --git a/packages/python/LICENSE b/packages/python/LICENSE new file mode 100644 index 00000000..cb13c728 --- /dev/null +++ b/packages/python/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Rohit Ghumare + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/python/README.md b/packages/python/README.md new file mode 100644 index 00000000..5c47624a --- /dev/null +++ b/packages/python/README.md @@ -0,0 +1,101 @@ +# agentmemory (Python) + +Thin Python REST client for the [agentmemory](https://github.com/rohitg00/agentmemory) daemon. Speaks the daemon's HTTP surface at `:3111` and nothing more — embedding, BM25, vector indexing, and lifecycle all live in the daemon. This package is a transport wrapper with typed responses. + +## Install + +```bash +pip install agentmemory +``` + +Requires Python 3.10+. The only runtime dependency is `httpx`. + +## Quickstart + +```python +from agentmemory import Client + +c = Client(base_url="http://localhost:3111") + +c.remember( + content="iii-engine collapses workflow, queue, and agent runtimes into three primitives.", + project="iii", + title="Three primitives", + concepts=["iii-engine", "primitives"], +) + +result = c.smart_search(query="three primitives", project="iii", limit=10) +for hit in result.get("hits", []): + print(hit["memory"]["title"]) + +c.health() +``` + +Async variant with identical surface: + +```python +import asyncio +from agentmemory import AsyncClient + +async def main(): + async with AsyncClient() as a: + await a.remember(content="hello", project="x") + print(await a.health()) + +asyncio.run(main()) +``` + +## API surface + +| Method | HTTP | +| -------------------------------------- | ------------------------------------- | +| `health()` | `GET /agentmemory/livez` | +| `remember(content=..., ...)` | `POST /agentmemory/remember` | +| `smart_search(query=..., limit=...)` | `POST /agentmemory/smart-search` | +| `memories(project=..., latest=False)` | `GET /agentmemory/memories` | +| `memory(memory_id)` | `GET /agentmemory/memories/{id}` | +| `forget(memory_id=...)` | `POST /agentmemory/forget` | +| `semantic()` | `GET /agentmemory/semantic` | +| `procedural()` | `GET /agentmemory/procedural` | +| `relations()` | `GET /agentmemory/relations` | +| `request(method, path, json=..., ...)` | escape hatch for any other endpoint | + +The full daemon REST surface lives in [`src/triggers/api.ts`](https://github.com/rohitg00/agentmemory/blob/main/src/triggers/api.ts). Use `Client.request()` (or `AsyncClient.request()`) for endpoints not yet wrapped as typed methods. + +## Environment variables + +| Variable | Default | Effect | +| ---------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AGENTMEMORY_URL` | `http://localhost:3111` | Resolved when `base_url=None` is passed to `Client`/`AsyncClient`. | +| `AGENTMEMORY_SECRET` | (unset) | Resolved when `api_key=None` is passed. Sent as `Authorization: Bearer `. | +| `AGENTMEMORY_REQUIRE_HTTPS` | `0` | When `1`, refuse to send a bearer token over plaintext HTTP to a non-loopback host (raises `AuthError`). When unset, the client warns once on stderr and still sends. Mirrors the v0.9.12 daemon guard. | + +## Plaintext-bearer guard + +When a bearer token would cross plaintext HTTP to anything other than `localhost` / `127.0.0.1` / `::1`, the client behaves like the v0.9.12 plugin guard: + +- Default: one stderr warning per process, request proceeds. +- `AGENTMEMORY_REQUIRE_HTTPS=1`: `AuthError` raised at `Client` construction and before every request, no token is sent. + +Loopback bearer + plaintext HTTP is silent — the typical local-daemon setup is treated as safe. + +## Errors + +All errors derive from `AgentMemoryError`: + +- `AuthError` — `401`/`403` from the daemon, or guard refusal. +- `NetworkError` — connection refused, DNS failure, TLS error, timeout. +- `ResponseError` — non-2xx response with `status_code` and `body` attributes. + +## Testing + +```bash +pip install -e ".[dev]" +pytest packages/python/tests +``` + +Set `AGENTMEMORY_LIVE=1` to additionally hit a running daemon at `:3111` from `test_live_health_against_local_daemon`. + +## License + +Apache-2.0. Same as the daemon. diff --git a/packages/python/agentmemory/__init__.py b/packages/python/agentmemory/__init__.py new file mode 100644 index 00000000..86587a0a --- /dev/null +++ b/packages/python/agentmemory/__init__.py @@ -0,0 +1,469 @@ +"""Thin Python REST client for the agentmemory daemon. + +The package wraps the ``:3111`` HTTP surface served by the agentmemory +daemon and exposes a sync ``Client`` and an async ``AsyncClient`` with the +same method set. No embedding logic, no BM25, no vector index — the daemon +owns all of that; this is a transport wrapper with typed responses. + +Quickstart: + +.. code-block:: python + + from agentmemory import Client + + c = Client(base_url="http://localhost:3111") + c.remember(content="iii-engine uses three primitives", project="iii") + hits = c.smart_search(query="three primitives", project="iii", limit=10) + for h in hits.get("hits", []): + print(h["memory"]["title"]) + +See ``README.md`` for the full method list and environment-variable +reference (``AGENTMEMORY_URL``, ``AGENTMEMORY_SECRET``, +``AGENTMEMORY_REQUIRE_HTTPS``). +""" + +from __future__ import annotations + +from typing import Any, List, Mapping, Optional + +from ._exceptions import AgentMemoryError, AuthError, NetworkError, ResponseError +from ._http import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, HttpCore +from ._types import ( + ForgetResult, + HealthResponse, + JSONObject, + LivezResponse, + Memory, + MemoriesResponse, + MemoryRelation, + MemoryResponse, + ProceduralListResponse, + ProceduralMemory, + RelationsListResponse, + RememberResult, + SemanticListResponse, + SemanticMemory, + SmartSearchHit, + SmartSearchResult, +) + +__version__ = "0.1.0" +_USER_AGENT = f"agentmemory-py/{__version__}" + + +def _drop_none(payload: Mapping[str, Any]) -> dict: + """Strip keys whose value is ``None``. + + The daemon treats missing keys and explicit ``null`` values differently + for several endpoints; this matches the TypeScript client which only + sends fields the caller passed. + """ + + return {k: v for k, v in payload.items() if v is not None} + + +class Client: + """Synchronous REST client for the agentmemory daemon. + + All methods are thin wrappers around ``POST``/``GET`` of the + corresponding endpoint under ``/agentmemory/*``. Construction does not + open a network connection — the first method call does. Use as a + context manager to close the underlying ``httpx.Client`` deterministically. + """ + + def __init__( + self, + base_url: Optional[str] = None, + *, + api_key: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + self._core = HttpCore( + base_url=base_url, + api_key=api_key, + timeout=timeout, + user_agent=_USER_AGENT, + ) + + @property + def base_url(self) -> str: + return self._core.base_url + + def close(self) -> None: + self._core.close() + + def __enter__(self) -> "Client": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + def health(self) -> LivezResponse: + """Return the daemon's ``/livez`` snapshot. + + Calls ``GET /agentmemory/livez``. Returns the parsed JSON; raises + ``NetworkError`` if the daemon is unreachable. + """ + + _, body = self._core.request_sync("GET", "/agentmemory/livez") + return body # type: ignore[no-any-return] + + def remember( + self, + *, + content: str, + project: Optional[str] = None, + title: Optional[str] = None, + concepts: Optional[List[str]] = None, + type: Optional[str] = None, + files: Optional[List[str]] = None, + session_id: Optional[str] = None, + extra: Optional[Mapping[str, Any]] = None, + ) -> RememberResult: + """Persist a memory. Maps to ``POST /agentmemory/remember``. + + ``content`` is the only required field. Other fields mirror the + daemon's ``mem::remember`` payload; pass ``extra`` for forward- + compatibility with daemon versions newer than this client. + """ + + payload = _drop_none( + { + "content": content, + "project": project, + "title": title, + "concepts": concepts, + "type": type, + "files": files, + "sessionId": session_id, + } + ) + if extra: + payload.update(extra) + _, body = self._core.request_sync( + "POST", "/agentmemory/remember", json_body=payload + ) + return body # type: ignore[no-any-return] + + def smart_search( + self, + *, + query: Optional[str] = None, + project: Optional[str] = None, + limit: Optional[int] = None, + expand_ids: Optional[List[str]] = None, + extra: Optional[Mapping[str, Any]] = None, + ) -> SmartSearchResult: + """Hybrid BM25 + vector search. Maps to ``POST /agentmemory/smart-search``. + + At least one of ``query`` or ``expand_ids`` must be provided — + otherwise the daemon returns ``400``. + """ + + payload = _drop_none( + { + "query": query, + "project": project, + "limit": limit, + "expandIds": expand_ids, + } + ) + if extra: + payload.update(extra) + _, body = self._core.request_sync( + "POST", "/agentmemory/smart-search", json_body=payload + ) + return body # type: ignore[no-any-return] + + def memories( + self, + *, + project: Optional[str] = None, + latest: bool = False, + limit: Optional[int] = None, + ) -> MemoriesResponse: + """List memories. Maps to ``GET /agentmemory/memories``. + + ``latest=True`` filters to records with ``isLatest=true`` (the + post-consolidation head of each chain). + """ + + params: dict = {} + if project is not None: + params["project"] = project + if latest: + params["latest"] = "true" + if limit is not None: + params["limit"] = str(limit) + _, body = self._core.request_sync( + "GET", + "/agentmemory/memories", + params=params or None, + ) + return body # type: ignore[no-any-return] + + def memory(self, memory_id: str) -> MemoryResponse: + """Fetch one memory by id. Maps to ``GET /agentmemory/memories/{id}``.""" + + _, body = self._core.request_sync( + "GET", f"/agentmemory/memories/{memory_id}" + ) + return body # type: ignore[no-any-return] + + def forget( + self, + *, + memory_id: Optional[str] = None, + session_id: Optional[str] = None, + observation_ids: Optional[List[str]] = None, + ) -> ForgetResult: + """Forget a memory or session. Maps to ``POST /agentmemory/forget``. + + Either ``memory_id`` or ``session_id`` must be set — the daemon + returns ``400`` otherwise. + """ + + payload = _drop_none( + { + "memoryId": memory_id, + "sessionId": session_id, + "observationIds": observation_ids, + } + ) + _, body = self._core.request_sync( + "POST", "/agentmemory/forget", json_body=payload + ) + return body # type: ignore[no-any-return] + + def semantic(self) -> SemanticListResponse: + """List semantic memories. Maps to ``GET /agentmemory/semantic``.""" + + _, body = self._core.request_sync("GET", "/agentmemory/semantic") + return body # type: ignore[no-any-return] + + def procedural(self) -> ProceduralListResponse: + """List procedural memories. Maps to ``GET /agentmemory/procedural``.""" + + _, body = self._core.request_sync("GET", "/agentmemory/procedural") + return body # type: ignore[no-any-return] + + def relations(self) -> RelationsListResponse: + """List memory relations. Maps to ``GET /agentmemory/relations``.""" + + _, body = self._core.request_sync("GET", "/agentmemory/relations") + return body # type: ignore[no-any-return] + + def request( + self, + method: str, + path: str, + *, + json: Optional[Any] = None, + params: Optional[Mapping[str, Any]] = None, + ) -> JSONObject: + """Escape hatch for endpoints not yet wrapped. + + ``path`` may be absolute (``/agentmemory/foo``) or relative + (``foo``). Useful for new daemon endpoints before the client is + updated to expose them as typed methods. + """ + + if not path.startswith("/"): + path = f"/agentmemory/{path}" + _, body = self._core.request_sync(method, path, json_body=json, params=params) + return body # type: ignore[no-any-return] + + +class AsyncClient: + """Async sibling of ``Client``. Same surface, ``await`` on every call. + + Use as an async context manager (``async with``) so the underlying + ``httpx.AsyncClient`` is closed without relying on garbage collection. + """ + + def __init__( + self, + base_url: Optional[str] = None, + *, + api_key: Optional[str] = None, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + self._core = HttpCore( + base_url=base_url, + api_key=api_key, + timeout=timeout, + user_agent=_USER_AGENT, + ) + + @property + def base_url(self) -> str: + return self._core.base_url + + async def aclose(self) -> None: + await self._core.aclose() + + async def __aenter__(self) -> "AsyncClient": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.aclose() + + async def health(self) -> LivezResponse: + _, body = await self._core.request_async("GET", "/agentmemory/livez") + return body # type: ignore[no-any-return] + + async def remember( + self, + *, + content: str, + project: Optional[str] = None, + title: Optional[str] = None, + concepts: Optional[List[str]] = None, + type: Optional[str] = None, + files: Optional[List[str]] = None, + session_id: Optional[str] = None, + extra: Optional[Mapping[str, Any]] = None, + ) -> RememberResult: + payload = _drop_none( + { + "content": content, + "project": project, + "title": title, + "concepts": concepts, + "type": type, + "files": files, + "sessionId": session_id, + } + ) + if extra: + payload.update(extra) + _, body = await self._core.request_async( + "POST", "/agentmemory/remember", json_body=payload + ) + return body # type: ignore[no-any-return] + + async def smart_search( + self, + *, + query: Optional[str] = None, + project: Optional[str] = None, + limit: Optional[int] = None, + expand_ids: Optional[List[str]] = None, + extra: Optional[Mapping[str, Any]] = None, + ) -> SmartSearchResult: + payload = _drop_none( + { + "query": query, + "project": project, + "limit": limit, + "expandIds": expand_ids, + } + ) + if extra: + payload.update(extra) + _, body = await self._core.request_async( + "POST", "/agentmemory/smart-search", json_body=payload + ) + return body # type: ignore[no-any-return] + + async def memories( + self, + *, + project: Optional[str] = None, + latest: bool = False, + limit: Optional[int] = None, + ) -> MemoriesResponse: + params: dict = {} + if project is not None: + params["project"] = project + if latest: + params["latest"] = "true" + if limit is not None: + params["limit"] = str(limit) + _, body = await self._core.request_async( + "GET", + "/agentmemory/memories", + params=params or None, + ) + return body # type: ignore[no-any-return] + + async def memory(self, memory_id: str) -> MemoryResponse: + _, body = await self._core.request_async( + "GET", f"/agentmemory/memories/{memory_id}" + ) + return body # type: ignore[no-any-return] + + async def forget( + self, + *, + memory_id: Optional[str] = None, + session_id: Optional[str] = None, + observation_ids: Optional[List[str]] = None, + ) -> ForgetResult: + payload = _drop_none( + { + "memoryId": memory_id, + "sessionId": session_id, + "observationIds": observation_ids, + } + ) + _, body = await self._core.request_async( + "POST", "/agentmemory/forget", json_body=payload + ) + return body # type: ignore[no-any-return] + + async def semantic(self) -> SemanticListResponse: + _, body = await self._core.request_async("GET", "/agentmemory/semantic") + return body # type: ignore[no-any-return] + + async def procedural(self) -> ProceduralListResponse: + _, body = await self._core.request_async("GET", "/agentmemory/procedural") + return body # type: ignore[no-any-return] + + async def relations(self) -> RelationsListResponse: + _, body = await self._core.request_async("GET", "/agentmemory/relations") + return body # type: ignore[no-any-return] + + async def request( + self, + method: str, + path: str, + *, + json: Optional[Any] = None, + params: Optional[Mapping[str, Any]] = None, + ) -> JSONObject: + if not path.startswith("/"): + path = f"/agentmemory/{path}" + _, body = await self._core.request_async( + method, path, json_body=json, params=params + ) + return body # type: ignore[no-any-return] + + +__all__ = [ + "__version__", + "Client", + "AsyncClient", + "AgentMemoryError", + "AuthError", + "NetworkError", + "ResponseError", + "DEFAULT_BASE_URL", + "DEFAULT_TIMEOUT", + "ForgetResult", + "HealthResponse", + "JSONObject", + "LivezResponse", + "Memory", + "MemoriesResponse", + "MemoryRelation", + "MemoryResponse", + "ProceduralListResponse", + "ProceduralMemory", + "RelationsListResponse", + "RememberResult", + "SemanticListResponse", + "SemanticMemory", + "SmartSearchHit", + "SmartSearchResult", +] diff --git a/packages/python/agentmemory/_exceptions.py b/packages/python/agentmemory/_exceptions.py new file mode 100644 index 00000000..798e354c --- /dev/null +++ b/packages/python/agentmemory/_exceptions.py @@ -0,0 +1,50 @@ +"""Exception hierarchy for the agentmemory client. + +All errors raised by ``Client``/``AsyncClient`` derive from ``AgentMemoryError`` +so callers can catch a single base type if they don't care about the cause. +""" + +from __future__ import annotations + +from typing import Any, Optional + + +class AgentMemoryError(Exception): + """Base class for every error raised by the agentmemory client.""" + + +class AuthError(AgentMemoryError): + """Bearer-token configuration prevents the request from being sent. + + Raised when ``AGENTMEMORY_REQUIRE_HTTPS=1`` is set and the configured + ``base_url`` would leak a bearer token over plaintext HTTP to a + non-loopback host. Also raised for ``401``/``403`` responses from the + daemon. + """ + + +class NetworkError(AgentMemoryError): + """The HTTP request failed before a response was received. + + Covers connection refused, DNS failure, TLS handshake errors, and + timeouts. Inspect ``__cause__`` for the underlying ``httpx`` exception. + """ + + +class ResponseError(AgentMemoryError): + """The daemon responded with a non-2xx status code. + + ``status_code`` is the HTTP status. ``body`` is the parsed JSON body + (a ``dict``) if the response was valid JSON, otherwise the raw text. + """ + + def __init__( + self, + message: str, + *, + status_code: int, + body: Optional[Any] = None, + ) -> None: + super().__init__(message) + self.status_code = status_code + self.body = body diff --git a/packages/python/agentmemory/_http.py b/packages/python/agentmemory/_http.py new file mode 100644 index 00000000..d7696c77 --- /dev/null +++ b/packages/python/agentmemory/_http.py @@ -0,0 +1,251 @@ +"""Transport layer shared by ``Client`` and ``AsyncClient``. + +Responsibilities, in order: + +1. Resolve the base URL (parameter, then ``AGENTMEMORY_URL`` env, then + ``http://localhost:3111``). +2. Enforce the plaintext-bearer guard ported from v0.9.12: warn once on + stderr when a bearer token would cross plaintext HTTP to a non-loopback + host; raise ``AuthError`` instead if ``AGENTMEMORY_REQUIRE_HTTPS=1``. +3. Build the ``httpx`` request with bearer auth + JSON body + timeout. +4. Translate HTTP errors into the ``AgentMemoryError`` hierarchy. + +The sync and async paths are kept in lockstep via two near-identical +``_request_sync`` / ``_request_async`` helpers — duplicating ten lines is +cheaper than the abstraction needed to share them across event loops. +""" + +from __future__ import annotations + +import os +import sys +import threading +from typing import Any, Mapping, Optional, Tuple +from urllib.parse import urlparse + +import httpx + +from ._exceptions import AgentMemoryError, AuthError, NetworkError, ResponseError + + +DEFAULT_BASE_URL = "http://localhost:3111" +DEFAULT_TIMEOUT = 5.0 +LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + +_plaintext_bearer_warned = False +_plaintext_bearer_lock = threading.Lock() + + +def _resolve_base_url(base_url: Optional[str]) -> str: + if base_url: + return base_url.rstrip("/") + env = os.environ.get("AGENTMEMORY_URL") + if env: + return env.rstrip("/") + return DEFAULT_BASE_URL + + +def _resolve_api_key(api_key: Optional[str]) -> Optional[str]: + if api_key is not None: + return api_key or None + env = os.environ.get("AGENTMEMORY_SECRET", "") + return env or None + + +def _uses_plaintext_bearer(base_url: str, api_key: Optional[str]) -> bool: + if not api_key: + return False + parsed = urlparse(base_url) + if parsed.scheme != "http": + return False + host = (parsed.hostname or "").lower() + return host not in LOOPBACK_HOSTS + + +def _plaintext_bearer_message(base_url: str) -> str: + return ( + f"agentmemory: bearer token configured for plaintext HTTP to " + f"{base_url}. Tokens and memory payloads can be observed on the " + f"network; use HTTPS or an SSH tunnel." + ) + + +def _check_plaintext_bearer_guard(base_url: str, api_key: Optional[str]) -> None: + """Warn-once or raise when bearer auth would cross plaintext HTTP. + + Raises ``AuthError`` if ``AGENTMEMORY_REQUIRE_HTTPS=1``. Otherwise emits + a single stderr warning per process and returns. Subsequent calls with + the same risky configuration are silent so chatty clients don't spam + the user's terminal. + """ + + global _plaintext_bearer_warned + if not _uses_plaintext_bearer(base_url, api_key): + return + message = _plaintext_bearer_message(base_url) + if os.environ.get("AGENTMEMORY_REQUIRE_HTTPS") == "1": + raise AuthError(message) + with _plaintext_bearer_lock: + if _plaintext_bearer_warned: + return + _plaintext_bearer_warned = True + print(message, file=sys.stderr) + + +def _reset_plaintext_bearer_guard_for_tests() -> None: + """Test-only helper. Resets the once-per-process warn flag.""" + + global _plaintext_bearer_warned + with _plaintext_bearer_lock: + _plaintext_bearer_warned = False + + +def _build_headers(api_key: Optional[str], extra: Optional[Mapping[str, str]]) -> dict: + headers: dict = {"Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + if extra: + headers.update(extra) + return headers + + +def _decode_response(resp: httpx.Response) -> Any: + """Return the parsed JSON body, or raise ``ResponseError`` on non-2xx. + + Bodies that fail to parse as JSON are returned as a raw string when the + status is 2xx, and attached to ``ResponseError.body`` as a raw string + otherwise — so callers can debug 500s from a misconfigured daemon. + """ + + parsed: Any + try: + parsed = resp.json() + except ValueError: + parsed = resp.text + + if 200 <= resp.status_code < 300: + return parsed + + if resp.status_code in (401, 403): + raise AuthError( + f"agentmemory rejected the request: HTTP {resp.status_code}", + ) + message = f"agentmemory returned HTTP {resp.status_code}" + if isinstance(parsed, dict) and "error" in parsed: + message = f"{message}: {parsed['error']}" + raise ResponseError(message, status_code=resp.status_code, body=parsed) + + +class HttpCore: + """Shared configuration for sync + async paths. + + Stores the resolved base URL, api key, timeout, and a pair of + ``httpx`` clients (one sync, one async) that share the same transport + settings. The async client is created lazily — most users only need + one or the other. + """ + + def __init__( + self, + base_url: Optional[str], + api_key: Optional[str], + timeout: float, + user_agent: str, + ) -> None: + self.base_url = _resolve_base_url(base_url) + self.api_key = _resolve_api_key(api_key) + self.timeout = timeout + self.user_agent = user_agent + _check_plaintext_bearer_guard(self.base_url, self.api_key) + self._sync_client: Optional[httpx.Client] = None + self._async_client: Optional[httpx.AsyncClient] = None + + def _common_headers(self, extra: Optional[Mapping[str, str]]) -> dict: + headers = _build_headers(self.api_key, extra) + headers.setdefault("User-Agent", self.user_agent) + return headers + + def url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return f"{self.base_url}{path}" + + def sync_client(self) -> httpx.Client: + if self._sync_client is None: + self._sync_client = httpx.Client(timeout=self.timeout) + return self._sync_client + + def async_client(self) -> httpx.AsyncClient: + if self._async_client is None: + self._async_client = httpx.AsyncClient(timeout=self.timeout) + return self._async_client + + def close(self) -> None: + if self._sync_client is not None: + self._sync_client.close() + self._sync_client = None + + async def aclose(self) -> None: + if self._async_client is not None: + await self._async_client.aclose() + self._async_client = None + + def request_sync( + self, + method: str, + path: str, + *, + json_body: Optional[Any] = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + ) -> Tuple[int, Any]: + merged = self._common_headers(headers) + if json_body is not None: + merged.setdefault("Content-Type", "application/json") + try: + resp = self.sync_client().request( + method, + self.url(path), + json=json_body, + params=params, + headers=merged, + ) + except httpx.HTTPError as exc: + raise NetworkError(f"agentmemory request failed: {exc}") from exc + return resp.status_code, _decode_response(resp) + + async def request_async( + self, + method: str, + path: str, + *, + json_body: Optional[Any] = None, + params: Optional[Mapping[str, Any]] = None, + headers: Optional[Mapping[str, str]] = None, + ) -> Tuple[int, Any]: + merged = self._common_headers(headers) + if json_body is not None: + merged.setdefault("Content-Type", "application/json") + try: + resp = await self.async_client().request( + method, + self.url(path), + json=json_body, + params=params, + headers=merged, + ) + except httpx.HTTPError as exc: + raise NetworkError(f"agentmemory request failed: {exc}") from exc + return resp.status_code, _decode_response(resp) + + +__all__ = [ + "DEFAULT_BASE_URL", + "DEFAULT_TIMEOUT", + "HttpCore", + "AgentMemoryError", + "AuthError", + "NetworkError", + "ResponseError", + "_reset_plaintext_bearer_guard_for_tests", +] diff --git a/packages/python/agentmemory/_types.py b/packages/python/agentmemory/_types.py new file mode 100644 index 00000000..e8b88392 --- /dev/null +++ b/packages/python/agentmemory/_types.py @@ -0,0 +1,127 @@ +"""Typed response envelopes for the agentmemory daemon. + +These are ``TypedDict`` mirrors of the JSON shapes returned by the REST +endpoints at ``:3111``. Fields not always present are flagged ``NotRequired`` +so callers can be permissive in older daemon versions and strict in new ones +without a runtime dependency on ``pydantic`` or ``msgspec``. + +The daemon is the source of truth: if a field is missing from a real +response, the typed dict will simply not contain it. Use ``dict.get`` on +optional fields. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal + +try: + from typing import NotRequired, TypedDict +except ImportError: + from typing_extensions import NotRequired, TypedDict + + +MemoryType = Literal["pattern", "preference", "architecture", "bug", "workflow", "fact"] + + +class Memory(TypedDict, total=False): + """A consolidated memory record.""" + + id: str + createdAt: str + updatedAt: str + type: MemoryType + title: str + content: str + concepts: List[str] + files: List[str] + sessionIds: List[str] + strength: float + version: int + parentId: NotRequired[str] + supersedes: NotRequired[List[str]] + relatedIds: NotRequired[List[str]] + sourceObservationIds: NotRequired[List[str]] + isLatest: bool + forgetAfter: NotRequired[str] + imageRef: NotRequired[str] + + +class SemanticMemory(TypedDict, total=False): + id: str + createdAt: str + project: str + concept: str + facts: List[str] + + +class ProceduralMemory(TypedDict, total=False): + id: str + createdAt: str + project: str + title: str + steps: List[str] + + +class MemoryRelation(TypedDict, total=False): + id: str + fromId: str + toId: str + type: str + createdAt: str + + +class MemoriesResponse(TypedDict): + memories: List[Memory] + + +class MemoryResponse(TypedDict): + memory: Memory + + +class SemanticListResponse(TypedDict): + semantic: List[SemanticMemory] + + +class ProceduralListResponse(TypedDict): + procedural: List[ProceduralMemory] + + +class RelationsListResponse(TypedDict): + relations: List[MemoryRelation] + + +class SmartSearchHit(TypedDict, total=False): + id: str + score: float + memory: Memory + + +class SmartSearchResult(TypedDict, total=False): + hits: List[SmartSearchHit] + query: str + expanded: NotRequired[List[str]] + + +class RememberResult(TypedDict, total=False): + id: str + memory: Memory + created: bool + + +class ForgetResult(TypedDict, total=False): + forgotten: int + ids: List[str] + + +class HealthResponse(TypedDict, total=False): + status: Literal["healthy", "degraded", "critical"] + uptimeSeconds: float + alerts: List[str] + + +class LivezResponse(TypedDict, total=False): + ok: bool + + +# Catch-all for endpoints whose response shape we don't model strictly. +JSONObject = Dict[str, Any] diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml new file mode 100644 index 00000000..9f5cb092 --- /dev/null +++ b/packages/python/pyproject.toml @@ -0,0 +1,68 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "agentmemory" +version = "0.1.0" +description = "Thin Python REST client for the agentmemory daemon." +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "Rohit Ghumare", email = "ghumare64@gmail.com" }, +] +keywords = [ + "ai", + "agent", + "memory", + "persistent", + "iii-engine", + "rest-client", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = [ + "httpx>=0.27", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "respx>=0.21", +] + +[project.urls] +Homepage = "https://github.com/rohitg00/agentmemory" +Source = "https://github.com/rohitg00/agentmemory" +Issues = "https://github.com/rohitg00/agentmemory/issues" +Changelog = "https://github.com/rohitg00/agentmemory/blob/main/CHANGELOG.md" + +[tool.hatch.build.targets.wheel] +packages = ["agentmemory"] + +[tool.hatch.build.targets.sdist] +include = [ + "agentmemory", + "tests", + "README.md", + "LICENSE", + "pyproject.toml", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/packages/python/tests/__init__.py b/packages/python/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/python/tests/conftest.py b/packages/python/tests/conftest.py new file mode 100644 index 00000000..4ce8a758 --- /dev/null +++ b/packages/python/tests/conftest.py @@ -0,0 +1,34 @@ +"""Shared pytest fixtures. + +Each test resets the once-per-process plaintext-bearer warn flag so the +``test_bearer_guard`` cases stay independent of test order. We also pop +the three env vars the client reads so a developer's local shell +configuration can't leak into the suite. +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# Make the in-tree package importable without `pip install -e .` so tests +# run unmodified in CI from a fresh checkout. +HERE = os.path.dirname(os.path.abspath(__file__)) +PKG_ROOT = os.path.dirname(HERE) +if PKG_ROOT not in sys.path: + sys.path.insert(0, PKG_ROOT) + + +from agentmemory._http import _reset_plaintext_bearer_guard_for_tests + + +@pytest.fixture(autouse=True) +def _isolate_environment(monkeypatch): + monkeypatch.delenv("AGENTMEMORY_URL", raising=False) + monkeypatch.delenv("AGENTMEMORY_SECRET", raising=False) + monkeypatch.delenv("AGENTMEMORY_REQUIRE_HTTPS", raising=False) + _reset_plaintext_bearer_guard_for_tests() + yield + _reset_plaintext_bearer_guard_for_tests() diff --git a/packages/python/tests/test_bearer_guard.py b/packages/python/tests/test_bearer_guard.py new file mode 100644 index 00000000..1d974966 --- /dev/null +++ b/packages/python/tests/test_bearer_guard.py @@ -0,0 +1,115 @@ +"""Plaintext-bearer guard parity with v0.9.12. + +The guard fires when a bearer token would be sent over plaintext HTTP to a +non-loopback host. Default behavior is one stderr warning per process. +Setting ``AGENTMEMORY_REQUIRE_HTTPS=1`` upgrades the warning to a raised +``AuthError`` before any request leaves the host. +""" + +from __future__ import annotations + +import httpx +import pytest +import respx + +from agentmemory import AsyncClient, AuthError, Client +from agentmemory._http import ( + _check_plaintext_bearer_guard, + _reset_plaintext_bearer_guard_for_tests, + _uses_plaintext_bearer, +) + + +def test_no_warn_when_no_api_key(capsys): + Client(base_url="http://example.com:3111") + out = capsys.readouterr() + assert out.err == "" + + +def test_no_warn_for_loopback_127_0_0_1(capsys): + Client(base_url="http://127.0.0.1:3111", api_key="secret") + out = capsys.readouterr() + assert out.err == "" + + +def test_no_warn_for_loopback_localhost(capsys): + Client(base_url="http://localhost:3111", api_key="secret") + out = capsys.readouterr() + assert out.err == "" + + +def test_no_warn_for_loopback_ipv6(capsys): + Client(base_url="http://[::1]:3111", api_key="secret") + out = capsys.readouterr() + assert out.err == "" + + +def test_no_warn_for_https_non_loopback(capsys): + Client(base_url="https://memory.example.com", api_key="secret") + out = capsys.readouterr() + assert out.err == "" + + +def test_warns_for_plaintext_http_non_loopback(capsys): + Client(base_url="http://memory.example.com", api_key="secret") + out = capsys.readouterr() + assert "plaintext HTTP" in out.err + assert "memory.example.com" in out.err + + +def test_warns_only_once_per_process(capsys): + Client(base_url="http://memory.example.com", api_key="secret") + capsys.readouterr() # drain + Client(base_url="http://other.example.com", api_key="secret") + out = capsys.readouterr() + assert out.err == "" + + +def test_require_https_raises_auth_error(monkeypatch): + monkeypatch.setenv("AGENTMEMORY_REQUIRE_HTTPS", "1") + with pytest.raises(AuthError): + Client(base_url="http://memory.example.com", api_key="secret") + + +def test_require_https_off_does_not_raise_for_loopback(monkeypatch): + monkeypatch.setenv("AGENTMEMORY_REQUIRE_HTTPS", "1") + Client(base_url="http://localhost:3111", api_key="secret") + + +def test_require_https_allows_https_targets(monkeypatch): + monkeypatch.setenv("AGENTMEMORY_REQUIRE_HTTPS", "1") + Client(base_url="https://memory.example.com", api_key="secret") + + +def test_async_client_applies_same_guard(monkeypatch): + monkeypatch.setenv("AGENTMEMORY_REQUIRE_HTTPS", "1") + with pytest.raises(AuthError): + AsyncClient(base_url="http://memory.example.com", api_key="secret") + + +def test_internal_helpers_classify_correctly(): + assert _uses_plaintext_bearer("http://example.com", "x") is True + assert _uses_plaintext_bearer("https://example.com", "x") is False + assert _uses_plaintext_bearer("http://localhost", "x") is False + assert _uses_plaintext_bearer("http://127.0.0.1", "x") is False + assert _uses_plaintext_bearer("http://[::1]", "x") is False + assert _uses_plaintext_bearer("http://example.com", None) is False + + +def test_check_helper_raises_under_require_https(monkeypatch): + monkeypatch.setenv("AGENTMEMORY_REQUIRE_HTTPS", "1") + with pytest.raises(AuthError): + _check_plaintext_bearer_guard("http://example.com", "secret") + + +@respx.mock +def test_bearer_header_still_sent_after_warning(capsys): + # Plaintext non-loopback + bearer = warn-and-send (not refuse). + route = respx.route( + method="GET", url="http://memory.example.com/agentmemory/livez" + ).mock(return_value=httpx.Response(200, json={"ok": True})) + c = Client(base_url="http://memory.example.com", api_key="secret") + c.health() + _reset_plaintext_bearer_guard_for_tests() + capsys.readouterr() + assert route.calls[0].request.headers["authorization"] == "Bearer secret" diff --git a/packages/python/tests/test_client.py b/packages/python/tests/test_client.py new file mode 100644 index 00000000..ac4cec0a --- /dev/null +++ b/packages/python/tests/test_client.py @@ -0,0 +1,280 @@ +"""Tests for ``Client`` and ``AsyncClient``. + +Network is mocked via ``respx`` so every assertion exercises the real +``httpx`` transport stack — there are no hand-rolled fakes. Sync and async +both flow through the same helper to keep the parity assertions honest. +""" + +from __future__ import annotations + +import os + +import httpx +import pytest +import respx + +from agentmemory import ( + AsyncClient, + AuthError, + Client, + NetworkError, + ResponseError, +) + + +BASE = "http://localhost:3111" + + +def _route(_mock, method: str, path: str, status: int, body, *, base: str = BASE): + """Register a respx route on the global mock router. + + The ``@respx.mock`` decorator patches the module-level transport, so we + use ``respx.route`` (not the ``respx.router`` module) to register + expectations. The first arg is kept for call-site readability. + """ + + return respx.route(method=method, url=f"{base}{path}").mock( + return_value=httpx.Response(status, json=body) + ) + + +# --- sync surface --------------------------------------------------------- + + +@respx.mock +def test_health_returns_livez_body(): + _route(respx.router, "GET", "/agentmemory/livez", 200, {"ok": True}) + with Client(base_url=BASE) as c: + assert c.health() == {"ok": True} + + +@respx.mock +def test_remember_strips_none_and_sends_post(): + route = _route( + respx.router, + "POST", + "/agentmemory/remember", + 201, + {"id": "m1", "created": True}, + ) + with Client(base_url=BASE) as c: + result = c.remember( + content="hello", + project="proj", + title="note", + concepts=["a"], + ) + assert result == {"id": "m1", "created": True} + sent = route.calls[0].request + assert sent.headers["content-type"] == "application/json" + import json as _json + + assert _json.loads(sent.content) == { + "content": "hello", + "project": "proj", + "title": "note", + "concepts": ["a"], + } + + +@respx.mock +def test_smart_search_passes_expand_ids(): + route = _route( + respx.router, + "POST", + "/agentmemory/smart-search", + 200, + {"hits": []}, + ) + with Client(base_url=BASE) as c: + c.smart_search(expand_ids=["m1", "m2"], limit=5) + import json as _json + + body = _json.loads(route.calls[0].request.content) + assert body == {"expandIds": ["m1", "m2"], "limit": 5} + + +@respx.mock +def test_memories_latest_query_string(): + route = _route( + respx.router, + "GET", + "/agentmemory/memories", + 200, + {"memories": []}, + ) + with Client(base_url=BASE) as c: + c.memories(project="proj", latest=True, limit=10) + sent = route.calls[0].request + assert sent.url.params["project"] == "proj" + assert sent.url.params["latest"] == "true" + assert sent.url.params["limit"] == "10" + + +@respx.mock +def test_memory_by_id_path_encoding(): + _route( + respx.router, + "GET", + "/agentmemory/memories/abc-123", + 200, + {"memory": {"id": "abc-123"}}, + ) + with Client(base_url=BASE) as c: + result = c.memory("abc-123") + assert result["memory"]["id"] == "abc-123" + + +@respx.mock +def test_forget_sends_memory_id(): + route = _route( + respx.router, + "POST", + "/agentmemory/forget", + 200, + {"forgotten": 1, "ids": ["m1"]}, + ) + with Client(base_url=BASE) as c: + c.forget(memory_id="m1") + import json as _json + + assert _json.loads(route.calls[0].request.content) == {"memoryId": "m1"} + + +@respx.mock +def test_bearer_header_set_when_api_key_provided(): + route = _route(respx.router, "GET", "/agentmemory/livez", 200, {"ok": True}) + with Client(base_url=BASE, api_key="secret123") as c: + c.health() + assert route.calls[0].request.headers["authorization"] == "Bearer secret123" + + +@respx.mock +def test_no_authorization_header_when_no_api_key(): + route = _route(respx.router, "GET", "/agentmemory/livez", 200, {"ok": True}) + with Client(base_url=BASE) as c: + c.health() + assert "authorization" not in route.calls[0].request.headers + + +@respx.mock +def test_non_2xx_raises_response_error_with_body(): + _route( + respx.router, + "POST", + "/agentmemory/remember", + 400, + {"error": "content is required"}, + ) + with Client(base_url=BASE) as c: + with pytest.raises(ResponseError) as exc_info: + c.remember(content="x") + assert exc_info.value.status_code == 400 + assert exc_info.value.body == {"error": "content is required"} + + +@respx.mock +def test_401_raises_auth_error(): + _route(respx.router, "GET", "/agentmemory/livez", 401, {"error": "no token"}) + with Client(base_url=BASE, api_key="bad") as c: + with pytest.raises(AuthError): + c.health() + + +def test_network_error_when_connect_refused(): + # Pick a port that's almost certainly closed. + c = Client(base_url="http://127.0.0.1:1", timeout=0.5) + with pytest.raises(NetworkError): + c.health() + + +@respx.mock +def test_escape_hatch_request_method(): + _route(respx.router, "POST", "/agentmemory/custom", 200, {"ok": 1}) + with Client(base_url=BASE) as c: + assert c.request("POST", "custom", json={"k": "v"}) == {"ok": 1} + + +@respx.mock +def test_env_var_resolves_base_url(monkeypatch): + monkeypatch.setenv("AGENTMEMORY_URL", "http://localhost:9999") + respx.route( + method="GET", url="http://localhost:9999/agentmemory/livez" + ).mock(return_value=httpx.Response(200, json={"ok": True})) + with Client() as c: + assert c.base_url == "http://localhost:9999" + assert c.health() == {"ok": True} + + +@respx.mock +def test_env_var_resolves_api_key(monkeypatch): + monkeypatch.setenv("AGENTMEMORY_SECRET", "from-env") + route = _route(respx.router, "GET", "/agentmemory/livez", 200, {"ok": True}) + with Client(base_url=BASE) as c: + c.health() + assert route.calls[0].request.headers["authorization"] == "Bearer from-env" + + +# --- async surface -------------------------------------------------------- + + +@pytest.mark.asyncio +@respx.mock +async def test_async_health(): + _route(respx.router, "GET", "/agentmemory/livez", 200, {"ok": True}) + async with AsyncClient(base_url=BASE) as c: + assert await c.health() == {"ok": True} + + +@pytest.mark.asyncio +@respx.mock +async def test_async_remember_and_forget(): + _route( + respx.router, + "POST", + "/agentmemory/remember", + 201, + {"id": "m1"}, + ) + _route( + respx.router, + "POST", + "/agentmemory/forget", + 200, + {"forgotten": 1, "ids": ["m1"]}, + ) + async with AsyncClient(base_url=BASE) as c: + r = await c.remember(content="hello", project="p") + f = await c.forget(memory_id=r["id"]) + assert r["id"] == "m1" + assert f["forgotten"] == 1 + + +@pytest.mark.asyncio +@respx.mock +async def test_async_smart_search_with_extra_payload(): + route = _route( + respx.router, + "POST", + "/agentmemory/smart-search", + 200, + {"hits": []}, + ) + async with AsyncClient(base_url=BASE) as c: + await c.smart_search(query="q", extra={"futureField": 1}) + import json as _json + + body = _json.loads(route.calls[0].request.content) + assert body == {"query": "q", "futureField": 1} + + +# --- optional live test --------------------------------------------------- + + +@pytest.mark.skipif( + os.environ.get("AGENTMEMORY_LIVE") != "1", + reason="set AGENTMEMORY_LIVE=1 to hit a running daemon at :3111", +) +def test_live_health_against_local_daemon(): + c = Client() + assert c.health() is not None