From 2fb8c954d54c1cc2c8a0189f86d4a836905d8541 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 14 May 2026 08:13:12 -0700 Subject: [PATCH] Sync python snapshot a1aa75f --- .github/ISSUE_TEMPLATE/benchmark_case.yml | 27 ++ .github/ISSUE_TEMPLATE/bug_report.yml | 39 +++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/docs_issue.yml | 21 ++ .github/ISSUE_TEMPLATE/feature_request.yml | 34 ++ .github/ISSUE_TEMPLATE/provider_request.yml | 35 ++ .github/PULL_REQUEST_TEMPLATE.md | 27 ++ CHANGELOG.md | 7 +- CODE_OF_CONDUCT.md | 39 +++ CONTRIBUTING.md | 54 +++ LICENSE | 222 ++++++++++-- README.md | 69 +++- ROADMAP.md | 72 ++++ SECURITY.md | 18 + atomicmemory/client/async_memory_client.py | 1 + atomicmemory/client/memory_client.py | 3 +- atomicmemory/providers/__init__.py | 2 +- .../providers/atomicmemory/__init__.py | 2 +- .../providers/atomicmemory/provider.py | 6 +- atomicmemory/providers/hindsight/__init__.py | 52 +++ .../providers/hindsight/async_provider.py | 258 ++++++++++++++ atomicmemory/providers/hindsight/config.py | 135 ++++++++ atomicmemory/providers/hindsight/http.py | 196 +++++++++++ atomicmemory/providers/hindsight/mappers.py | 173 ++++++++++ atomicmemory/providers/hindsight/provider.py | 315 ++++++++++++++++++ pyproject.toml | 16 +- tests/client/test_async_memory_client.py | 10 + tests/client/test_memory_client.py | 9 + tests/conftest.py | 33 ++ tests/memory/test_registry.py | 7 + tests/providers/atomicmemory/test_provider.py | 2 +- tests/providers/hindsight/__init__.py | 1 + .../hindsight/test_async_provider.py | 82 +++++ tests/providers/hindsight/test_integration.py | 127 +++++++ tests/providers/hindsight/test_mappers.py | 115 +++++++ tests/providers/hindsight/test_provider.py | 202 +++++++++++ uv.lock | 2 +- 37 files changed, 2374 insertions(+), 47 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/benchmark_case.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/docs_issue.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/provider_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 ROADMAP.md create mode 100644 SECURITY.md create mode 100644 atomicmemory/providers/hindsight/__init__.py create mode 100644 atomicmemory/providers/hindsight/async_provider.py create mode 100644 atomicmemory/providers/hindsight/config.py create mode 100644 atomicmemory/providers/hindsight/http.py create mode 100644 atomicmemory/providers/hindsight/mappers.py create mode 100644 atomicmemory/providers/hindsight/provider.py create mode 100644 tests/providers/hindsight/__init__.py create mode 100644 tests/providers/hindsight/test_async_provider.py create mode 100644 tests/providers/hindsight/test_integration.py create mode 100644 tests/providers/hindsight/test_mappers.py create mode 100644 tests/providers/hindsight/test_provider.py diff --git a/.github/ISSUE_TEMPLATE/benchmark_case.yml b/.github/ISSUE_TEMPLATE/benchmark_case.yml new file mode 100644 index 0000000..cba5b57 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/benchmark_case.yml @@ -0,0 +1,27 @@ +name: Benchmark Case +description: Propose a benchmark, regression case, or evaluation scenario. +labels: [benchmark] +body: + - type: textarea + id: scenario + attributes: + label: Scenario + description: What behavior should the benchmark measure? + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected signal + description: What would a good result demonstrate? + validations: + required: true + - type: textarea + id: data + attributes: + label: Dataset or fixture + description: Link or describe the data needed to reproduce the case. + - type: textarea + id: notes + attributes: + label: Notes diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..e76989f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,39 @@ +name: Bug Report +description: Report a reproducible bug in the AtomicMemory Python SDK. +labels: [bug] +body: + - type: textarea + id: description + attributes: + label: Description + description: What happened, and what did you expect instead? + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Include a minimal code sample or repository when possible. + placeholder: | + 1. Install package version ... + 2. Configure provider ... + 3. Run ... + validations: + required: true + - type: input + id: version + attributes: + label: Package version + placeholder: "atomicmemory==..." + - type: input + id: python-version + attributes: + label: Python version + placeholder: "3.10, 3.11, 3.12, or 3.13" + validations: + required: true + - type: textarea + id: logs + attributes: + label: Relevant logs or errors + render: shell diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..898842d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://docs.atomicmemory.ai + about: Read the AtomicMemory docs and quickstarts. + - name: Discussions + url: https://github.com/atomicstrata/atomicmemory-python/discussions + about: Ask questions, share ideas, and discuss roadmap items. diff --git a/.github/ISSUE_TEMPLATE/docs_issue.yml b/.github/ISSUE_TEMPLATE/docs_issue.yml new file mode 100644 index 0000000..23e5b33 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs_issue.yml @@ -0,0 +1,21 @@ +name: Docs Issue +description: Report missing, unclear, or incorrect documentation. +labels: [docs] +body: + - type: input + id: page + attributes: + label: Page or file + placeholder: "README.md, docs URL, API section, etc." + validations: + required: true + - type: textarea + id: issue + attributes: + label: What is wrong or missing? + validations: + required: true + - type: textarea + id: suggestion + attributes: + label: Suggested fix diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..078c2ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,34 @@ +name: Feature Request +description: Suggest a Python SDK capability or improvement. +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What user or developer problem should this solve? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe the Python API or behavior you want. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: area + attributes: + label: Area + options: + - Memory client + - Provider contract + - Storage + - Local search + - Async client + - Documentation + - Not sure diff --git a/.github/ISSUE_TEMPLATE/provider_request.yml b/.github/ISSUE_TEMPLATE/provider_request.yml new file mode 100644 index 0000000..45dcd32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/provider_request.yml @@ -0,0 +1,35 @@ +name: Provider Request +description: Request support for a memory, embedding, LLM, storage, or reranking provider. +labels: [provider] +body: + - type: input + id: provider + attributes: + label: Provider name + placeholder: "Provider or service name" + validations: + required: true + - type: dropdown + id: provider-type + attributes: + label: Provider type + options: + - Memory backend + - Embedding model + - LLM + - Reranker + - Storage + - Other + validations: + required: true + - type: textarea + id: use-case + attributes: + label: Use case + description: What workflow would this provider enable? + validations: + required: true + - type: textarea + id: references + attributes: + label: API docs or references diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4eb2a9c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,27 @@ +## Summary + + + +## Validation + +- [ ] Tests were run or the reason they were not run is documented. +- [ ] Typecheck/build were run when code or package output changed. +- [ ] Docs were updated or no docs changes are needed. +- [ ] Benchmark or performance impact was considered. + +## Change Type + +- [ ] Bug fix +- [ ] Feature +- [ ] Provider change +- [ ] Refactoring (no behavior change) +- [ ] Documentation +- [ ] Tests / benchmarks +- [ ] Chore / maintenance + +## Breaking Changes + +- [ ] No breaking changes +- [ ] Breaking changes are documented below + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a09811..4240c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [1.0.1] - 2026-05-14 + +### Changed +- Version bump for public package publication after internal-to-public repository sync. + ## [1.0.0] Initial public stable release. @@ -13,7 +18,7 @@ Initial public stable release. ### Added - `AtomicMemoryClient` and `AsyncAtomicMemoryClient` as the primary public client surfaces. - Memory ingestion, search, package, get, list, and delete support. -- AtomicMemory and Mem0 provider adapters. +- AtomicMemory, Mem0, and Hindsight provider adapters. - Typed AtomicMemory namespace handles for lifecycle, audit, lessons, agents, and runtime config. - Direct artifact storage client with pointer and managed artifact workflows. - Local embedding, semantic search, and KV cache helpers. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6079e94 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,39 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers at **support@atomicstrata.ai**. All complaints +will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e71bd61 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to atomicmemory-python + +Thank you for helping improve the AtomicMemory Python SDK. This package mirrors +the public surface of the TypeScript SDK while using Python-native conventions, +typed Pydantic models, and `httpx` clients. + +## Setup + +Use `uv` for package management: + +```bash +uv sync --extra dev --extra embeddings +``` + +Do not use `uv pip install` for repository setup. + +## Development Checks + +Run these before opening a pull request: + +```bash +uv sync +uv run ruff check . +uv run ruff format --check . +uv run mypy atomicmemory --strict +uv run vulture atomicmemory tests .vulture_whitelist.py --min-confidence 90 +uv run pytest +``` + +Integration tests are opt-in and require a live provider backend: + +```bash +uv run pytest -m integration +``` + +## Branch Conventions + +- `feat/` for new features +- `fix/` for bug fixes +- `docs/` for documentation-only changes +- `chore/` for tooling, dependency, and maintenance work + +## Pull Request Checklist + +- Tests or a clear validation note are included. +- Public behavior changes are documented in `README.md` or examples. +- Provider-specific behavior stays aligned with the TypeScript SDK contract. +- New code keeps files under 400 lines and functions under 40 lines, excluding comments and docstrings. +- No secrets, local credentials, or environment-specific values are committed. + +## License + +By contributing, you agree that your contributions will be licensed under the +same license as this repository. See [LICENSE](LICENSE). diff --git a/LICENSE b/LICENSE index 367451c..e4e62cb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2026 AtomicMemory - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + 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 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 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 those 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 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 + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 AtomicMemory + + 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/README.md b/README.md index bd909a8..e9717ec 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,21 @@ # atomicmemory-python +[![CI](https://github.com/atomicstrata/atomicmemory-python/actions/workflows/ci.yml/badge.svg)](https://github.com/atomicstrata/atomicmemory-python/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/atomicmemory?label=pypi)](https://pypi.org/project/atomicmemory/) +[![Python](https://img.shields.io/pypi/pyversions/atomicmemory)](https://pypi.org/project/atomicmemory/) +[![Docs](https://img.shields.io/badge/docs-docs.atomicstrata.ai-blue)](https://docs.atomicstrata.ai) +[![License: Apache 2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) + Python client SDK for [AtomicMemory](https://github.com/atomicstrata) memory and artifact storage. -A backend-agnostic memory and storage client: ingest conversations and documents, search them semantically, package retrieval-ready context, register or upload raw artifacts, and access AtomicMemory-specific features (lifecycle, audit, lessons, agents/trust, runtime config) through typed namespace handles. +**Docs:** [docs.atomicstrata.ai](https://docs.atomicstrata.ai) + +AtomicMemory Core currently reaches cost-Pareto SOTA on BEAM-100K, BEAM-1M, and LoCoMo10, with BEAM-10M parity against the strongest published Mem0-new result. This package brings that memory layer to Python services, agents, notebooks, and evaluation workflows. + +A backend-agnostic memory and storage client: ingest conversations and +documents, search them semantically, package retrieval-ready context, register +or upload raw artifacts, and access AtomicMemory-specific features (lifecycle, +audit, lessons, agents/trust, runtime config) through typed namespace handles. This is a Python port of the TypeScript [`atomicmemory-sdk`](https://github.com/atomicstrata/atomicmemory-sdk). It mirrors the public surface 1:1 while staying idiomatic to Python (Pydantic models, `httpx` sync + async clients, `match` statements, `snake_case`). @@ -10,8 +23,17 @@ This is a Python port of the TypeScript [`atomicmemory-sdk`](https://github.com/ Stable release — `1.0.0` on [PyPI](https://pypi.org/project/atomicmemory/). +## Installation + +```bash +pip install atomicmemory # core + local search + SQLite store +pip install 'atomicmemory[embeddings]' # + sentence-transformers for local embeddings +``` + ## Quick start +Prerequisite: start `atomicmemory-core` first. Follow the [Core Quickstart](https://docs.atomicstrata.ai/quickstart) if you do not already have a backend at `http://localhost:3050`. + ```python from atomicmemory import AtomicMemoryClient @@ -73,6 +95,30 @@ health = client.memory.atomicmemory.config.health() Categories: `lifecycle`, `audit`, `lessons`, `config`, `agents`. +## Memory providers + +The memory namespace supports the same provider family as the TypeScript SDK: + +- `atomicmemory` — AtomicMemory core backend. +- `mem0` — Mem0 OSS or hosted backend. +- `hindsight` — Hindsight Cloud or self-hosted backend. + +```python +from atomicmemory import MemoryClient + +with MemoryClient( + providers={ + "hindsight": { + "apiUrl": "http://localhost:8888", + "apiVersion": "v1", + "projectId": "default", + } + } +) as memory: + memory.initialize() + page = memory.search({"query": "seat preference", "scope": {"user": "demo"}}) +``` + ## Artifact storage The `client.storage` namespace mirrors the TypeScript SDK's direct storage API: @@ -85,13 +131,6 @@ The `client.storage` namespace mirrors the TypeScript SDK's direct storage API: Every storage request sends `Authorization: Bearer ` and `X-AtomicMemory-User-Id`. The SDK never sends the legacy `?user_id=` URL parameter. -## Installation - -```bash -pip install atomicmemory # core + local search + SQLite store -pip install 'atomicmemory[embeddings]' # + sentence-transformers for local embeddings -``` - ## Development ```bash @@ -103,6 +142,18 @@ uv run mypy atomicmemory --strict uv run vulture atomicmemory tests .vulture_whitelist.py --min-confidence 90 ``` +### Live provider smoke tests + +Live provider tests are opt-in and are not required for normal development. +They assume the backend is already running and configured with its own model. + +```bash +ATOMICMEMORY_HINDSIGHT_INTEGRATION=1 \ +HINDSIGHT_API_URL=http://localhost:8890 \ +HINDSIGHT_TIMEOUT_SECONDS=120 \ +uv run pytest tests/providers/hindsight/test_integration.py -m integration -ra +``` + ## License -MIT +Apache-2.0 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..92c4a55 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,72 @@ +# AtomicMemory Python Roadmap + +This roadmap is directional. It describes the areas the maintainers are actively investing in, but it is not a promise of specific features or dates. + +AtomicMemory Python is the Python client SDK for AtomicMemory. The near-term focus is a typed, reliable Python interface that tracks the core memory API and fits naturally into Python agent, service, and evaluation workflows. + +## Current Focus + +- Keep Python client behavior aligned with AtomicMemory Core and the TypeScript SDK where appropriate. +- Provide clear typed models for memory capture, retrieval, mutation, and result metadata. +- Support practical synchronous and asynchronous usage patterns. +- Make configuration, errors, and examples straightforward for Python developers. +- Build out tests that protect compatibility across supported Python versions. +- Prepare the repository for public contribution and package distribution. + +## Near-Term Work + +### API Parity + +- Track the stable Core API surface for capture, search, retrieval, and memory mutation. +- Document where Python intentionally differs from the TypeScript SDK because of language conventions. +- Add migration notes when client behavior changes. +- Keep examples in sync with the docs site. + +### Typed Client Experience + +- Use Pydantic models for request and response shapes. +- Use httpx for transport behavior. +- Improve validation and error messages for configuration and API failures. +- Add examples for common service and notebook-style workflows. + +### Async And Runtime Support + +- Support async-first agent and service workflows. +- Keep synchronous usage ergonomic for scripts and simple applications. +- Verify behavior across the supported Python version matrix. +- Document connection, timeout, and retry expectations. + +### Packaging And Release Readiness + +- Keep package metadata, license, security policy, and contribution docs complete. +- Add badges for release, license, tests, and docs where appropriate. +- Publish clear quickstarts and minimal examples. +- Keep changelog and release notes useful for downstream users. + +## Later Work + +- Higher-level helper functions for context packaging and retrieval diagnostics. +- Additional examples for agent frameworks and evaluation workflows. +- Provider-specific convenience integrations where they do not weaken the core client API. +- More structured memory tools for temporal, correction-aware, and multi-session workflows. + +## Contribution Areas + +Good first areas for contributors include: + +- Typed model improvements and docstring updates. +- Examples for common Python frameworks and agent use cases. +- Tests for client behavior across Python versions. +- Bug reports with request, response, and environment details. +- Documentation fixes that make setup or usage clearer. + +## Non-Goals + +- The Python SDK should not become a separate memory engine with behavior that diverges from Core. +- The Python SDK should not require a hosted AtomicMemory service. +- The Python SDK should not expose internal benchmark strategy, private launch plans, or customer-specific work. +- The Python SDK should not introduce hidden fallback behavior that masks configuration errors. + +## How We Prioritize + +We prioritize correctness, type clarity, API parity, and examples that help Python developers build real memory-enabled applications without guessing at the underlying Core behavior. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..87ca5bc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| --- | --- | +| 1.x | Yes | + +## Reporting a Vulnerability + +Email **security@atomicstrata.ai** with: + +- A description of the vulnerability +- Steps to reproduce +- Potential impact +- Affected package version, if known + +Do not open a public GitHub issue for security vulnerabilities. We respond within 48 hours. diff --git a/atomicmemory/client/async_memory_client.py b/atomicmemory/client/async_memory_client.py index 4139cb8..25870a1 100644 --- a/atomicmemory/client/async_memory_client.py +++ b/atomicmemory/client/async_memory_client.py @@ -14,6 +14,7 @@ # Importing the provider packages registers both sync and async factories. import atomicmemory.providers.atomicmemory +import atomicmemory.providers.hindsight import atomicmemory.providers.mem0 # noqa: F401 from atomicmemory.client.memory_client import ( _coerce_ingest, diff --git a/atomicmemory/client/memory_client.py b/atomicmemory/client/memory_client.py index a3d2962..691f50f 100644 --- a/atomicmemory/client/memory_client.py +++ b/atomicmemory/client/memory_client.py @@ -4,7 +4,7 @@ :class:`atomicmemory.memory.service.MemoryService` and the configured providers, providing the public API users construct in application code. Async users get the same surface via -``atomicmemory.AsyncMemoryClient`` (Phase 4). +``atomicmemory.AsyncMemoryClient``. """ from __future__ import annotations @@ -18,6 +18,7 @@ # Importing the provider packages registers their factories. import atomicmemory.providers.atomicmemory +import atomicmemory.providers.hindsight import atomicmemory.providers.mem0 # noqa: F401 from atomicmemory.core.errors import ConfigError, NotInitializedError, ValidationError from atomicmemory.core.validation import sanitized_pydantic_errors diff --git a/atomicmemory/providers/__init__.py b/atomicmemory/providers/__init__.py index 1e0b9b2..1862974 100644 --- a/atomicmemory/providers/__init__.py +++ b/atomicmemory/providers/__init__.py @@ -1,5 +1,5 @@ """Concrete memory providers. -Each subpackage (`atomicmemory.providers.atomicmemory`, `mem0`) registers +Each subpackage (`atomicmemory.providers.atomicmemory`, `mem0`, `hindsight`) registers itself with the default registries on import. """ diff --git a/atomicmemory/providers/atomicmemory/__init__.py b/atomicmemory/providers/atomicmemory/__init__.py index a23690a..c299461 100644 --- a/atomicmemory/providers/atomicmemory/__init__.py +++ b/atomicmemory/providers/atomicmemory/__init__.py @@ -3,7 +3,7 @@ Port of `atomicmemory-sdk/src/memory/atomicmemory-provider/`. Importing this package registers the provider on `atomicmemory.memory.registry.default_registry` (and the async registry -once Phase 4 lands). +when both sync and async clients are available). """ from atomicmemory.memory.registry import ( diff --git a/atomicmemory/providers/atomicmemory/provider.py b/atomicmemory/providers/atomicmemory/provider.py index 02bc9cf..6b3f758 100644 --- a/atomicmemory/providers/atomicmemory/provider.py +++ b/atomicmemory/providers/atomicmemory/provider.py @@ -1,8 +1,8 @@ """Sync AtomicMemoryProvider — V3 core methods + Packager + TemporalSearch + Versioner + Health. Port of `atomicmemory-sdk/src/memory/atomicmemory-provider/atomicmemory-provider.ts`. -The handle namespace (lifecycle/audit/lessons/config/agents) is wired in -Phase 3 via :mod:`atomicmemory.providers.atomicmemory.handle_impl`. +The handle namespace (lifecycle/audit/lessons/config/agents) is wired via +:mod:`atomicmemory.providers.atomicmemory.handle_impl`. """ from __future__ import annotations @@ -238,7 +238,7 @@ def _require_client(self) -> httpx.Client: # --------------------------------------------------------------------------- -# Body builders — pure functions, shared with the async provider in Phase 4. +# Body builders — pure functions shared with the async provider. # --------------------------------------------------------------------------- diff --git a/atomicmemory/providers/hindsight/__init__.py b/atomicmemory/providers/hindsight/__init__.py new file mode 100644 index 0000000..4aef282 --- /dev/null +++ b/atomicmemory/providers/hindsight/__init__.py @@ -0,0 +1,52 @@ +"""Hindsight provider — HTTP client for Hindsight Cloud or self-hosted APIs.""" + +from atomicmemory.memory.registry import ( + AsyncProviderRegistration, + ProviderRegistration, + default_async_registry, + default_registry, +) +from atomicmemory.providers.hindsight.async_provider import AsyncHindsightProvider +from atomicmemory.providers.hindsight.config import ( + AsyncHindsightOperationsHandle, + AsyncHindsightRetainHandle, + HindsightOperation, + HindsightOperationsHandle, + HindsightOperationsPage, + HindsightProviderConfig, + HindsightRetainHandle, + HindsightRetainResponse, +) +from atomicmemory.providers.hindsight.provider import HindsightProvider + + +def _coerce_config(config: object) -> HindsightProviderConfig: + if isinstance(config, HindsightProviderConfig): + return config + return HindsightProviderConfig.model_validate(config) + + +def _factory(config: object) -> ProviderRegistration: + return ProviderRegistration(provider=HindsightProvider(_coerce_config(config))) + + +def _async_factory(config: object) -> AsyncProviderRegistration: + return AsyncProviderRegistration(provider=AsyncHindsightProvider(_coerce_config(config))) + + +default_registry.register("hindsight", _factory) +default_async_registry.register("hindsight", _async_factory) + + +__all__ = [ + "AsyncHindsightOperationsHandle", + "AsyncHindsightProvider", + "AsyncHindsightRetainHandle", + "HindsightOperation", + "HindsightOperationsHandle", + "HindsightOperationsPage", + "HindsightProvider", + "HindsightProviderConfig", + "HindsightRetainHandle", + "HindsightRetainResponse", +] diff --git a/atomicmemory/providers/hindsight/async_provider.py b/atomicmemory/providers/hindsight/async_provider.py new file mode 100644 index 0000000..62f3cba --- /dev/null +++ b/atomicmemory/providers/hindsight/async_provider.py @@ -0,0 +1,258 @@ +"""Async HindsightProvider — V3 core plus package, reflect, and health.""" + +from __future__ import annotations + +import time +from typing import Any + +import httpx + +from atomicmemory.core.errors import ProviderError +from atomicmemory.memory.provider import BaseAsyncMemoryProvider +from atomicmemory.memory.types import ( + Capabilities, + CapabilitiesExtensions, + CapabilitiesRequiredScope, + ContextPackage, + CustomExtensionMeta, + HealthStatus, + IngestInput, + IngestResult, + Insight, + ListRequest, + ListResultPage, + Memory, + MemoryRef, + PackageRequest, + Scope, + SearchRequest, + SearchResultPage, +) +from atomicmemory.providers.hindsight.config import ( + HINDSIGHT_DEFAULT_API_VERSION, + HINDSIGHT_DEFAULT_PROJECT_ID, + AsyncHindsightOperationsHandle, + AsyncHindsightRetainHandle, + HindsightOperation, + HindsightOperationsPage, + HindsightProviderConfig, + HindsightRetainResponse, +) +from atomicmemory.providers.hindsight.http import HttpOptions, adelete_ignore_404, afetch_json, afetch_json_or_none +from atomicmemory.providers.hindsight.mappers import ( + build_recall_request, + build_retain_request, + to_memory, + to_search_result, + unwrap_results, +) +from atomicmemory.providers.hindsight.provider import ( + _assert_retain_succeeded, + _format_package_text, + _is_healthy, + _map_list_page, + _normalize_operation, + _normalize_segment, + _to_insight, +) + + +class AsyncHindsightProvider(BaseAsyncMemoryProvider): + """Async HTTP-backed V3 provider for Hindsight.""" + + name = "hindsight" + + def __init__(self, config: HindsightProviderConfig) -> None: + self._config = config + self._http_options = HttpOptions(config.api_url.rstrip("/"), config.api_key, config.timeout_seconds) + self._api_version = _normalize_segment(config.api_version or HINDSIGHT_DEFAULT_API_VERSION) + self._project_id = _normalize_segment(config.project_id or HINDSIGHT_DEFAULT_PROJECT_ID) + self._client: httpx.AsyncClient | None = None + self._initialized = False + self._retain_handle = AsyncHindsightRetainHandle(self._retain_extension) + self._operations_handle = AsyncHindsightOperationsHandle( + self._list_operations_extension, + self._get_operation_extension, + ) + + async def initialize(self) -> None: + if self._client is None: + self._client = httpx.AsyncClient() + self._initialized = True + + async def close(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + self._initialized = False + + async def do_ingest(self, input: IngestInput) -> IngestResult: + if input.mode == "verbatim": + raise ProviderError( + "hindsight does not support verbatim ingest.", + provider=self.name, + context={"operation": "ingest", "mode": "verbatim"}, + ) + await self._retain(input) + return IngestResult(created=[], updated=[], unchanged=[]) + + async def do_search(self, request: SearchRequest) -> SearchResultPage: + raw = await self._recall_raw(request) + results = [to_search_result(row, request.scope) for row in unwrap_results(raw)] + if request.limit is not None: + results = results[: request.limit] + return SearchResultPage(results=results) + + async def do_get(self, ref: MemoryRef) -> Memory | None: + raw = await afetch_json_or_none( + self._require_client(), self._http_options, self._memory_path(ref.scope, ref.id) + ) + return to_memory(raw, ref.scope) if isinstance(raw, dict) else None + + async def do_delete(self, ref: MemoryRef) -> None: + await adelete_ignore_404(self._require_client(), self._http_options, self._memory_path(ref.scope, ref.id)) + + async def do_list(self, request: ListRequest) -> ListResultPage: + from urllib.parse import urlencode + + limit = request.limit or 20 + offset = int(request.cursor) if request.cursor else 0 + query = urlencode({"limit": str(limit), "offset": str(offset)}) + raw = await afetch_json( + self._require_client(), + self._http_options, + f"{self._bank_path(request.scope)}/memories/list?{query}", + ) + return _map_list_page(raw, request.scope, offset, limit) + + def capabilities(self) -> Capabilities: + return Capabilities( + ingest_modes=["text", "messages"], + required_scope=CapabilitiesRequiredScope(default=["user"]), + extensions=CapabilitiesExtensions(package=True, reflect=True, health=True), + custom_extensions={ + "hindsight.retain": CustomExtensionMeta( + version="1.0.0", + description="Raw Hindsight retain response and operation metadata.", + ), + "hindsight.operations": CustomExtensionMeta( + version="1.0.0", + description="Hindsight async operation status helpers.", + ), + }, + ) + + def get_extension(self, name: str) -> Any | None: + if name == "hindsight.retain": + return self._retain_handle + if name == "hindsight.operations": + return self._operations_handle + if name in {"package", "reflect", "health"}: + return self + return None + + async def package(self, request: PackageRequest) -> ContextPackage: + return await self._run_operation("package", request.scope, lambda: self._package(request)) + + async def reflect(self, query: str, scope: Scope) -> list[Insight]: + return await self._run_operation("reflect", scope, lambda: self._reflect(query, scope)) + + async def _package(self, request: PackageRequest) -> ContextPackage: + raw = await self._recall_raw(request, request.token_budget) + results = [to_search_result(row, request.scope) for row in unwrap_results(raw)] + text = _format_package_text([result.memory for result in results]) + from atomicmemory.providers.hindsight.mappers import estimate_tokens + + return ContextPackage(text=text, results=results, tokens=estimate_tokens(text), budget_constrained=False) + + async def _reflect(self, query: str, scope: Scope) -> list[Insight]: + from atomicmemory.providers.hindsight.mappers import build_reflect_request + + raw = await afetch_json( + self._require_client(), + self._http_options, + f"{self._bank_path(scope)}/reflect", + method="POST", + json=build_reflect_request(query, scope), + ) + return [_to_insight(raw)] + + async def health(self) -> HealthStatus: + start = time.monotonic() + try: + raw = await afetch_json(self._require_client(), self._http_options, "/health") + return HealthStatus( + ok=_is_healthy(raw), + latency_ms=(time.monotonic() - start) * 1000.0, + version=raw.get("version") if isinstance(raw.get("version"), str) else None, + ) + except (ProviderError, ValueError): + return HealthStatus(ok=False, latency_ms=(time.monotonic() - start) * 1000.0) + + async def _retain_extension(self, input: IngestInput) -> HindsightRetainResponse: + return await self._run_operation("hindsight.retain", input.scope, lambda: self._retain(input)) + + async def _list_operations_extension(self, scope: Scope) -> HindsightOperationsPage: + return await self._run_operation("hindsight.operations", scope, lambda: self._list_operations(scope)) + + async def _get_operation_extension(self, scope: Scope, operation_id: str) -> HindsightOperation | None: + return await self._run_operation( + "hindsight.operations", scope, lambda: self._get_operation(scope, operation_id) + ) + + async def _retain(self, input: IngestInput) -> HindsightRetainResponse: + raw = await afetch_json( + self._require_client(), + self._http_options, + f"{self._bank_path(input.scope)}/memories", + method="POST", + json=build_retain_request(input), + ) + retained = HindsightRetainResponse.model_validate(raw) + _assert_retain_succeeded(retained) + return retained + + async def _recall_raw(self, request: SearchRequest, max_tokens: int | None = None) -> Any: + return await afetch_json( + self._require_client(), + self._http_options, + f"{self._bank_path(request.scope)}/memories/recall", + method="POST", + json=build_recall_request(request.query, request.scope, self._config, max_tokens), + ) + + async def _list_operations(self, scope: Scope) -> HindsightOperationsPage: + raw = await afetch_json(self._require_client(), self._http_options, f"{self._bank_path(scope)}/operations") + return HindsightOperationsPage.model_validate(raw) + + async def _get_operation(self, scope: Scope, operation_id: str) -> HindsightOperation | None: + from urllib.parse import quote + + raw = await afetch_json_or_none( + self._require_client(), + self._http_options, + f"{self._bank_path(scope)}/operations/{quote(operation_id, safe='')}", + ) + return _normalize_operation(raw) if isinstance(raw, dict) else None + + def _bank_path(self, scope: Scope) -> str: + from urllib.parse import quote + + from atomicmemory.providers.hindsight.mappers import bank_id_for_scope + + return self._route(f"/banks/{quote(bank_id_for_scope(scope), safe='')}") + + def _memory_path(self, scope: Scope, memory_id: str) -> str: + from urllib.parse import quote + + return f"{self._bank_path(scope)}/memories/{quote(memory_id, safe='')}" + + def _route(self, path: str) -> str: + return f"/{self._api_version}/{self._project_id}{path}" + + def _require_client(self) -> httpx.AsyncClient: + if self._client is None: + raise ProviderError( + "AsyncHindsightProvider is not initialized. Call await initialize() first.", provider=self.name + ) + return self._client diff --git a/atomicmemory/providers/hindsight/config.py b/atomicmemory/providers/hindsight/config.py new file mode 100644 index 0000000..fba8562 --- /dev/null +++ b/atomicmemory/providers/hindsight/config.py @@ -0,0 +1,135 @@ +"""Hindsight provider configuration and extension wire models. + +Ports the TypeScript SDK's Hindsight provider contract to Python while keeping +Hindsight-specific operation metadata behind named custom extensions. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from atomicmemory.memory.types import IngestInput, Scope + +HindsightRecallBudget = Literal["low", "mid", "high"] +HindsightTagsMatch = Literal["any", "all", "any_strict", "all_strict"] + +HINDSIGHT_DEFAULT_TIMEOUT_SECONDS: float = 30.0 +HINDSIGHT_DEFAULT_API_VERSION: str = "v1" +HINDSIGHT_DEFAULT_PROJECT_ID: str = "default" +HINDSIGHT_DEFAULT_MAX_TOKENS: int = 4096 +HINDSIGHT_SCOPE_TAGS_MATCH: HindsightTagsMatch = "all_strict" + + +class HindsightProviderConfig(BaseModel): + """Inputs to construct a Hindsight provider.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + api_url: str = Field(alias="apiUrl") + api_key: str | None = Field(default=None, alias="apiKey") + timeout_seconds: float = Field( + default=HINDSIGHT_DEFAULT_TIMEOUT_SECONDS, + alias="timeoutSeconds", + ) + api_version: str = Field(default=HINDSIGHT_DEFAULT_API_VERSION, alias="apiVersion") + project_id: str = Field(default=HINDSIGHT_DEFAULT_PROJECT_ID, alias="projectId") + default_budget: HindsightRecallBudget | None = Field(default=None, alias="defaultBudget") + default_max_tokens: int | None = Field(default=None, alias="defaultMaxTokens") + + +class HindsightRetainResponse(BaseModel): + """Raw Hindsight retain response exposed through ``hindsight.retain``.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + success: bool | None = None + bank_id: str | None = None + items_count: int | None = None + async_: bool | None = Field(default=None, alias="async") + operation_id: str | None = None + operation_ids: list[str] | None = None + usage: dict[str, Any] | None = None + + +class HindsightOperation(BaseModel): + """Hindsight background operation status.""" + + model_config = ConfigDict(extra="ignore") + + id: str + task_type: str | None = None + items_count: int | None = None + document_id: str | None = None + created_at: str | None = None + status: str | None = None + error_message: str | None = None + retry_count: int | None = None + next_retry_at: str | None = None + + +class HindsightOperationsPage(BaseModel): + """Page of Hindsight operation statuses.""" + + model_config = ConfigDict(extra="ignore") + + bank_id: str | None = None + operations: list[HindsightOperation] = Field(default_factory=list) + + +class HindsightRetainHandle: + """Custom extension handle for raw Hindsight retain calls.""" + + def __init__(self, retain_fn: Callable[[IngestInput], HindsightRetainResponse]) -> None: + self._retain_fn = retain_fn + + def retain(self, input: IngestInput) -> HindsightRetainResponse: + return self._retain_fn(input) + + +class HindsightOperationsHandle: + """Custom extension handle for Hindsight operation status calls.""" + + def __init__( + self, + list_fn: Callable[[Scope], HindsightOperationsPage], + get_fn: Callable[[Scope, str], HindsightOperation | None], + ) -> None: + self._list_fn = list_fn + self._get_fn = get_fn + + def list(self, scope: Scope) -> HindsightOperationsPage: + return self._list_fn(scope) + + def get(self, scope: Scope, operation_id: str) -> HindsightOperation | None: + return self._get_fn(scope, operation_id) + + +class AsyncHindsightRetainHandle: + """Async custom extension handle for raw Hindsight retain calls.""" + + def __init__(self, retain_fn: Callable[[IngestInput], Awaitable[HindsightRetainResponse]]) -> None: + self._retain_fn = retain_fn + + async def retain(self, input: IngestInput) -> HindsightRetainResponse: + return await self._retain_fn(input) + + +class AsyncHindsightOperationsHandle: + """Async custom extension handle for Hindsight operation status calls.""" + + def __init__( + self, + list_fn: Callable[[Scope], Awaitable[HindsightOperationsPage]], + get_fn: Callable[[Scope, str], Awaitable[HindsightOperation | None]], + ) -> None: + self._list_fn = list_fn + self._get_fn = get_fn + + async def list(self, scope: Scope) -> HindsightOperationsPage: + return await self._list_fn(scope) + + async def get(self, scope: Scope, operation_id: str) -> HindsightOperation | None: + return await self._get_fn(scope, operation_id) diff --git a/atomicmemory/providers/hindsight/http.py b/atomicmemory/providers/hindsight/http.py new file mode 100644 index 0000000..344793f --- /dev/null +++ b/atomicmemory/providers/hindsight/http.py @@ -0,0 +1,196 @@ +"""HTTP transport helpers for the Hindsight provider.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import httpx + +from atomicmemory.core.errors import NetworkError, ProviderError, RateLimitError + +_PROVIDER_NAME = "hindsight" + + +@dataclass(frozen=True) +class HttpOptions: + api_url: str + api_key: str | None + timeout_seconds: float + + +def _headers(options: HttpOptions) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if options.api_key: + headers["Authorization"] = f"Bearer {options.api_key}" + return headers + + +def _parse_retry_after(value: str | None) -> float | None: + if not value: + return None + try: + return float(value) + except ValueError: + return None + + +def _raise_for_status(response: httpx.Response, path: str) -> None: + if response.status_code == 429: + raise RateLimitError( + "Rate limited", + provider=_PROVIDER_NAME, + retry_after_seconds=_parse_retry_after(response.headers.get("Retry-After")), + context={"path": path}, + ) + if response.is_success: + return + body = _decode_body(response) + raise ProviderError( + f"HTTP {response.status_code}: {response.text or response.reason_phrase}", + provider=_PROVIDER_NAME, + status_code=response.status_code, + response_body=body, + context={"path": path}, + ) + + +def _decode_body(response: httpx.Response) -> Any: + try: + return response.json() + except (ValueError, httpx.DecodingError): + return response.text + + +def _request( + client: httpx.Client, + options: HttpOptions, + method: str, + path: str, + *, + json: Any | None = None, +) -> httpx.Response: + try: + return client.request( + method, + f"{options.api_url}{path}", + headers=_headers(options), + json=json, + timeout=options.timeout_seconds, + ) + except httpx.TimeoutException as exc: + raise NetworkError( + f"Timeout after {options.timeout_seconds}s", + provider=_PROVIDER_NAME, + cause=exc, + context={"path": path, "method": method}, + ) from exc + except httpx.RequestError as exc: + raise NetworkError( + f"Transport error: {exc}", + provider=_PROVIDER_NAME, + cause=exc, + context={"path": path, "method": method}, + ) from exc + + +def fetch_json( + client: httpx.Client, + options: HttpOptions, + path: str, + *, + method: str = "GET", + json: Any | None = None, +) -> Any: + response = _request(client, options, method, path, json=json) + _raise_for_status(response, path) + return response.json() + + +def fetch_json_or_none( + client: httpx.Client, + options: HttpOptions, + path: str, + *, + method: str = "GET", + json: Any | None = None, +) -> Any | None: + response = _request(client, options, method, path, json=json) + if response.status_code == 404: + return None + _raise_for_status(response, path) + return response.json() + + +def delete_ignore_404(client: httpx.Client, options: HttpOptions, path: str) -> None: + response = _request(client, options, "DELETE", path) + if response.status_code == 404: + return + _raise_for_status(response, path) + + +async def _arequest( + client: httpx.AsyncClient, + options: HttpOptions, + method: str, + path: str, + *, + json: Any | None = None, +) -> httpx.Response: + try: + return await client.request( + method, + f"{options.api_url}{path}", + headers=_headers(options), + json=json, + timeout=options.timeout_seconds, + ) + except httpx.TimeoutException as exc: + raise NetworkError( + f"Timeout after {options.timeout_seconds}s", + provider=_PROVIDER_NAME, + cause=exc, + context={"path": path, "method": method}, + ) from exc + except httpx.RequestError as exc: + raise NetworkError( + f"Transport error: {exc}", + provider=_PROVIDER_NAME, + cause=exc, + context={"path": path, "method": method}, + ) from exc + + +async def afetch_json( + client: httpx.AsyncClient, + options: HttpOptions, + path: str, + *, + method: str = "GET", + json: Any | None = None, +) -> Any: + response = await _arequest(client, options, method, path, json=json) + _raise_for_status(response, path) + return response.json() + + +async def afetch_json_or_none( + client: httpx.AsyncClient, + options: HttpOptions, + path: str, + *, + method: str = "GET", + json: Any | None = None, +) -> Any | None: + response = await _arequest(client, options, method, path, json=json) + if response.status_code == 404: + return None + _raise_for_status(response, path) + return response.json() + + +async def adelete_ignore_404(client: httpx.AsyncClient, options: HttpOptions, path: str) -> None: + response = await _arequest(client, options, "DELETE", path) + if response.status_code == 404: + return + _raise_for_status(response, path) diff --git a/atomicmemory/providers/hindsight/mappers.py b/atomicmemory/providers/hindsight/mappers.py new file mode 100644 index 0000000..375d205 --- /dev/null +++ b/atomicmemory/providers/hindsight/mappers.py @@ -0,0 +1,173 @@ +"""Hindsight request builders and strict response mappers.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from atomicmemory.memory.types import IngestInput, Memory, MemoryKind, Message, Scope, SearchResult +from atomicmemory.providers.hindsight.config import ( + HINDSIGHT_DEFAULT_MAX_TOKENS, + HINDSIGHT_SCOPE_TAGS_MATCH, + HindsightProviderConfig, +) + + +def bank_id_for_scope(scope: Scope) -> str: + return scope.user or "" + + +def tags_for_scope(scope: Scope) -> list[str]: + tags: list[str] = [] + if scope.agent: + tags.append(f"agent:{scope.agent}") + if scope.namespace: + tags.append(f"namespace:{scope.namespace}") + if scope.thread: + tags.append(f"thread:{scope.thread}") + return tags + + +def build_retain_request(input: IngestInput) -> dict[str, Any]: + return {"items": [_build_retain_item(input)], "async": False} + + +def build_recall_request( + query: str, + scope: Scope, + config: HindsightProviderConfig, + max_tokens: int | None = None, +) -> dict[str, Any]: + tags = tags_for_scope(scope) + body: dict[str, Any] = { + "query": query, + "max_tokens": max_tokens + if max_tokens is not None + else config.default_max_tokens or HINDSIGHT_DEFAULT_MAX_TOKENS, + } + if config.default_budget: + body["budget"] = config.default_budget + if tags: + body["tags"] = tags + body["tags_match"] = HINDSIGHT_SCOPE_TAGS_MATCH + return body + + +def build_reflect_request(query: str, scope: Scope) -> dict[str, Any]: + tags = tags_for_scope(scope) + body: dict[str, Any] = {"query": query} + if tags: + body["tags"] = tags + body["tags_match"] = HINDSIGHT_SCOPE_TAGS_MATCH + return body + + +def unwrap_results(raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, dict) or not isinstance(raw.get("results"), list): + raise ValueError("Hindsight recall response missing results array") + return list(raw["results"]) + + +def to_memory(raw: dict[str, Any], scope: Scope) -> Memory: + memory_id = _require_string(raw.get("id"), "id") + return Memory( + id=memory_id, + content=_require_string(raw.get("text"), f"text for memory {memory_id}"), + scope=scope, + kind=_map_memory_kind(raw.get("type")), + created_at=_parse_memory_date(raw), + updated_at=_parse_iso(raw.get("updated_at")), + metadata=_build_metadata(raw), + ) + + +def to_search_result(raw: dict[str, Any], scope: Scope) -> SearchResult: + return SearchResult(memory=to_memory(raw, scope), score=0.0) + + +def messages_to_transcript(messages: list[Message]) -> str: + return "\n".join(f"{message.role}: {message.content}" for message in messages) + + +def estimate_tokens(text: str) -> int: + if text == "": + return 0 + return (len(text) + 3) // 4 + + +def _build_retain_item(input: IngestInput) -> dict[str, Any]: + metadata = _build_ingest_metadata(input) + item: dict[str, Any] = { + "content": messages_to_transcript(input.messages) if input.mode == "messages" else input.content, + "tags": tags_for_scope(input.scope), + } + if input.provenance and input.provenance.source: + item["context"] = input.provenance.source + if metadata: + item["metadata"] = metadata + return item + + +def _build_ingest_metadata(input: IngestInput) -> dict[str, Any]: + metadata = dict(input.metadata or {}) + if input.provenance is None: + return metadata + if input.provenance.source: + metadata["source"] = input.provenance.source + if input.provenance.source_url: + metadata["sourceUrl"] = input.provenance.source_url + if input.provenance.source_id: + metadata["sourceId"] = input.provenance.source_id + return metadata + + +def _build_metadata(raw: dict[str, Any]) -> dict[str, Any] | None: + metadata = dict(raw["metadata"]) if isinstance(raw.get("metadata"), dict) else {} + for source, target in _METADATA_FIELDS.items(): + if raw.get(source) is not None: + metadata[target] = raw[source] + return metadata or None + + +_METADATA_FIELDS = { + "type": "hindsightType", + "context": "context", + "tags": "tags", + "entities": "entities", + "occurred_start": "occurredStart", + "occurred_end": "occurredEnd", + "mentioned_at": "mentionedAt", + "date": "hindsightDate", +} + + +def _map_memory_kind(raw_type: Any) -> MemoryKind | None: + if raw_type == "world": + return "fact" + if raw_type == "experience": + return "episode" + if raw_type == "observation": + return "summary" + return None + + +def _parse_memory_date(raw: dict[str, Any]) -> datetime: + value = raw.get("created_at") or raw.get("mentioned_at") or raw.get("date") + if isinstance(value, str) and value: + parsed = _parse_iso(value) + if parsed is not None: + return parsed + raise ValueError(f"Hindsight memory {raw.get('id') or ''} missing timestamp field") + + +def _parse_iso(value: Any) -> datetime | None: + if not isinstance(value, str) or not value: + return None + text = value.replace("Z", "+00:00") if value.endswith("Z") else value + return datetime.fromisoformat(text) + + +def _require_string(value: Any, field: str) -> str: + if isinstance(value, str) and value: + return value + raise ValueError(f"Hindsight response missing required {field}") diff --git a/atomicmemory/providers/hindsight/provider.py b/atomicmemory/providers/hindsight/provider.py new file mode 100644 index 0000000..71785fd --- /dev/null +++ b/atomicmemory/providers/hindsight/provider.py @@ -0,0 +1,315 @@ +"""Sync HindsightProvider — V3 core plus package, reflect, and health.""" + +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import quote, urlencode + +import httpx + +from atomicmemory.core.errors import ProviderError +from atomicmemory.memory.provider import BaseMemoryProvider +from atomicmemory.memory.types import ( + Capabilities, + CapabilitiesExtensions, + CapabilitiesRequiredScope, + ContextPackage, + CustomExtensionMeta, + HealthStatus, + IngestInput, + IngestResult, + Insight, + ListRequest, + ListResultPage, + Memory, + MemoryRef, + PackageRequest, + Scope, + SearchRequest, + SearchResultPage, +) +from atomicmemory.providers.hindsight.config import ( + HINDSIGHT_DEFAULT_API_VERSION, + HINDSIGHT_DEFAULT_PROJECT_ID, + HindsightOperation, + HindsightOperationsHandle, + HindsightOperationsPage, + HindsightProviderConfig, + HindsightRetainHandle, + HindsightRetainResponse, +) +from atomicmemory.providers.hindsight.http import HttpOptions, delete_ignore_404, fetch_json, fetch_json_or_none +from atomicmemory.providers.hindsight.mappers import ( + bank_id_for_scope, + build_recall_request, + build_reflect_request, + build_retain_request, + estimate_tokens, + to_memory, + to_search_result, + unwrap_results, +) + + +class HindsightProvider(BaseMemoryProvider): + """HTTP-backed V3 provider for Hindsight Cloud or self-hosted Hindsight.""" + + name = "hindsight" + + def __init__(self, config: HindsightProviderConfig) -> None: + self._config = config + self._http_options = HttpOptions( + api_url=config.api_url.rstrip("/"), + api_key=config.api_key, + timeout_seconds=config.timeout_seconds, + ) + self._api_version = _normalize_segment(config.api_version or HINDSIGHT_DEFAULT_API_VERSION) + self._project_id = _normalize_segment(config.project_id or HINDSIGHT_DEFAULT_PROJECT_ID) + self._client: httpx.Client | None = None + self._initialized = False + self._retain_handle = HindsightRetainHandle(self._retain_extension) + self._operations_handle = HindsightOperationsHandle( + self._list_operations_extension, self._get_operation_extension + ) + + def initialize(self) -> None: + if self._client is None: + self._client = httpx.Client() + self._initialized = True + + def close(self) -> None: + if self._client is not None: + self._client.close() + self._client = None + self._initialized = False + + def do_ingest(self, input: IngestInput) -> IngestResult: + if input.mode == "verbatim": + raise ProviderError( + "hindsight does not support verbatim ingest.", + provider=self.name, + context={"operation": "ingest", "mode": "verbatim"}, + ) + self._retain(input) + return IngestResult(created=[], updated=[], unchanged=[]) + + def do_search(self, request: SearchRequest) -> SearchResultPage: + raw = self._recall_raw(request) + results = [to_search_result(row, request.scope) for row in unwrap_results(raw)] + if request.limit is not None: + results = results[: request.limit] + return SearchResultPage(results=results) + + def do_get(self, ref: MemoryRef) -> Memory | None: + raw = fetch_json_or_none(self._require_client(), self._http_options, self._memory_path(ref.scope, ref.id)) + return to_memory(raw, ref.scope) if isinstance(raw, dict) else None + + def do_delete(self, ref: MemoryRef) -> None: + delete_ignore_404(self._require_client(), self._http_options, self._memory_path(ref.scope, ref.id)) + + def do_list(self, request: ListRequest) -> ListResultPage: + limit = request.limit or 20 + offset = int(request.cursor) if request.cursor else 0 + query = urlencode({"limit": str(limit), "offset": str(offset)}) + raw = fetch_json( + self._require_client(), self._http_options, f"{self._bank_path(request.scope)}/memories/list?{query}" + ) + return _map_list_page(raw, request.scope, offset, limit) + + def capabilities(self) -> Capabilities: + return Capabilities( + ingest_modes=["text", "messages"], + required_scope=CapabilitiesRequiredScope(default=["user"]), + extensions=CapabilitiesExtensions(package=True, reflect=True, health=True), + custom_extensions={ + "hindsight.retain": CustomExtensionMeta( + version="1.0.0", + description="Raw Hindsight retain response and operation metadata.", + ), + "hindsight.operations": CustomExtensionMeta( + version="1.0.0", + description="Hindsight async operation status helpers.", + ), + }, + ) + + def get_extension(self, name: str) -> Any | None: + if name == "hindsight.retain": + return self._retain_handle + if name == "hindsight.operations": + return self._operations_handle + if name in {"package", "reflect", "health"}: + return self + return None + + def package(self, request: PackageRequest) -> ContextPackage: + return self._run_operation("package", request.scope, lambda: self._package(request)) + + def reflect(self, query: str, scope: Scope) -> list[Insight]: + return self._run_operation("reflect", scope, lambda: self._reflect(query, scope)) + + def health(self) -> HealthStatus: + start = time.monotonic() + try: + raw = fetch_json(self._require_client(), self._http_options, "/health") + return HealthStatus( + ok=_is_healthy(raw), + latency_ms=(time.monotonic() - start) * 1000.0, + version=raw.get("version") if isinstance(raw.get("version"), str) else None, + ) + except (ProviderError, ValueError): + return HealthStatus(ok=False, latency_ms=(time.monotonic() - start) * 1000.0) + + def _package(self, request: PackageRequest) -> ContextPackage: + raw = self._recall_raw(request, request.token_budget) + results = [to_search_result(row, request.scope) for row in unwrap_results(raw)] + text = _format_package_text([result.memory for result in results]) + return ContextPackage( + text=text, + results=results, + tokens=estimate_tokens(text), + budget_constrained=False, + ) + + def _reflect(self, query: str, scope: Scope) -> list[Insight]: + raw = fetch_json( + self._require_client(), + self._http_options, + f"{self._bank_path(scope)}/reflect", + method="POST", + json=build_reflect_request(query, scope), + ) + return [_to_insight(raw)] + + def _retain_extension(self, input: IngestInput) -> HindsightRetainResponse: + return self._run_operation("hindsight.retain", input.scope, lambda: self._retain(input)) + + def _list_operations_extension(self, scope: Scope) -> HindsightOperationsPage: + return self._run_operation("hindsight.operations", scope, lambda: self._list_operations(scope)) + + def _get_operation_extension(self, scope: Scope, operation_id: str) -> HindsightOperation | None: + return self._run_operation("hindsight.operations", scope, lambda: self._get_operation(scope, operation_id)) + + def _retain(self, input: IngestInput) -> HindsightRetainResponse: + raw = fetch_json( + self._require_client(), + self._http_options, + f"{self._bank_path(input.scope)}/memories", + method="POST", + json=build_retain_request(input), + ) + retained = HindsightRetainResponse.model_validate(raw) + _assert_retain_succeeded(retained) + return retained + + def _recall_raw(self, request: SearchRequest, max_tokens: int | None = None) -> Any: + return fetch_json( + self._require_client(), + self._http_options, + f"{self._bank_path(request.scope)}/memories/recall", + method="POST", + json=build_recall_request(request.query, request.scope, self._config, max_tokens), + ) + + def _list_operations(self, scope: Scope) -> HindsightOperationsPage: + raw = fetch_json(self._require_client(), self._http_options, f"{self._bank_path(scope)}/operations") + return HindsightOperationsPage.model_validate(raw) + + def _get_operation(self, scope: Scope, operation_id: str) -> HindsightOperation | None: + raw = fetch_json_or_none( + self._require_client(), + self._http_options, + f"{self._bank_path(scope)}/operations/{quote(operation_id, safe='')}", + ) + return _normalize_operation(raw) if isinstance(raw, dict) else None + + def _bank_path(self, scope: Scope) -> str: + return self._route(f"/banks/{quote(bank_id_for_scope(scope), safe='')}") + + def _memory_path(self, scope: Scope, memory_id: str) -> str: + return f"{self._bank_path(scope)}/memories/{quote(memory_id, safe='')}" + + def _route(self, path: str) -> str: + return f"/{self._api_version}/{self._project_id}{path}" + + def _require_client(self) -> httpx.Client: + if self._client is None: + raise ProviderError("HindsightProvider is not initialized. Call initialize() first.", provider=self.name) + return self._client + + +def _normalize_segment(segment: str) -> str: + return segment.strip("/") + + +def _map_list_page(raw: Any, scope: Scope, offset: int, limit: int) -> ListResultPage: + if not isinstance(raw, dict) or not isinstance(raw.get("items"), list): + raise ValueError("Hindsight list response missing items array") + total = raw.get("total") + if not isinstance(total, int): + raise ValueError("Hindsight list response missing total") + rows = list(raw["items"]) + next_offset = offset + len(rows) + cursor = str(next_offset) if next_offset < total else None + return ListResultPage(memories=[to_memory(row, scope) for row in rows], cursor=cursor) + + +def _assert_retain_succeeded(raw: HindsightRetainResponse) -> None: + if raw.success is False: + raise ProviderError( + f"Hindsight retain failed: {_retain_failure_context(raw)}", + provider="hindsight", + context={"operation": "ingest"}, + ) + + +def _retain_failure_context(raw: HindsightRetainResponse) -> str: + ids = ",".join(raw.operation_ids or []) or raw.operation_id or "none" + return f"operation_id={ids}, items_count={raw.items_count or 'unknown'}, async={raw.async_}" + + +def _normalize_operation(raw: dict[str, Any]) -> HindsightOperation: + return HindsightOperation( + id=str(raw["operation_id"]), + task_type=raw.get("operation_type"), + created_at=raw.get("created_at"), + status=raw.get("status"), + error_message=raw.get("error_message"), + retry_count=raw.get("retry_count"), + next_retry_at=raw.get("next_retry_at"), + ) + + +def _format_package_text(memories: list[Memory]) -> str: + if not memories: + return "" + lines = [f"- [{_package_type_label(memory)}] {memory.content}" for memory in memories] + return "\n".join(["Relevant memories:", *lines]) + + +def _package_type_label(memory: Memory) -> str: + hindsight_type = memory.metadata.get("hindsightType") if memory.metadata else None + return str(hindsight_type or memory.kind or "memory") + + +def _to_insight(raw: Any) -> Insight: + if not isinstance(raw, dict) or not isinstance(raw.get("text"), str): + raise ValueError("Hindsight reflect response missing text") + return Insight(content=raw["text"], confidence=0.0, supporting_memory_ids=_supporting_ids(raw)) + + +def _supporting_ids(raw: dict[str, Any]) -> list[str]: + based_on = raw.get("based_on") + memories = based_on.get("memories") if isinstance(based_on, dict) else None + if not isinstance(memories, list): + return [] + return [item["id"] for item in memories if isinstance(item, dict) and isinstance(item.get("id"), str)] + + +def _is_healthy(raw: Any) -> bool: + if not isinstance(raw, dict): + raise ValueError("Hindsight health response must be an object") + if isinstance(raw.get("ok"), bool): + return bool(raw["ok"]) + return raw.get("status") in {None, "ok", "healthy"} diff --git a/pyproject.toml b/pyproject.toml index 9c312e9..91b08bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,16 @@ [project] name = "atomicmemory" -version = "1.0.0" +version = "1.0.1" description = "Python client SDK for AtomicMemory memory and artifact storage." readme = "README.md" requires-python = ">=3.10" -license = { text = "MIT" } +license = { text = "Apache-2.0" } authors = [{ name = "AtomicMemory" }] -keywords = ["memory", "llm", "agent", "rag", "atomicmemory", "mem0"] +keywords = ["memory", "llm", "agent", "rag", "atomicmemory", "mem0", "hindsight"] classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", @@ -99,15 +99,15 @@ init_forbid_extra = false init_typed = true warn_required_dynamic_aliases = false -# Optional-extras module overrides are added back when Phase 5 lands the -# `embeddings` adapter; mypy warns on overrides that do not match any -# imported module, so the section stays out until then. +# Optional-extras module overrides should be added only when the +# corresponding adapter imports exist; mypy warns on overrides that do +# not match any imported module. [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] markers = [ - "integration: requires a live atomicmemory-core instance (set ATOMICMEMORY_TEST_API_URL)", + "integration: requires a live backend and explicit provider-specific environment", ] addopts = "-ra" diff --git a/tests/client/test_async_memory_client.py b/tests/client/test_async_memory_client.py index 88de15f..2c04376 100644 --- a/tests/client/test_async_memory_client.py +++ b/tests/client/test_async_memory_client.py @@ -88,6 +88,16 @@ async def test_atomicmemory_handle_property_returns_async_handle() -> None: assert isinstance(client.atomicmemory, AsyncAtomicMemoryHandle) +@pytest.mark.asyncio +async def test_hindsight_provider_is_registered_async() -> None: + async with AsyncMemoryClient(providers={"hindsight": {"api_url": "http://hindsight.test"}}) as client: + await client.initialize() + statuses = client.get_provider_status() + + assert statuses[0].name == "hindsight" + assert statuses[0].initialized is True + + @pytest.mark.asyncio @respx.mock async def test_dict_ingest_routes_through_to_provider() -> None: diff --git a/tests/client/test_memory_client.py b/tests/client/test_memory_client.py index f61d24f..6090de0 100644 --- a/tests/client/test_memory_client.py +++ b/tests/client/test_memory_client.py @@ -97,6 +97,15 @@ def test_provider_status_after_initialize_reports_capabilities() -> None: assert "verbatim" in statuses[0].capabilities.ingest_modes +def test_hindsight_provider_is_registered() -> None: + with MemoryClient(providers={"hindsight": {"api_url": "http://hindsight.test"}}) as client: + client.initialize() + statuses = client.get_provider_status() + + assert statuses[0].name == "hindsight" + assert statuses[0].initialized is True + + @respx.mock def test_atomicmemory_property_returns_handle_when_provider_configured() -> None: from atomicmemory.providers.atomicmemory.handle_impl import AtomicMemoryHandle diff --git a/tests/conftest.py b/tests/conftest.py index 6bd3406..4d4957f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,9 +28,42 @@ def enabled(self) -> bool: return self.api_url is not None +@dataclass(frozen=True) +class HindsightIntegrationTestConfig: + """Live Hindsight smoke-test environment. + + The test suite intentionally keeps this separate from the AtomicMemory core + integration config so a developer can opt into one backend without starting + every supported backend locally. + """ + + enabled_flag: str | None + api_url: str | None + api_key: str | None + timeout_seconds: str | None + api_version: str | None + project_id: str | None + + @property + def enabled(self) -> bool: + return self.enabled_flag == "1" + + @pytest.fixture(scope="session") def integration_config() -> IntegrationTestConfig: return IntegrationTestConfig( api_url=os.environ.get("ATOMICMEMORY_TEST_API_URL"), api_key=os.environ.get("ATOMICMEMORY_TEST_API_KEY"), ) + + +@pytest.fixture(scope="session") +def hindsight_integration_config() -> HindsightIntegrationTestConfig: + return HindsightIntegrationTestConfig( + enabled_flag=os.environ.get("ATOMICMEMORY_HINDSIGHT_INTEGRATION"), + api_url=os.environ.get("HINDSIGHT_API_URL"), + api_key=os.environ.get("HINDSIGHT_API_KEY"), + timeout_seconds=os.environ.get("HINDSIGHT_TIMEOUT_SECONDS"), + api_version=os.environ.get("HINDSIGHT_API_VERSION"), + project_id=os.environ.get("HINDSIGHT_PROJECT_ID"), + ) diff --git a/tests/memory/test_registry.py b/tests/memory/test_registry.py index 52aca06..b4b5120 100644 --- a/tests/memory/test_registry.py +++ b/tests/memory/test_registry.py @@ -76,3 +76,10 @@ def test_contains() -> None: registry.register("stub", lambda _cfg: ProviderRegistration(provider=_Stub())) assert "stub" in registry assert "missing" not in registry + + +def test_default_registry_includes_hindsight() -> None: + import atomicmemory.providers.hindsight # noqa: F401 + from atomicmemory.memory.registry import default_registry + + assert "hindsight" in default_registry diff --git a/tests/providers/atomicmemory/test_provider.py b/tests/providers/atomicmemory/test_provider.py index a5de785..334d6a4 100644 --- a/tests/providers/atomicmemory/test_provider.py +++ b/tests/providers/atomicmemory/test_provider.py @@ -251,7 +251,7 @@ def test_health_returns_ok_flag(provider: AtomicMemoryProvider) -> None: def test_capabilities_advertises_atomicmemory_namespace( provider: AtomicMemoryProvider, ) -> None: - """Phase 3: every advertised custom_extension must resolve via get_extension.""" + """Every advertised custom_extension must resolve via get_extension.""" caps = provider.capabilities() assert caps.custom_extensions is not None diff --git a/tests/providers/hindsight/__init__.py b/tests/providers/hindsight/__init__.py new file mode 100644 index 0000000..83ea1d1 --- /dev/null +++ b/tests/providers/hindsight/__init__.py @@ -0,0 +1 @@ +"""Hindsight provider tests.""" diff --git a/tests/providers/hindsight/test_async_provider.py b/tests/providers/hindsight/test_async_provider.py new file mode 100644 index 0000000..68355fa --- /dev/null +++ b/tests/providers/hindsight/test_async_provider.py @@ -0,0 +1,82 @@ +"""Tests for AsyncHindsightProvider against a mocked Hindsight HTTP API.""" + +from __future__ import annotations + +import httpx +import pytest +import pytest_asyncio +import respx + +from atomicmemory.memory.types import PackageRequest, Scope, SearchRequest, TextIngest +from atomicmemory.providers.hindsight.async_provider import AsyncHindsightProvider +from atomicmemory.providers.hindsight.config import ( + AsyncHindsightOperationsHandle, + AsyncHindsightRetainHandle, + HindsightProviderConfig, +) + + +@pytest_asyncio.fixture +async def provider() -> AsyncHindsightProvider: + p = AsyncHindsightProvider(HindsightProviderConfig(api_url="http://hindsight.test")) + await p.initialize() + yield p + await p.close() + + +@pytest.mark.asyncio +@respx.mock +async def test_async_search(provider: AsyncHindsightProvider) -> None: + respx.post("http://hindsight.test/v1/default/banks/u1/memories/recall").mock( + return_value=httpx.Response( + 200, + json={"results": [{"id": "m-1", "text": "a", "created_at": "2024-01-01T00:00:00Z"}]}, + ) + ) + + page = await provider.search(SearchRequest(query="q", scope=Scope(user="u1"))) + + assert page.results[0].memory.id == "m-1" + + +@pytest.mark.asyncio +@respx.mock +async def test_async_package_reflect_and_health(provider: AsyncHindsightProvider) -> None: + respx.post("http://hindsight.test/v1/default/banks/u1/memories/recall").mock( + return_value=httpx.Response( + 200, + json={"results": [{"id": "m-1", "text": "a", "type": "world", "created_at": "2024-01-01T00:00:00Z"}]}, + ) + ) + respx.post("http://hindsight.test/v1/default/banks/u1/reflect").mock( + return_value=httpx.Response(200, json={"text": "answer"}) + ) + respx.get("http://hindsight.test/health").mock(return_value=httpx.Response(200, json={"ok": True})) + + pkg = await provider.package(PackageRequest(query="q", scope=Scope(user="u1"))) + insight = (await provider.reflect("q", Scope(user="u1")))[0] + health = await provider.health() + + assert pkg.text.startswith("Relevant memories:") + assert insight.content == "answer" + assert health.ok is True + + +@pytest.mark.asyncio +@respx.mock +async def test_async_custom_extensions(provider: AsyncHindsightProvider) -> None: + respx.post("http://hindsight.test/v1/default/banks/u1/memories").mock( + return_value=httpx.Response(200, json={"success": True, "operation_id": "op-1"}) + ) + respx.get("http://hindsight.test/v1/default/banks/u1/operations/op-1").mock( + return_value=httpx.Response(200, json={"operation_id": "op-1", "status": "completed"}) + ) + retain = provider.get_extension("hindsight.retain") + operations = provider.get_extension("hindsight.operations") + + assert isinstance(retain, AsyncHindsightRetainHandle) + assert isinstance(operations, AsyncHindsightOperationsHandle) + retained = await retain.retain(TextIngest(content="hi", scope=Scope(user="u1"))) + operation = await operations.get(Scope(user="u1"), retained.operation_id or "") + assert operation is not None + assert operation.status == "completed" diff --git a/tests/providers/hindsight/test_integration.py b/tests/providers/hindsight/test_integration.py new file mode 100644 index 0000000..97f0bfb --- /dev/null +++ b/tests/providers/hindsight/test_integration.py @@ -0,0 +1,127 @@ +"""Opt-in live smoke tests for the Hindsight provider. + +These tests require a running Hindsight backend with its own model configured. +They are skipped by default so ordinary SDK development never installs or +starts provider backends implicitly. +""" + +from __future__ import annotations + +from collections.abc import Generator +from uuid import uuid4 + +import pytest + +from atomicmemory.memory.types import Scope, SearchRequest, TextIngest +from atomicmemory.providers.hindsight.config import ( + HindsightOperation, + HindsightOperationsHandle, + HindsightProviderConfig, + HindsightRetainHandle, + HindsightRetainResponse, +) +from atomicmemory.providers.hindsight.provider import HindsightProvider +from tests.conftest import HindsightIntegrationTestConfig + +pytestmark = pytest.mark.integration + +COMPLETED_OPERATION_STATUSES = {"completed", "succeeded", "success"} +FAILED_OPERATION_STATUSES = {"cancelled", "canceled", "failed", "errored", "error", "timed_out", "timeout"} +OPERATION_STATUS_ATTEMPTS = 120 + + +@pytest.fixture +def live_hindsight_provider( + hindsight_integration_config: HindsightIntegrationTestConfig, +) -> Generator[HindsightProvider]: + if not hindsight_integration_config.enabled: + pytest.skip("Set ATOMICMEMORY_HINDSIGHT_INTEGRATION=1 to run live Hindsight tests") + if not hindsight_integration_config.api_url: + pytest.skip("Set HINDSIGHT_API_URL to run live Hindsight tests") + + provider = HindsightProvider(_provider_config(hindsight_integration_config)) + provider.initialize() + health = provider.health() + if not health.ok: + provider.close() + pytest.skip(f"Hindsight health check failed at {hindsight_integration_config.api_url}") + + yield provider + provider.close() + + +def test_live_retain_search_and_reflect(live_hindsight_provider: HindsightProvider) -> None: + scope = Scope(user=f"python-sdk-{uuid4().hex}", agent="hindsight-integration") + marker = f"python-hindsight-marker-{uuid4().hex}" + retain = live_hindsight_provider.get_extension("hindsight.retain") + operations = live_hindsight_provider.get_extension("hindsight.operations") + + assert isinstance(retain, HindsightRetainHandle) + assert isinstance(operations, HindsightOperationsHandle) + + retained = retain.retain( + TextIngest( + content=f"The integration marker is {marker}. Preserve this marker for recall.", + scope=scope, + ) + ) + assert retained.success is not False + _wait_for_retain_operations(operations, scope, retained) + + page = live_hindsight_provider.search(SearchRequest(query=marker, scope=scope, limit=3)) + insights = live_hindsight_provider.reflect("What is the integration marker?", scope) + + assert page.results + assert insights + assert insights[0].content + + +def _provider_config(raw_config: HindsightIntegrationTestConfig) -> HindsightProviderConfig: + config: dict[str, object] = {"api_url": raw_config.api_url} + if raw_config.api_key: + config["api_key"] = raw_config.api_key + if raw_config.timeout_seconds: + config["timeout_seconds"] = raw_config.timeout_seconds + if raw_config.api_version: + config["api_version"] = raw_config.api_version + if raw_config.project_id: + config["project_id"] = raw_config.project_id + return HindsightProviderConfig.model_validate(config) + + +def _wait_for_retain_operations( + operations: HindsightOperationsHandle, + scope: Scope, + retained: HindsightRetainResponse, +) -> None: + operation_ids = _retain_operation_ids(retained) + if not operation_ids and retained.async_ is False: + return + assert operation_ids, "Async Hindsight retain response must include operation_id or operation_ids" + for operation_id in operation_ids: + _wait_for_operation(operations, scope, operation_id) + + +def _wait_for_operation(operations: HindsightOperationsHandle, scope: Scope, operation_id: str) -> None: + last_operation: HindsightOperation | None = None + for _ in range(OPERATION_STATUS_ATTEMPTS): + last_operation = operations.get(scope, operation_id) + status = last_operation.status if last_operation else None + if status in COMPLETED_OPERATION_STATUSES: + return + if status in FAILED_OPERATION_STATUSES: + pytest.fail(f"Hindsight operation {operation_id} failed with status={status}") + + status = last_operation.status if last_operation else "missing" + pytest.fail(f"Hindsight operation {operation_id} did not complete after polling; last status={status}") + + +def _retain_operation_ids(retained: HindsightRetainResponse) -> list[str]: + operation_ids: list[str] = [] + if retained.operation_id: + operation_ids.append(retained.operation_id) + if retained.operation_ids: + operation_ids.extend( + operation_id for operation_id in retained.operation_ids if operation_id not in operation_ids + ) + return operation_ids diff --git a/tests/providers/hindsight/test_mappers.py b/tests/providers/hindsight/test_mappers.py new file mode 100644 index 0000000..05bf602 --- /dev/null +++ b/tests/providers/hindsight/test_mappers.py @@ -0,0 +1,115 @@ +"""Tests for Hindsight request builders and response mappers.""" + +from __future__ import annotations + +import pytest + +from atomicmemory.memory.types import Message, MessageIngest, Scope, TextIngest +from atomicmemory.providers.hindsight.config import HindsightProviderConfig +from atomicmemory.providers.hindsight.mappers import ( + build_recall_request, + build_reflect_request, + build_retain_request, + to_memory, + to_search_result, + unwrap_results, +) + + +def test_build_retain_request_maps_scope_tags_and_metadata() -> None: + body = build_retain_request( + TextIngest( + content="Alice likes aisle seats.", + scope=Scope(user="u1", agent="sdk", namespace="travel", thread="t1"), + metadata={"kind": "preference"}, + ) + ) + + item = body["items"][0] + assert body["async"] is False + assert item["content"] == "Alice likes aisle seats." + assert item["metadata"] == {"kind": "preference"} + assert item["tags"] == ["agent:sdk", "namespace:travel", "thread:t1"] + + +def test_build_retain_request_converts_messages_to_transcript() -> None: + body = build_retain_request( + MessageIngest( + messages=[ + Message(role="user", content="hi"), + Message(role="assistant", content="hello"), + ], + scope=Scope(user="u1"), + ) + ) + + assert body["items"][0]["content"] == "user: hi\nassistant: hello" + + +def test_build_recall_request_uses_max_tokens_and_all_strict_tags() -> None: + body = build_recall_request( + "q", + Scope(user="u1", agent="sdk"), + HindsightProviderConfig(api_url="http://hindsight.test", default_budget="mid"), + ) + + assert body["max_tokens"] == 4096 + assert body["budget"] == "mid" + assert body["tags"] == ["agent:sdk"] + assert body["tags_match"] == "all_strict" + + +def test_build_recall_request_preserves_explicit_zero_max_tokens() -> None: + body = build_recall_request( + "q", + Scope(user="u1"), + HindsightProviderConfig(api_url="http://hindsight.test", default_max_tokens=128), + max_tokens=0, + ) + + assert body["max_tokens"] == 0 + + +def test_build_reflect_request_uses_same_scope_tags() -> None: + body = build_reflect_request("q", Scope(user="u1", agent="sdk")) + assert body == {"query": "q", "tags": ["agent:sdk"], "tags_match": "all_strict"} + + +def test_unwrap_results_rejects_unknown_shapes() -> None: + with pytest.raises(ValueError, match="results array"): + unwrap_results({"items": []}) + + +def test_to_memory_maps_documented_fields_strictly() -> None: + memory = to_memory( + { + "id": "m-1", + "text": "Alice likes aisles.", + "type": "world", + "created_at": "2024-01-01T00:00:00Z", + "tags": ["agent:sdk"], + }, + Scope(user="u1"), + ) + + assert memory.content == "Alice likes aisles." + assert memory.kind == "fact" + assert memory.metadata == {"hindsightType": "world", "tags": ["agent:sdk"]} + + +def test_to_search_result_uses_zero_score_sentinel() -> None: + result = to_search_result( + { + "id": "m-1", + "text": "Alice likes aisles.", + "created_at": "2024-01-01T00:00:00Z", + }, + Scope(user="u1"), + ) + + assert result.score == 0.0 + + +def test_to_memory_requires_timestamp() -> None: + with pytest.raises(ValueError, match="missing timestamp"): + to_memory({"id": "m-1", "text": "no date"}, Scope(user="u1")) diff --git a/tests/providers/hindsight/test_provider.py b/tests/providers/hindsight/test_provider.py new file mode 100644 index 0000000..ae6d0a7 --- /dev/null +++ b/tests/providers/hindsight/test_provider.py @@ -0,0 +1,202 @@ +"""Tests for sync HindsightProvider against a mocked Hindsight HTTP API.""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from atomicmemory.core.errors import ProviderError +from atomicmemory.memory.types import ( + ListRequest, + MemoryRef, + PackageRequest, + Scope, + SearchRequest, + TextIngest, + VerbatimIngest, +) +from atomicmemory.providers.hindsight.config import ( + HindsightOperationsHandle, + HindsightProviderConfig, + HindsightRetainHandle, +) +from atomicmemory.providers.hindsight.provider import HindsightProvider + + +@pytest.fixture +def provider() -> HindsightProvider: + p = HindsightProvider(HindsightProviderConfig(api_url="http://hindsight.test")) + p.initialize() + yield p + p.close() + + +@respx.mock +def test_ingest_returns_empty_created_and_posts_retain(provider: HindsightProvider) -> None: + route = respx.post("http://hindsight.test/v1/default/banks/u1/memories").mock( + return_value=httpx.Response(200, json={"success": True, "operation_id": "op-1"}) + ) + + result = provider.ingest(TextIngest(content="hi", scope=Scope(user="u1", agent="sdk"))) + + assert result.created == [] + body = json.loads(route.calls[0].request.content) + assert body["items"][0]["tags"] == ["agent:sdk"] + assert body["async"] is False + + +def test_ingest_verbatim_raises(provider: HindsightProvider) -> None: + with pytest.raises(ProviderError, match="verbatim"): + provider.ingest(VerbatimIngest(content="raw", scope=Scope(user="u1"))) + + +@respx.mock +def test_search_truncates_limit_after_recall(provider: HindsightProvider) -> None: + route = respx.post("http://hindsight.test/v1/default/banks/u1/memories/recall").mock( + return_value=httpx.Response( + 200, + json={ + "results": [ + {"id": "m-1", "text": "a", "created_at": "2024-01-01T00:00:00Z"}, + {"id": "m-2", "text": "b", "created_at": "2024-01-02T00:00:00Z"}, + ] + }, + ) + ) + + page = provider.search(SearchRequest(query="q", scope=Scope(user="u1"), limit=1)) + + body = json.loads(route.calls[0].request.content) + assert body["max_tokens"] == 4096 + assert "limit" not in body + assert [hit.memory.id for hit in page.results] == ["m-1"] + + +@respx.mock +def test_list_get_delete_routes(provider: HindsightProvider) -> None: + respx.get("http://hindsight.test/v1/default/banks/u1/memories/list?limit=2&offset=0").mock( + return_value=httpx.Response( + 200, + json={ + "items": [ + {"id": "m-1", "text": "a", "date": "2024-01-01T00:00:00Z"}, + {"id": "m-2", "text": "b", "date": "2024-01-02T00:00:00Z"}, + ], + "total": 3, + }, + ) + ) + respx.get("http://hindsight.test/v1/default/banks/u1/memories/m-1").mock( + return_value=httpx.Response(200, json={"id": "m-1", "text": "a", "date": "2024-01-01T00:00:00Z"}) + ) + respx.delete("http://hindsight.test/v1/default/banks/u1/memories/missing").mock(return_value=httpx.Response(404)) + + page = provider.list(ListRequest(scope=Scope(user="u1"), limit=2)) + found = provider.get(MemoryRef(id="m-1", scope=Scope(user="u1"))) + provider.delete(MemoryRef(id="missing", scope=Scope(user="u1"))) + + assert page.cursor == "2" + assert found is not None + assert found.id == "m-1" + + +@respx.mock +def test_list_omits_cursor_when_total_is_exhausted(provider: HindsightProvider) -> None: + respx.get("http://hindsight.test/v1/default/banks/u1/memories/list?limit=2&offset=0").mock( + return_value=httpx.Response( + 200, + json={ + "items": [ + {"id": "m-1", "text": "a", "date": "2024-01-01T00:00:00Z"}, + {"id": "m-2", "text": "b", "date": "2024-01-02T00:00:00Z"}, + ], + "total": 2, + }, + ) + ) + + page = provider.list(ListRequest(scope=Scope(user="u1"), limit=2)) + + assert page.cursor is None + + +@respx.mock +def test_package_and_reflect_use_scope_tags(provider: HindsightProvider) -> None: + package_route = respx.post("http://hindsight.test/v1/default/banks/u1/memories/recall").mock( + return_value=httpx.Response( + 200, + json={"results": [{"id": "m-1", "text": "a", "type": "world", "created_at": "2024-01-01T00:00:00Z"}]}, + ) + ) + reflect_route = respx.post("http://hindsight.test/v1/default/banks/u1/reflect").mock( + return_value=httpx.Response( + 200, json={"text": "Alice validates AtomicMemory.", "based_on": {"memories": [{"id": "m-1"}]}} + ) + ) + + pkg = provider.package(PackageRequest(query="q", scope=Scope(user="u1", agent="sdk"), token_budget=128)) + insights = provider.reflect("q", Scope(user="u1", agent="sdk")) + + assert "Relevant memories:" in pkg.text + assert pkg.tokens > 0 + assert json.loads(package_route.calls[0].request.content)["tags_match"] == "all_strict" + assert json.loads(reflect_route.calls[0].request.content)["tags"] == ["agent:sdk"] + assert insights[0].confidence == 0.0 + assert insights[0].supporting_memory_ids == ["m-1"] + + +@respx.mock +def test_package_uses_memory_label_when_hindsight_type_is_absent(provider: HindsightProvider) -> None: + respx.post("http://hindsight.test/v1/default/banks/u1/memories/recall").mock( + return_value=httpx.Response( + 200, + json={"results": [{"id": "m-1", "text": "a", "created_at": "2024-01-01T00:00:00Z"}]}, + ) + ) + + pkg = provider.package(PackageRequest(query="q", scope=Scope(user="u1"))) + + assert pkg.text == "Relevant memories:\n- [memory] a" + + +@respx.mock +def test_health_maps_success_and_failure(provider: HindsightProvider) -> None: + respx.get("http://hindsight.test/health").mock( + return_value=httpx.Response(200, json={"status": "healthy", "version": "0.6.1"}) + ) + assert provider.health().ok is True + assert provider.health().version == "0.6.1" + + +@respx.mock +def test_custom_extensions_round_trip(provider: HindsightProvider) -> None: + respx.post("http://hindsight.test/v1/default/banks/u1/memories").mock( + return_value=httpx.Response(200, json={"success": True, "operation_id": "op-1"}) + ) + respx.get("http://hindsight.test/v1/default/banks/u1/operations/op-1").mock( + return_value=httpx.Response(200, json={"operation_id": "op-1", "status": "processing"}) + ) + retain = provider.get_extension("hindsight.retain") + operations = provider.get_extension("hindsight.operations") + + assert isinstance(retain, HindsightRetainHandle) + assert isinstance(operations, HindsightOperationsHandle) + retained = retain.retain(TextIngest(content="hi", scope=Scope(user="u1"))) + operation = operations.get(Scope(user="u1"), retained.operation_id or "") + assert operation is not None + assert operation.status == "processing" + + +@respx.mock +def test_retain_failure_has_context(provider: HindsightProvider) -> None: + respx.post("http://hindsight.test/v1/default/banks/u1/memories").mock( + return_value=httpx.Response( + 200, json={"success": False, "operation_id": "op-failed", "items_count": 2, "async": True} + ) + ) + + with pytest.raises(ProviderError, match="operation_id=op-failed, items_count=2, async=True"): + provider.ingest(TextIngest(content="hi", scope=Scope(user="u1"))) diff --git a/uv.lock b/uv.lock index 2ac396e..47601c9 100644 --- a/uv.lock +++ b/uv.lock @@ -79,7 +79,7 @@ wheels = [ [[package]] name = "atomicmemory" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "httpx" },