diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b7f9b0a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "attribution": { + "commit": "", + "pr": "", + "sessionUrl": false + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39e0acf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +# The connector's own gates: lint, format, types, and the test-suite (driven +# against a fake client — no live backend API). Mirrors rac-core's conventions. + +on: + push: + branches: [main] + pull_request: + # Reusable: the release workflow calls this so a publish is gated on the + # same lint + test suite that guards every push and PR. + workflow_call: + +permissions: + contents: read + +jobs: + lint: + name: lint (ruff + mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # setuptools-scm derives the version from git tags; fetch them so the + # editable install can resolve a version. + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: ruff check + run: python -m ruff check src/ tests/ scripts/ + + - name: ruff format --check + run: python -m ruff format --check src/ tests/ scripts/ + + - name: mypy + run: python -m mypy src/ + + - name: README connectors in sync + # The README's Connectors region is generated from docs/connectors/*.md; + # fail if a page was edited without re-running the sync. + run: python scripts/sync_readme.py --check + + test: + name: pytest (py${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package with dev extras + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: Run tests + run: python -m pytest -q diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..b7817b1 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,69 @@ +name: Upload Python Package + +# Publish rac-connectors to PyPI when a GitHub Release is published. The +# trigger is the Release, not a tag pattern, so the tag name carries no `v` +# requirement. Versioning is tag-driven (setuptools-scm) and CalVer (ADR-008): +# tag YYYY.M. (e.g. 2026.6.1), publish the release, and the build is +# that version. Mirrors rac-core's release flow. + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + ci: + # Gate the release on the full lint + test suite (reusable CI workflow). + # Nothing is built or published unless it passes. + uses: ./.github/workflows/ci.yml + + release-build: + needs: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # setuptools-scm derives the version from git tags, so fetch the full + # history + tags (the default shallow checkout omits them). + fetch-depth: 0 + + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Build release distributions + run: | + python -m pip install build + python -m build + + - name: Upload distributions + uses: actions/upload-artifact@v6 + with: + name: release-dists + path: dist/ + + pypi-publish: + runs-on: ubuntu-latest + needs: + - release-build + permissions: + # Mandatory for PyPI trusted publishing (OIDC) — no API token needed. + id-token: write + + environment: + name: pypi + url: https://pypi.org/p/rac-connectors + + steps: + - name: Retrieve release distributions + uses: actions/download-artifact@v7 + with: + name: release-dists + path: dist/ + + - name: Publish release distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36995e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ + +# Tooling +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ + +# Env / secrets +.env diff --git a/.rac/config.yaml b/.rac/config.yaml new file mode 100644 index 0000000..998cc13 --- /dev/null +++ b/.rac/config.yaml @@ -0,0 +1 @@ +repository_key: LCON diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..302587d --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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 reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + 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 Tom Ballard + + 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/NOTICE b/NOTICE new file mode 100644 index 0000000..a663138 --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +rac-connectors +Copyright 2026 Tom Ballard + +This product is licensed under the Apache License, Version 2.0 (see LICENSE). + +Companion to Lore / RAC (requirements-as-code, https://github.com/itsthelore/rac-core). diff --git a/README.md b/README.md new file mode 100644 index 0000000..05e7fb1 --- /dev/null +++ b/README.md @@ -0,0 +1,542 @@ +# rac-connectors + + + + + Lore — agents that know why. Deterministic. Read-only. No RAG, no guessing. + + +

+Quickstart · +How it works · +Connectors · +Add a backend · +Lore / RAC +

+ +

+CI +Python +Typed +License: Apache 2.0 +

+ +> **Push the decisions your team already recorded into the memory and RAG tools your agent already uses — so it can recall fuzzily there, then verify in Lore.** + +rac-connectors is the **outbound** companion to [Lore](https://github.com/itsthelore/rac-core) — the product surface of **RAC — Requirements as Code**, the open-source engine underneath. RAC keeps your team's requirements, decisions, designs, roadmaps, and prompts as typed Markdown and serves them **read-only** over MCP. This repo holds the connectors that ship RAC's export payloads into the external memory, RAG, and graph backends a team already runs. It is a *consumer of a stable export contract*, not part of the engine: no embeddings, vectors, or model calls happen here — those live in the backend. The first connector is **Supermemory**. + +## How it compares + +A connector isn't a sync tool or a second source of truth — it keeps a fuzzy +backend **fresh** so an agent can recall loosely, then return to Lore for the +exact, current decision. Recall fuzzily, verify in Lore. + +| | Lore | The backend (Supermemory / RAG / memory) | +|---|---|---| +| Good at | the exact, current decision | finding what's *near* a question | +| Retrieval | deterministic, reproducible | similarity-ranked, varies by run | +| Role | source of truth, read-only | a fast index this connector keeps fresh | +| Direction | the agent verifies here | this connector pushes here, one-way | + +## Quickstart + +1. **Install** a connector — pick your backend from [Connectors](#connectors) and + install its extra (see [Install](#install) for the from-source command until + it's on PyPI): + + ```bash + pip install 'rac-connectors[supermemory]' + ``` + +2. **Authenticate** the backend via the environment (never hard-coded): + + ```bash + export SUPERMEMORY_API_KEY=sk-... + ``` + +3. **Push** the corpus — pipe a `rac export --documents` stream straight in: + + ```bash + rac export rac/ --documents | rac-connect supermemory + ``` + +4. **Preview** first if you like — `--dry-run` calls no API: + + ```bash + rac export rac/ --documents | rac-connect supermemory --dry-run + ``` + +Re-running is idempotent: a re-push updates rather than duplicates. Each +backend's exact commands, auth, and flags live under [Connectors](#connectors). + +## Install + +There is nothing to build — it's pure Python. Installing puts a `rac-connect` +command on your PATH. + +**From PyPI** (once published — the name is reserved): + +```bash +pip install 'rac-connectors[supermemory]' +``` + +**From source today** (pre-release — install straight from the repo): + +```bash +# one-liner, no clone: +pip install 'rac-connectors[supermemory] @ git+https://github.com/itsthelore/rac-connectors.git' + +# or from a clone (editable, for hacking on it): +git clone https://github.com/itsthelore/rac-connectors.git +cd rac-connectors +pip install -e '.[supermemory]' +``` + +| Extra | Gets you | +|---|---| +| *(none)* | the `rac-connect` CLI + the connector library + `--dry-run` | +| `[]` | + that backend's SDK, needed for a live push — one per connector (see [Connectors](#connectors)) | +| `[dev]` | + ruff, mypy, and pytest for development | + +Requires Python 3.11+, and the [`rac`](https://github.com/itsthelore/rac-core) +engine (`pip install requirements-as-code`) to produce the export. The core +install and the whole test-suite are dependency-free — provider SDKs are +optional extras, so CI never needs a live backend. + +## How it works + +```text +rac export rac/ --documents # Lore emits one JSON line per artifact + │ + ▼ +rac-connect supermemory # this repo: upsert each record into the backend + │ + ▼ +Supermemory (fuzzy, associative recall) + │ + ▼ the agent recalls a candidate by id, then… +get_artifact / rac resolve # …verifies the authoritative text in Lore +``` + +- **One-way, outbound only.** The connector pushes to the backend and never + pulls, re-ranks, or routes; the verify-in-Lore loop is the reading agent's + job, not this connector's. +- **Idempotent on the canonical `id`.** Each record maps to + `add(content=text, container_tag=metadata.source, metadata={id, type, status, …}, custom_id=id)`, + so re-exporting and re-pushing updates the stored copy instead of duplicating + it. +- **No embeddings here.** The backend embeds; the connector only ships text and + metadata (rac-core ADR-002, ADR-066). + +## Connectors + +One package, one CLI: pick a backend with a subcommand (`rac-connect +`) and pull only its SDK via the matching extra. Each connector's full +page lives in [`docs/connectors/`](docs/connectors/); the collapsible sections +below are generated from those pages, so this README and the pages never drift. + + + + +
+Supermemory — documents → server-side embedding, idempotent on the canonical id + +A one-way, outbound push of the `rac export --documents` stream into +[Supermemory](https://supermemory.ai). + +```bash +pip install 'rac-connectors[supermemory]' +export SUPERMEMORY_API_KEY=sk-... + +rac export rac/ --documents | rac-connect supermemory # upsert every record +rac export rac/ --documents | rac-connect supermemory --dry-run # preview, no API call +rac-connect supermemory --input corpus.jsonl # read a file, not stdin +``` + +Each record maps to a Supermemory upsert: + +``` +record → add(content=text, + container_tag=metadata.source, + metadata={rac id, type, status, title, path, …}, + custom_id=id) +``` + +| Flag | Meaning | +|---|---| +| `--dry-run` | Print what would be sent; make no API call. | +| `--input`, `-i` | Read JSONL from a file (default: stdin; `-` also means stdin). | +| `--strict` | Fail on a malformed line instead of skipping it. | +| `--verbose`, `-v` | Print per-record actions on a live push too. | + +- **Idempotent on the canonical `id`.** `custom_id=id` makes a re-push an update, + not a duplicate. +- **No embeddings here.** Supermemory embeds; the connector only ships text + + metadata. +- **Auth via `SUPERMEMORY_API_KEY`** — never hard-coded. Set + `SUPERMEMORY_BASE_URL` to point at a self-hosted instance. + +Decision: [`rac/decisions/`](rac/decisions) — the connector seam (ADR-002). + +**Full page:** [`docs/connectors/supermemory.md`](docs/connectors/supermemory.md) + +
+ +
+Mem0 — documents → server-side embedding; idempotent by container resync + +A one-way, outbound push of the `rac export --documents` stream into +[Mem0](https://mem0.ai). Same stream and flags as the other documents backends, +a different subcommand: + +```bash +pip install 'rac-connectors[mem0]' +export MEM0_API_KEY=m0-... + +rac export rac/ --documents | rac-connect mem0 # upsert every record +rac export rac/ --documents | rac-connect mem0 --dry-run # preview, no API call +rac-connect mem0 --input corpus.jsonl # read a file, not stdin +``` + +- **Stores the text as-is.** `infer=False` skips Mem0's LLM fact-extraction, so it + only embeds the artifact text; the canonical `rac_id`, `type`, `status`, and + `title` ride in metadata for the verify-in-Lore loop. +- **Idempotent by container resync.** Mem0 has no per-record upsert key, so each + push clears the corpus partition (Mem0 `user_id = source`) and re-adds — + re-running never duplicates. The trade-off (a wipe-and-rebuild rather than a + surgical update) is recorded in the decision. +- **No embeddings here.** Mem0 embeds; the connector only ships text + metadata. +- **Auth via `MEM0_API_KEY`** — never hard-coded. + +Decision: [`rac/decisions/`](rac/decisions) — ADR-004 (Mem0 backend, resync idempotency). + +**Full page:** [`docs/connectors/mem0.md`](docs/connectors/mem0.md) + +
+ +
+Zep — documents → a Zep knowledge graph; idempotent by graph resync + +A one-way, outbound push of the `rac export --documents` stream into +[Zep Cloud](https://getzep.com). Same stream and flags as the other documents +backends, a different subcommand: + +```bash +pip install 'rac-connectors[zep]' +export ZEP_API_KEY=z_... + +rac export rac/ --documents | rac-connect zep # upsert every record +rac export rac/ --documents | rac-connect zep --dry-run # preview, no API call +rac-connect zep --input corpus.jsonl # read a file, not stdin +``` + +- **A corpus maps to a Zep graph.** A `source` becomes a Zep `graph_id`; each + record is added as a `type="text"` episode carrying the canonical `rac_id`, + `type`, `status`, and `title` in metadata. +- **Idempotent by graph resync.** Zep has no per-record upsert key, so each push + deletes and recreates the corpus graph, then re-adds — re-running never + duplicates. +- **No embeddings here.** Zep derives its knowledge graph and embeds; the + connector only ships text + metadata. Zep's copy is an associative index, not a + citation — authoritative text is always re-fetched from Lore. +- **Auth via `ZEP_API_KEY`** — never hard-coded. + +Decision: [`rac/decisions/`](rac/decisions) — ADR-005 (Zep backend, graph-resync idempotency). + +**Full page:** [`docs/connectors/zep.md`](docs/connectors/zep.md) + +
+ +
+Letta — documents → Letta archives (cloud or self-hosted); idempotent by archive resync + +A one-way, outbound push of the `rac export --documents` stream into +[Letta](https://letta.com) archives. Same stream and flags as the other +documents backends, a different subcommand: + +```bash +pip install 'rac-connectors[letta]' +export LETTA_API_KEY=... # Letta Cloud +# or, self-hosted: export LETTA_BASE_URL=http://localhost:8283 + +rac export rac/ --documents | rac-connect letta # upsert every record +rac export rac/ --documents | rac-connect letta --dry-run # preview, no API call +rac-connect letta --input corpus.jsonl # read a file, not stdin +``` + +- **A corpus maps to a Letta archive.** A `source` becomes a named archive; each + record is added as a passage carrying the canonical `rac_id`, `type`, + `status`, and `title` in metadata. (The connector resolves the opaque + `archive_id` internally, so you address it by the source name.) +- **Idempotent by archive resync.** Letta has no per-record upsert key, so each + push deletes and recreates the corpus archive, then re-adds — re-running never + duplicates. +- **Cloud or self-hosted.** Auth via `LETTA_API_KEY` (Letta Cloud) **or** + `LETTA_BASE_URL` (a self-hosted server). Letta embeds the passages; nothing is + embedded here. + +Decision: [`rac/decisions/`](rac/decisions) — ADR-006 (Letta backend, archive-resync idempotency). + +**Full page:** [`docs/connectors/letta.md`](docs/connectors/letta.md) + +
+ +
+Cognee — documents → a Cognee knowledge graph; content-hash idempotent + +The odd one out: [Cognee](https://www.cognee.ai) is an async pipeline that builds +the corpus into a **knowledge graph** (`add` then `cognify`) rather than a +per-record store. It still consumes the same `rac export --documents` stream: + +```bash +pip install 'rac-connectors[cognee]' +export LLM_API_KEY=... # Cognee needs an LLM to cognify + +rac export rac/ --documents | rac-connect cognee # build the graph +rac export rac/ --documents | rac-connect cognee --dry-run # preview, no pipeline run +rac-connect cognee --input corpus.jsonl # read a file, not stdin +``` + +- **A corpus maps to a Cognee dataset.** Each record is staged with a `Rac-Id:` + provenance header (Cognee has no per-record metadata filter), then the whole + dataset is built once via `add` + `cognify`. +- **Content-hash idempotency, not a resync.** Cognee's native + `incremental_loading` dedups by content hash, so re-pushing unchanged records + is a no-op. **Caveat:** it does **not** prune artifacts deleted from the corpus + (unlike the wipe-and-rebuild backends). +- **No embeddings here.** Cognee builds the graph and embeds; the connector only + ships text. Auth via `LLM_API_KEY` (Cognee's LLM credential). + +Decision: [`rac/decisions/`](rac/decisions) — ADR-007 (Cognee backend, two-phase pipeline, the deletion-prune trade-off). + +**Full page:** [`docs/connectors/cognee.md`](docs/connectors/cognee.md) + +
+ +
+Neo4j — graph → typed nodes & edges via Cypher MERGE; idempotent on the canonical id + +The other export projection, `rac export --graph`, is Lore's *real, validated* +relationship graph — typed nodes and edges (`supersedes`, `related_decisions`, +…). The [Neo4j](https://neo4j.com) connector loads it so an agent can traverse +the actual decision graph instead of one an LLM inferred from prose: + +```bash +pip install 'rac-connectors[neo4j]' +export NEO4J_URI=bolt://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=... + +rac export rac/ --graph | rac-connect neo4j # upsert nodes + edges +rac export rac/ --graph | rac-connect neo4j --dry-run # preview, no connection +rac-connect neo4j --input graph.json # read a file, not stdin +``` + +- **Idempotent via Cypher `MERGE`** on the canonical `id` — nodes + `MERGE (n:Artifact {id})`, edges `MERGE (a)-[r:REL {type}]->(b)` — so a re-push + updates in place and never duplicates a node or relationship. +- **Faithful to the export.** Undirected edges (`directed:false`) are written + once carrying `directed=false`; unresolved references (`resolved:false`) are + skipped, never written as phantom nodes. +- **Injection-safe.** Every node and edge value is a query parameter; only the + fixed labels `Artifact`/`REL` are interpolated, so no corpus content reaches + Cypher as code. +- **Outbound only.** It writes the graph and never queries, traverses, or + analyses — the verify-in-Lore loop stays the agent's job. Auth via `NEO4J_URI` + / `NEO4J_USERNAME` / `NEO4J_PASSWORD`. + +### The `--graph` contract it consumes + +`rac export --graph` emits one JSON object of typed nodes and edges: + +```json +{"schema_version":"1","source":"rac", + "nodes":[{"id":"RAC-…","type":"decision","status":"Accepted","title":"…"}], + "edges":[{"source":"RAC-…","target":"RAC-…","type":"supersedes", + "directed":true,"resolved":true}]} +``` + +`edges[].type` is the real relationship kind with its registry direction; +`resolved:false` means the reference didn't resolve and `target` is literal text. +The contract is additive and stable (rac-core ADR-007). + +### Python API + +```python +from rac_connectors import parse_graph +from rac_connectors.neo4j import Neo4jConnector, client_from_env + +graph = parse_graph(open("graph.json").read()) +summary = Neo4jConnector(client_from_env()).push_graph(graph) +print(summary.summary_line()) # -> "neo4j push: 1494 pushed, 0 skipped" +``` + +Pass `dry_run=True` to preview without a client or a connection. + +Design + decision: [`rac/designs/`](rac/designs) (graph-connector-shape) and +[`rac/decisions/`](rac/decisions) (ADR-003). + +**Full page:** [`docs/connectors/neo4j.md`](docs/connectors/neo4j.md) + +
+ + + +## Run it in CI + +`rac-connect` is a one-shot command — it pushes and exits — so keeping a +backend fresh is just a job that runs the pipe whenever the corpus changes. A +GitHub Actions step on merge to `main`: + +```yaml +name: Sync corpus to Supermemory +on: + push: + branches: [main] +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - run: pip install requirements-as-code 'rac-connectors[supermemory]' + - run: rac export rac/ --documents | rac-connect supermemory + env: + SUPERMEMORY_API_KEY: ${{ secrets.SUPERMEMORY_API_KEY }} +``` + +The same one-liner works from a cron job or a git post-commit hook. Because the +push is idempotent on the canonical `id`, running it on every change only +updates — it never duplicates — so you don't need to diff or prune first. + +## The export contract + +`rac export --documents` emits JSON Lines, one record per artifact: + +```json +{"schema_version":"1","id":"RAC-…","type":"decision","status":"Accepted", + "title":"ADR-001: Markdown First","text":"…Markdown body, frontmatter stripped…", + "metadata":{"path":"…","aliases":["adr-001"],"tags":[],"source":"rac"}} +``` + +`text` is the Markdown body (backends embed text, not HTML); `id` is the +canonical handle for the verify-in-Lore round-trip; `status` lets a reader drop +retired or superseded items. The contract is additive and stable (rac-core +ADR-007) — connectors depend only on it. + +The connector targets export **`schema_version` 1** — the only thing it depends +on across repos (not the `rac` package version). It reads from any `rac` release +that emits version 1, and warns (on stderr) if it ever sees a newer contract +major. See `rac/decisions/` (ADR-008). + +## Python API + +The connector is a library too. Parse a `--documents` stream into records and +push them through any backend module: + +```python +from rac_connectors import parse_documents +from rac_connectors.supermemory import SupermemoryConnector, client_from_env + +records = parse_documents(open("corpus.jsonl")) +summary = SupermemoryConnector(client_from_env()).push(records) +print(summary.summary_line()) # -> "supermemory push: 263 pushed, 0 skipped" +``` + +Pass `dry_run=True` to preview without a client or an API call. + +## One package, many backends + +There is **one** `rac-connectors` package on PyPI, not one per provider. As +more backends land, you don't install or learn a new tool — you: + +- **pick the backend with a CLI subcommand:** `rac-connect supermemory`, + later `rac-connect mem0`, `rac-connect neo4j`, …; and +- **pull only the SDKs you use, as extras:** + `pip install 'rac-connectors[supermemory,mem0]'`. The base install and the + test-suite stay dependency-free; a provider's SDK arrives only with its extra. + +This is a recorded decision, not a convenience: rac-core ADR-073 keeps all +backend connectors in one repo (the export contract is the product, so most +backends need no per-provider package), and this repo's ADR-002 fixes "one +outbound `push` seam, one module per backend, one CLI subcommand each." A +provider only graduates to its own package if it grows into an installable +product with independent cadence — the documented escape hatch, not the default. + +## Add a backend + +A new backend is a module under `src/rac_connectors/` implementing one outbound +seam — record parsing, the CLI, dry-run, and the summary shape are shared: + +```python +class Connector(Protocol): + name: str + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: ... +``` + +The module supplies the upsert mapping behind a thin, mockable client, and adds +its subcommand and optional `[backend]` extra. Document it once in +`docs/connectors/.md` (with a `` metadata header) +and run `python scripts/sync_readme.py` — that stitches the page into the +[Connectors](#connectors) section above, so each connector owns its own file and +the README never drifts. Named future targets (shape only, not built): +documents → Mem0, Zep, Letta, Cognee, Pinecone, Weaviate, Qdrant, Chroma, Milvus, +pgvector, LanceDB; graph → Neo4j, Zep Graphiti, Cognee, Microsoft GraphRAG. + +## Who it's for + +- **Teams running Lore** who also run a memory or RAG backend and want the + agent to recall fuzzily there, then verify against the authoritative corpus. +- **Teams who want semantic recall over their decisions** without putting a + fuzzy component inside Lore's deterministic serving path. +- **Anyone wiring Lore's export into the backend they already operate** — the + export contract is the product; this repo is the reference adapter. + +## Documentation + +This repo consumes Lore's export contract; the engine and its CLI are +documented with Lore. + +- [Lore / RAC](https://github.com/itsthelore/rac-core) — the engine, CLI, and MCP server +- [CLI reference — `rac export`](https://itsthelore.github.io/rac-core/cli/#export) — the `--documents` / `--graph` contract this consumes + +## Origin + +rac-connectors is the **connector companion** to Lore / RAC. rac-core ADR-073 +settles the topology: backend connectors are export-contract consumers, so they +consolidate into **one** repo with one module per backend — not a repo per +provider, and never inside the engine (it stays pure-Python, AI-optional, and +offline). This repo dogfoods Lore for its own decisions under `rac/`. + +## Repository layout + +```text +rac-connectors/ + src/rac_connectors/ the connector library: the documents reader, the shared + push seam, the rac-connect CLI, and one module per + backend (supermemory/ first) + tests/ the suite, driven against a fake client — no live API + rac/ the dogfood corpus: this repo's own decisions (ADRs), + keyed LCON + .github/workflows/ CI — ruff, mypy, and the test-suite +``` + +## Test + +```bash +pip install -e .[dev] +python -m pytest +``` + +`ruff check`, `ruff format --check`, and `mypy src/` run in CI alongside the +test-suite across Python 3.11–3.13. + +## Project status + +Early and evolving alongside Lore. The Supermemory connector ships today; +further backends slot in as new modules (see [Add a backend](#add-a-backend)). +Contributions, ideas, and experiments welcome. + +## License + +[Apache License 2.0](LICENSE). Matches `rac-core`. diff --git a/docs/connectors/cognee.md b/docs/connectors/cognee.md new file mode 100644 index 0000000..730b221 --- /dev/null +++ b/docs/connectors/cognee.md @@ -0,0 +1,33 @@ + +# Cognee + +The odd one out: [Cognee](https://www.cognee.ai) is an async pipeline that builds +the corpus into a **knowledge graph** (`add` then `cognify`) rather than a +per-record store. It still consumes the same `rac export --documents` stream: + +```bash +pip install 'rac-connectors[cognee]' +export LLM_API_KEY=... # Cognee needs an LLM to cognify + +rac export rac/ --documents | rac-connect cognee # build the graph +rac export rac/ --documents | rac-connect cognee --dry-run # preview, no pipeline run +rac-connect cognee --input corpus.jsonl # read a file, not stdin +``` + +- **A corpus maps to a Cognee dataset.** Each record is staged with a `Rac-Id:` + provenance header (Cognee has no per-record metadata filter), then the whole + dataset is built once via `add` + `cognify`. +- **Content-hash idempotency, not a resync.** Cognee's native + `incremental_loading` dedups by content hash, so re-pushing unchanged records + is a no-op. **Caveat:** it does **not** prune artifacts deleted from the corpus + (unlike the wipe-and-rebuild backends). +- **No embeddings here.** Cognee builds the graph and embeds; the connector only + ships text. Auth via `LLM_API_KEY` (Cognee's LLM credential). + +Decision: [`rac/decisions/`](../../rac/decisions/) — ADR-007 (Cognee backend, two-phase pipeline, the deletion-prune trade-off). diff --git a/docs/connectors/letta.md b/docs/connectors/letta.md new file mode 100644 index 0000000..c4776bf --- /dev/null +++ b/docs/connectors/letta.md @@ -0,0 +1,35 @@ + +# Letta + +A one-way, outbound push of the `rac export --documents` stream into +[Letta](https://letta.com) archives. Same stream and flags as the other +documents backends, a different subcommand: + +```bash +pip install 'rac-connectors[letta]' +export LETTA_API_KEY=... # Letta Cloud +# or, self-hosted: export LETTA_BASE_URL=http://localhost:8283 + +rac export rac/ --documents | rac-connect letta # upsert every record +rac export rac/ --documents | rac-connect letta --dry-run # preview, no API call +rac-connect letta --input corpus.jsonl # read a file, not stdin +``` + +- **A corpus maps to a Letta archive.** A `source` becomes a named archive; each + record is added as a passage carrying the canonical `rac_id`, `type`, + `status`, and `title` in metadata. (The connector resolves the opaque + `archive_id` internally, so you address it by the source name.) +- **Idempotent by archive resync.** Letta has no per-record upsert key, so each + push deletes and recreates the corpus archive, then re-adds — re-running never + duplicates. +- **Cloud or self-hosted.** Auth via `LETTA_API_KEY` (Letta Cloud) **or** + `LETTA_BASE_URL` (a self-hosted server). Letta embeds the passages; nothing is + embedded here. + +Decision: [`rac/decisions/`](../../rac/decisions/) — ADR-006 (Letta backend, archive-resync idempotency). diff --git a/docs/connectors/mem0.md b/docs/connectors/mem0.md new file mode 100644 index 0000000..7f51b36 --- /dev/null +++ b/docs/connectors/mem0.md @@ -0,0 +1,33 @@ + +# Mem0 + +A one-way, outbound push of the `rac export --documents` stream into +[Mem0](https://mem0.ai). Same stream and flags as the other documents backends, +a different subcommand: + +```bash +pip install 'rac-connectors[mem0]' +export MEM0_API_KEY=m0-... + +rac export rac/ --documents | rac-connect mem0 # upsert every record +rac export rac/ --documents | rac-connect mem0 --dry-run # preview, no API call +rac-connect mem0 --input corpus.jsonl # read a file, not stdin +``` + +- **Stores the text as-is.** `infer=False` skips Mem0's LLM fact-extraction, so it + only embeds the artifact text; the canonical `rac_id`, `type`, `status`, and + `title` ride in metadata for the verify-in-Lore loop. +- **Idempotent by container resync.** Mem0 has no per-record upsert key, so each + push clears the corpus partition (Mem0 `user_id = source`) and re-adds — + re-running never duplicates. The trade-off (a wipe-and-rebuild rather than a + surgical update) is recorded in the decision. +- **No embeddings here.** Mem0 embeds; the connector only ships text + metadata. +- **Auth via `MEM0_API_KEY`** — never hard-coded. + +Decision: [`rac/decisions/`](../../rac/decisions/) — ADR-004 (Mem0 backend, resync idempotency). diff --git a/docs/connectors/neo4j.md b/docs/connectors/neo4j.md new file mode 100644 index 0000000..4560efd --- /dev/null +++ b/docs/connectors/neo4j.md @@ -0,0 +1,66 @@ + +# Neo4j + +The other export projection, `rac export --graph`, is Lore's *real, validated* +relationship graph — typed nodes and edges (`supersedes`, `related_decisions`, +…). The [Neo4j](https://neo4j.com) connector loads it so an agent can traverse +the actual decision graph instead of one an LLM inferred from prose: + +```bash +pip install 'rac-connectors[neo4j]' +export NEO4J_URI=bolt://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=... + +rac export rac/ --graph | rac-connect neo4j # upsert nodes + edges +rac export rac/ --graph | rac-connect neo4j --dry-run # preview, no connection +rac-connect neo4j --input graph.json # read a file, not stdin +``` + +- **Idempotent via Cypher `MERGE`** on the canonical `id` — nodes + `MERGE (n:Artifact {id})`, edges `MERGE (a)-[r:REL {type}]->(b)` — so a re-push + updates in place and never duplicates a node or relationship. +- **Faithful to the export.** Undirected edges (`directed:false`) are written + once carrying `directed=false`; unresolved references (`resolved:false`) are + skipped, never written as phantom nodes. +- **Injection-safe.** Every node and edge value is a query parameter; only the + fixed labels `Artifact`/`REL` are interpolated, so no corpus content reaches + Cypher as code. +- **Outbound only.** It writes the graph and never queries, traverses, or + analyses — the verify-in-Lore loop stays the agent's job. Auth via `NEO4J_URI` + / `NEO4J_USERNAME` / `NEO4J_PASSWORD`. + +### The `--graph` contract it consumes + +`rac export --graph` emits one JSON object of typed nodes and edges: + +```json +{"schema_version":"1","source":"rac", + "nodes":[{"id":"RAC-…","type":"decision","status":"Accepted","title":"…"}], + "edges":[{"source":"RAC-…","target":"RAC-…","type":"supersedes", + "directed":true,"resolved":true}]} +``` + +`edges[].type` is the real relationship kind with its registry direction; +`resolved:false` means the reference didn't resolve and `target` is literal text. +The contract is additive and stable (rac-core ADR-007). + +### Python API + +```python +from rac_connectors import parse_graph +from rac_connectors.neo4j import Neo4jConnector, client_from_env + +graph = parse_graph(open("graph.json").read()) +summary = Neo4jConnector(client_from_env()).push_graph(graph) +print(summary.summary_line()) # -> "neo4j push: 1494 pushed, 0 skipped" +``` + +Pass `dry_run=True` to preview without a client or a connection. + +Design + decision: [`rac/designs/`](../../rac/designs/) (graph-connector-shape) and +[`rac/decisions/`](../../rac/decisions/) (ADR-003). diff --git a/docs/connectors/supermemory.md b/docs/connectors/supermemory.md new file mode 100644 index 0000000..77d841d --- /dev/null +++ b/docs/connectors/supermemory.md @@ -0,0 +1,45 @@ + +# Supermemory + +A one-way, outbound push of the `rac export --documents` stream into +[Supermemory](https://supermemory.ai). + +```bash +pip install 'rac-connectors[supermemory]' +export SUPERMEMORY_API_KEY=sk-... + +rac export rac/ --documents | rac-connect supermemory # upsert every record +rac export rac/ --documents | rac-connect supermemory --dry-run # preview, no API call +rac-connect supermemory --input corpus.jsonl # read a file, not stdin +``` + +Each record maps to a Supermemory upsert: + +``` +record → add(content=text, + container_tag=metadata.source, + metadata={rac id, type, status, title, path, …}, + custom_id=id) +``` + +| Flag | Meaning | +|---|---| +| `--dry-run` | Print what would be sent; make no API call. | +| `--input`, `-i` | Read JSONL from a file (default: stdin; `-` also means stdin). | +| `--strict` | Fail on a malformed line instead of skipping it. | +| `--verbose`, `-v` | Print per-record actions on a live push too. | + +- **Idempotent on the canonical `id`.** `custom_id=id` makes a re-push an update, + not a duplicate. +- **No embeddings here.** Supermemory embeds; the connector only ships text + + metadata. +- **Auth via `SUPERMEMORY_API_KEY`** — never hard-coded. Set + `SUPERMEMORY_BASE_URL` to point at a self-hosted instance. + +Decision: [`rac/decisions/`](../../rac/decisions/) — the connector seam (ADR-002). diff --git a/docs/connectors/zep.md b/docs/connectors/zep.md new file mode 100644 index 0000000..c8c52fe --- /dev/null +++ b/docs/connectors/zep.md @@ -0,0 +1,34 @@ + +# Zep + +A one-way, outbound push of the `rac export --documents` stream into +[Zep Cloud](https://getzep.com). Same stream and flags as the other documents +backends, a different subcommand: + +```bash +pip install 'rac-connectors[zep]' +export ZEP_API_KEY=z_... + +rac export rac/ --documents | rac-connect zep # upsert every record +rac export rac/ --documents | rac-connect zep --dry-run # preview, no API call +rac-connect zep --input corpus.jsonl # read a file, not stdin +``` + +- **A corpus maps to a Zep graph.** A `source` becomes a Zep `graph_id`; each + record is added as a `type="text"` episode carrying the canonical `rac_id`, + `type`, `status`, and `title` in metadata. +- **Idempotent by graph resync.** Zep has no per-record upsert key, so each push + deletes and recreates the corpus graph, then re-adds — re-running never + duplicates. +- **No embeddings here.** Zep derives its knowledge graph and embeds; the + connector only ships text + metadata. Zep's copy is an associative index, not a + citation — authoritative text is always re-fetched from Lore. +- **Auth via `ZEP_API_KEY`** — never hard-coded. + +Decision: [`rac/decisions/`](../../rac/decisions/) — ADR-005 (Zep backend, graph-resync idempotency). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2db02b8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,95 @@ +[build-system] +requires = ["setuptools>=77", "setuptools-scm>=8"] +build-backend = "setuptools.build_meta" + +[project] +name = "rac-connectors" +# Version is derived from the git tag by setuptools-scm (mirrors rac-core). +# Release flow (CalVer, ADR-008): tag YYYY.M. (e.g. 2026.6.1, no `v`, +# no zero-padded month) -> the build is that version. No manual edits. +dynamic = ["version"] +description = "Outbound connectors that push Lore's (RAC) corpus export into external memory, RAG, and graph backends." +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE"] +authors = [{ name = "Tom Ballard" }] +keywords = ["lore", "rac", "requirements-as-code", "rag", "memory", "supermemory", "connector"] +dependencies = [] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries", +] + +[project.urls] +Homepage = "https://github.com/itsthelore/rac-connectors" +Repository = "https://github.com/itsthelore/rac-connectors" +Issues = "https://github.com/itsthelore/rac-connectors/issues" +Engine = "https://github.com/itsthelore/rac-core" + +[project.optional-dependencies] +# The live Supermemory push needs the provider SDK; the core install and the +# whole test-suite (which drives a fake client) stay dependency-free. Pinned to +# the v3 major whose add() shape the adapter is verified against. +supermemory = ["supermemory>=3,<4"] +# The live Mem0 push needs the provider SDK; the core install and the +# fake-driven test-suite stay dependency-free. +mem0 = ["mem0ai>=0.1"] +# The live Neo4j push needs the official driver; the core install and the +# fake-driven test-suite stay dependency-free. Pinned to the v5 major. +neo4j = ["neo4j>=5,<6"] +# The live Zep push needs the Zep Cloud SDK; the core install and the +# fake-driven test-suite stay dependency-free. Pinned to the verified v3 major. +zep = ["zep-cloud>=3,<4"] +# The live Letta push needs the Letta client; the core install and the +# fake-driven test-suite stay dependency-free. Pinned to the verified v1 major. +letta = ["letta-client>=1,<2"] +# The live Cognee push needs the cognee pipeline; the core install and the +# fake-driven test-suite stay dependency-free. Pinned to the verified v1 major. +cognee = ["cognee>=1,<2"] +dev = ["pytest>=7.0", "ruff", "mypy"] + +[project.scripts] +rac-connect = "rac_connectors.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools_scm] +# Version comes from git tags. CalVer: tag YYYY.M. (e.g. 2026.6.1) — +# PEP 440 strips a zero-padded month, so tag 2026.6.1, not 2026.06.1. Independent +# minor counter, not lock-step with rac-core (ADR-008). Untagged builds get a +# dev version. + +[tool.ruff] +line-length = 88 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.11" +packages = ["rac_connectors"] + +# Provider SDKs are optional extras, imported lazily; CI type-checks without +# them installed, so their missing stubs are not an error. +[[tool.mypy.overrides]] +module = [ + "supermemory.*", + "mem0.*", + "neo4j.*", + "zep_cloud.*", + "letta_client.*", + "cognee.*", +] +ignore_missing_imports = true diff --git a/rac/assets/images/lore-header-dark.png b/rac/assets/images/lore-header-dark.png new file mode 100644 index 0000000..036e9e8 Binary files /dev/null and b/rac/assets/images/lore-header-dark.png differ diff --git a/rac/assets/images/lore-header-light.png b/rac/assets/images/lore-header-light.png new file mode 100644 index 0000000..8791db9 Binary files /dev/null and b/rac/assets/images/lore-header-light.png differ diff --git a/rac/decisions/adr-001-python-stack.md b/rac/decisions/adr-001-python-stack.md new file mode 100644 index 0000000..03dd808 --- /dev/null +++ b/rac/decisions/adr-001-python-stack.md @@ -0,0 +1,98 @@ +--- +schema_version: 1 +id: LCON-KVKGQD318KM8 +type: decision +--- +# ADR-001: rac-connectors Is a Python Package + +## Context + +`rac-connectors` is the companion that pushes RAC's export payloads into the +external memory / RAG / graph backends a team already runs (ADR-073 in +rac-core). The export contract it consumes is language-agnostic JSON/JSONL +(`rac export --documents` / `--graph`), so the connector could be written in any +language. The first backend, Supermemory, ships both Python and TypeScript SDKs, +so the SDK does not force the choice either. + +The decision is which stack the reference connectors in this repo are built on. +The forces: + +- **Ecosystem match.** RAC (the engine) is a pure-Python CLI; its contributors, + tooling (`ruff`, `pytest`, `mypy`), and CI conventions are Python. A Python + companion is the lowest-friction repo for the same maintainers. +- **Dogfooding Lore.** A Python repo can carry its own `rac/` corpus and run the + `rac` CLI to validate its decisions (this ADR is the proof), so the companion + records its own knowledge the way the product intends. +- **Thin-client posture (ADR-063 in rac-core).** Whatever the language, a + connector is a thin consumer of the published contract — it adds no engine + logic — so the choice is about maintainer ergonomics, not capability. +- **Per-backend SDKs.** Supermemory, Mem0, Zep, Pinecone, and the rest all + publish Python SDKs; Python keeps every future backend module in one toolchain. + +## Decision + +`rac-connectors` is a **Python package** (`rac_connectors`, Python 3.11+), +matching the RAC ecosystem. + +- One installable package with a `rac-connect` console entrypoint; one module + per backend under it (ADR-073), Supermemory first. +- Tooling mirrors rac-core: `ruff` for lint, `pytest` for tests, `mypy` + available; Apache-2.0 licensed to match. +- The repo dogfoods Lore: its own decisions live in `rac/` and are validated by + the `rac` CLI. +- Provider SDKs are **optional extras** (e.g. `rac-connectors[supermemory]`) so + the core install and the whole test-suite stay dependency-free and CI never + needs a live backend. + +This does not bind non-Python clients: per ADR-063 the contract is the product, +so a TypeScript or Go connector remains valid against the same JSONL — it simply +lives elsewhere, not in this reference repo. + +## Consequences + +### Positive + +- Same maintainers, same toolchain, same CI conventions as rac-core. +- The repo can dogfood Lore for its own ADRs, validated deterministically. +- Optional-extra SDKs keep the core install and CI light and offline. +- Every future backend module shares one Python toolchain. + +### Negative / trade-offs + +- A team whose stack is entirely TypeScript gets a reference in a second + language. Accepted: the contract is language-agnostic (ADR-063), so a TS + connector is fully supported — it just is not the reference implementation + here. + +## Status + +Accepted + +## Category + +Technical + +## Alternatives Considered + +### TypeScript / Node + +Rejected as the reference stack: it diverges from the Python engine ecosystem +and would split maintainer tooling, with no offsetting capability gain since the +contract is language-agnostic. Supermemory's TS SDK does not justify it on its +own. A TypeScript connector against the same contract remains welcome (ADR-063); +it just is not this repo's baseline. + +### A polyglot repo (Python + TypeScript side by side) + +Rejected for the first phase: it doubles CI, lint, and release surface before a +second-language connector is even requested. Revisit only if real demand for a +TS reference appears. + +## Related Decisions + +- adr-002 + +## Review Date + +Revisit if a non-Python reference connector is requested often enough to justify +a second toolchain in this repo, or if rac-core's ecosystem stack changes. diff --git a/rac/decisions/adr-002-connector-interface.md b/rac/decisions/adr-002-connector-interface.md new file mode 100644 index 0000000..9df2157 --- /dev/null +++ b/rac/decisions/adr-002-connector-interface.md @@ -0,0 +1,115 @@ +--- +schema_version: 1 +id: LCON-KVKGQD9Y4W0D +type: decision +--- +# ADR-002: One Outbound `push` Seam, One Module per Backend + +## Context + +ADR-073 (rac-core) fixes the repo topology: **one** `rac-connectors` repo with +**one module per backend**, not a repo per provider. Supermemory is module one; +Mem0, Zep, Letta, Cognee, and the vector/graph stores are named future targets. +This ADR fixes the *code seam* those modules share, so a new backend slots in +without reworking the CLI or the record-parsing layer — while honouring the rule +(from the interplay design in rac-core) that a connector is **outbound only**: it +pushes, and never pulls, re-ranks, or routes. The re-rank / memory-router shape +was explicitly rejected there; the seam must not even tempt it. + +The forces: + +- **A shared shape, not premature generality.** Two layers are common to every + backend — parsing the `--documents` JSONL into records, and the CLI — and only + the upsert mapping is backend-specific. The seam should capture exactly that + split and nothing speculative, since only one backend exists today. +- **Outbound-only, idempotent.** Every backend must upsert on the canonical + `id` so re-running an export updates rather than duplicates, and none may + expose a read-back path. +- **Dry-run and testability are universal.** Every backend needs a `--dry-run` + that makes no network call and a fake-client test path (no live API in CI), so + those belong in the shared shape, not re-invented per module. + +## Decision + +Every backend module implements one small seam: + +```python +class Connector(Protocol): + name: str + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: ... +``` + +- **`push` is the only required method** — outbound, idempotent on each record's + canonical `id`, returning a deterministic `PushSummary` (pushed/skipped counts + plus a per-record action log). There is deliberately no `pull`, `search`, or + `rerank` on the seam; recall and verify-in-Lore are the reading agent's job. +- **Records are shared, parsed once.** A single `parse_documents` reader turns + the `rac export --documents` JSONL into `Record` objects for every backend; a + module never re-parses raw Markdown (ADR-073, and ADR-063 in rac-core). +- **`dry_run` is part of the seam**, not a per-module flag, so every backend + gets a no-network preview for free. +- **The backend SDK sits behind a thin client Protocol** the module depends on, + so the test-suite drives a fake and CI stays offline. Auth is read from the + environment by that client, never hard-coded. +- **The CLI is one subcommand per backend** (`rac-connect `), each + wiring stdin/`--input` and `--dry-run` to the module's `push`. + +The seam stays this small until a second backend proves a wider shape is needed; +the graph projection (`--graph`, ADR-074 in rac-core) will add a sibling reader +and, if required, a `push_graph` seam at that time — not now. + +## Consequences + +### Positive + +- A new backend is one module implementing `push` plus a thin client; record + parsing, the CLI, dry-run, and the summary shape are reused. +- The outbound-only, idempotent-on-`id` contract is enforced by the seam's + shape, so the rejected re-rank/router pattern cannot creep in accidentally. +- Offline, fake-client testing is the default for every backend. + +### Negative / trade-offs + +- The seam covers documents only; graph backends will need an additive sibling + seam later. Accepted: `--graph` is out of scope for the Supermemory-first + phase, and designing its seam now would be speculative. +- One `push` signature may feel thin for a backend with a richer API. Accepted: + modules keep their own specifics behind the client; the seam is the common + denominator, not the whole surface. + +## Status + +Accepted + +## Category + +Architecture + +## Alternatives Considered + +### A fat base class with batching, retry, and pull hooks + +Rejected as premature: with one backend it would be generality without evidence, +and a `pull`/read hook invites exactly the bidirectional coupling the interplay +design rejected. Add capability when a second backend demands it. + +### No shared seam — each module is bespoke + +Rejected: it would re-implement record parsing, dry-run, and CLI wiring per +backend and let each drift on the idempotency and outbound-only guarantees that +must hold uniformly. + +### One combined documents+graph connector now + +Rejected: documents and graph serve different consumers (memory vs graph) and +`--graph` is unscheduled here (ADR-074 in rac-core). Separate seams compose +better; Supermemory needs only documents. + +## Related Decisions + +- adr-001 + +## Review Date + +Revisit when the second backend module lands, or when the `--graph` projection +is scheduled and needs its own push seam. diff --git a/rac/decisions/adr-003-graph-connector-seam-neo4j.md b/rac/decisions/adr-003-graph-connector-seam-neo4j.md new file mode 100644 index 0000000..d6ec30e --- /dev/null +++ b/rac/decisions/adr-003-graph-connector-seam-neo4j.md @@ -0,0 +1,120 @@ +--- +schema_version: 1 +id: LCON-KVMK1G6R146P +type: decision +--- +# ADR-003: Graph Connectors Use a Node/Edge Push Seam, Neo4j First + +## Context + +ADR-002 fixed one outbound `push(records)` seam for the documents projection and +explicitly deferred graph: "documents-only for now, the graph seam deferred to +when `--graph` / ADR-074 is scheduled." That time has come — the `rac export +--graph` producer is live (rac-core ADR-074), emitting a single object of typed +nodes and edges with `directed` and `resolved` flags. + +A graph push is a different shape from a documents push: one whole graph, not a +stream of independent records. ADR-002 anticipated exactly this ("a sibling seam +later"). Two questions need locking before code: the seam shape, and which graph +backend is the first reference. The design `graph-connector-shape` works the +*how*; this ADR records the *decisions*. + +## Decision + +- **A sibling seam, not an overload.** Add `GraphConnector.push_graph(graph, *, + dry_run) -> PushSummary` alongside the documents `Connector`. The documents + seam, reader, and CLI are untouched (additive — rac-core ADR-007, ADR-063). + The CLI, `PushSummary`, and `--dry-run` are shared; only the input shape and + the seam differ. +- **Neo4j is the first graph backend.** The most widely-run graph database, with + an official Python driver and a Cypher `MERGE` model that makes node/edge + upserts idempotent on the canonical `id`. It is the portable reference later + graph backends (Zep Graphiti, Cognee, Microsoft GraphRAG) are measured + against. It is a module under `rac-connectors`, not a new repo (rac-core + ADR-073), with the driver behind a thin, mockable client and an optional + `[neo4j]` extra. +- **Idempotency is `MERGE` on `id`.** Nodes `MERGE (n:Artifact {id})`; edges + `MERGE (a)-[r:REL {type}]->(b)` after matching both endpoints by id. Re-push + updates in place and never duplicates. +- **Undirected edges are written once.** `directed: false` relationships are + stored as a single edge carrying `directed=false`; the connector does not + invent a reciprocal edge. +- **Unresolved edges are skipped.** `resolved: false` edges (literal, + unresolved target) are counted and dropped, never written — mirroring the + documents side's no-phantom-nodes rule. +- **Cypher is injection-safe.** All node and edge properties are query + parameters; only the fixed labels `Artifact` and `REL` are interpolated, so no + corpus content reaches Cypher as code. + +This does not reopen ADR-002; it extends the connector seam to the graph shape +ADR-002 deferred. + +## Consequences + +### Positive + +- The graph path slots into the existing companion with shared CLI, summary, and + dry-run; only a reader and a seam are added. +- Re-sync is safe by construction (`MERGE` on the canonical id). +- The loaded graph is a faithful image of Lore's validated relationship graph, + with provenance (`id`/`type`/`status`) on every node for the verify-in-Lore + loop. +- CI stays offline — the driver is mocked. + +### Negative / trade-offs + +- A single `:Artifact` label and `REL` relationship type is a deliberately small + schema; richer per-type labels are deferred until a query workload needs them. + Accepted: it can be added additively without breaking `MERGE` keys. +- A second seam (`push_graph`) sits beside `push`. Accepted: the inputs are + genuinely different shapes, so one signature would weaken typing for no gain. + +### Risks + +- Driver API churn. Mitigation: a thin client Protocol and a pinned major. +- Edge-direction or unresolved-reference handling drifting from the export. + Mitigation: both are recorded here and covered by tests. + +## Status + +Accepted + +## Category + +Architecture + +## Alternatives Considered + +### Overload the documents `push` for graphs + +Rejected: documents are an `Iterable[Record]` and a graph is one `Graph`; +overloading blurs two different inputs and weakens typing for no benefit. + +### A different first graph backend (Zep Graphiti, Cognee, Microsoft GraphRAG) + +Reasonable later targets, rejected as *first*: Neo4j is the most portable and +widely-run, and its `MERGE` model is the cleanest expression of the +idempotent-on-`id` contract. The others compose in later as additional modules. + +### Reciprocal edges for undirected relationships; placeholder nodes for unresolved edges + +Rejected: both invent graph structure the export did not assert, diverging from +Lore's validated graph and the no-phantom rule. + +## Related Decisions + +- adr-002 + +## Related Designs + +- graph-connector-shape + +## Related Roadmaps + +- graph-connector + +## Review Date + +Revisit when a second graph backend lands (testing the seam's generality), or +when a query workload asks for a richer node/edge schema than the base +`:Artifact` / `REL` shape. diff --git a/rac/decisions/adr-004-mem0-documents-connector.md b/rac/decisions/adr-004-mem0-documents-connector.md new file mode 100644 index 0000000..bf8e52b --- /dev/null +++ b/rac/decisions/adr-004-mem0-documents-connector.md @@ -0,0 +1,99 @@ +--- +schema_version: 1 +id: LCON-KVMMTWY928WN +type: decision +--- +# ADR-004: Mem0 Is a Documents Backend, Idempotent by Container Resync + +## Context + +Mem0 is the second documents-export backend (ADR-002: one module per backend, +one CLI subcommand). It is a memory layer that embeds server-side, so it fits the +existing `Connector.push(records)` seam — no new seam, unlike the graph path +(ADR-003). + +One thing does not carry over from Supermemory. Supermemory's `add` takes a +`custom_id`, so re-pushing an edited artifact is a per-record upsert keyed on the +canonical `id`. Mem0's `add` has **no per-record upsert key** (verified against +mem0ai 2.0.7: `MemoryClient.add(messages, *, user_id, agent_id, metadata, infer, +…)` — no `custom_id`). So the connector needs a different, explicit idempotency +strategy, or re-running would duplicate every memory. + +## Decision + +- **Mem0 is a documents backend on the existing `push(records)` seam.** No new + interface; it reuses the documents reader, CLI, `PushSummary`, and `--dry-run`. +- **Idempotency is a container resync, not a per-record upsert.** A corpus + `source` maps to a Mem0 partition (`user_id`). On a push, the first time a + partition is seen it is **cleared** (`delete_all(user_id=source)`), then every + record for it is added. Re-running yields exactly the corpus — no duplicates — + which satisfies the export contract's "containerTag as the upsert key". This is + the cleanest idempotency Mem0's API supports, since it lacks `custom_id`. +- **Records are stored as-is, not LLM-rewritten.** `add(..., infer=False)` skips + Mem0's fact-extraction so it only embeds the artifact text; the canonical + `rac_id`, `type`, `status`, and `title` ride in metadata for the + verify-in-Lore loop and retired-item filtering. No embeddings in the connector + (rac-core ADR-002, ADR-066). +- **Auth from the environment** (`MEM0_API_KEY`), the SDK behind a thin, mockable + client, an optional `[mem0]` extra — the same shape as every backend (ADR-073). + +## Consequences + +### Positive + +- A second documents backend with zero new seam — pure reuse of the documents + path. +- Re-sync is unambiguously idempotent and uses only well-supported Mem0 + operations (`delete_all` by partition, `add`). +- Provenance (`rac_id`/`status`) is preserved for the verify-in-Lore loop. + +### Negative / trade-offs + +- A resync clears the partition before re-adding, so a push is a wipe-and-rebuild + rather than a surgical per-record update: there is a brief window where the + partition is incomplete, and unchanged records are re-embedded. Accepted: Mem0 + exposes no per-record upsert key, the sync is a one-shot job, and the contract + permits container-level idempotency. If Mem0 adds a stable upsert key, this can + move to a surgical upsert without changing the seam. +- The corpus maps to a Mem0 `user_id` partition, which is a semantic stretch + (`user_id` names a partition, not a person). Accepted: `user_id` is Mem0's + primary, broadly-supported scope for add and delete. + +## Status + +Accepted + +## Category + +Architecture + +## Alternatives Considered + +### Per-record delete-then-add keyed on a `rac_id` metadata filter + +Rejected for now: it is more surgical, but depends on Mem0's metadata-filtered +delete semantics, which vary across platform and OSS and could not be verified +offline. The container resync relies only on partition-level `delete_all`, which +is unambiguous. Revisit if a verified metadata-filter delete makes per-record +upsert reliable. + +### Plain `add` and rely on Mem0's own dedup + +Rejected: dedup is heuristic and not guaranteed idempotent on the canonical `id`, +so re-runs could accumulate near-duplicate memories. + +### `infer=True` (let Mem0 extract memories) + +Rejected: it would let an LLM rewrite the artifact into extracted "memories", +making the stored copy a non-faithful, non-deterministic derivative — contrary to +shipping the artifact text for the backend to embed. + +## Related Decisions + +- adr-001 +- adr-002 + +## Review Date + +Revisit if Mem0 introduces a stable per-record upsert key (move to a surgical +upsert), or when a third documents backend tests the seam's generality. diff --git a/rac/decisions/adr-005-zep-documents-connector.md b/rac/decisions/adr-005-zep-documents-connector.md new file mode 100644 index 0000000..4fd29cb --- /dev/null +++ b/rac/decisions/adr-005-zep-documents-connector.md @@ -0,0 +1,96 @@ +--- +schema_version: 1 +id: LCON-KVMN69KS0WWS +type: decision +--- +# ADR-005: Zep Is a Documents Backend, Idempotent by Graph Resync + +## Context + +Zep is a documents-export backend (ADR-002: one module and one CLI subcommand +per backend). Zep Cloud ingests data as **episodes** into a per-namespace +knowledge **graph** that it derives and embeds server-side, so it fits the +existing `Connector.push(records)` seam — no new seam, unlike the graph-export +path (ADR-003). + +As with Mem0 (ADR-004), Zep's ingestion has **no per-record upsert key** +(verified against zep-cloud 3.23.0: `graph.add(*, data, type, graph_id, +metadata, …)` returns an `Episode` with no caller-supplied id). Re-running would +duplicate episodes. Unlike Mem0, Zep exposes explicit graph lifecycle methods — +`graph.create(graph_id=…)` and `graph.delete(graph_id)` — which give a clean way +to resync a namespace. + +## Decision + +- **Zep is a documents backend on the existing `push(records)` seam.** It reuses + the documents reader, CLI, `PushSummary`, and `--dry-run`. +- **Idempotency is a graph resync.** A corpus `source` maps to a Zep `graph_id`. + On a push, the first time a graph is seen it is cleared — `graph.delete(id)` + (tolerating absence on a first sync) then `graph.create(graph_id=id)` — and + every record for it is added as a `type="text"` episode. Re-running yields + exactly the corpus, no duplicates, satisfying the contract's "containerTag as + the upsert key". +- **Records are added as text episodes carrying provenance metadata.** The + canonical `rac_id`, `type`, `status`, and `title` ride in episode metadata for + the verify-in-Lore loop and retired-item filtering. Zep derives its graph and + embeds; no embeddings in the connector (rac-core ADR-002, ADR-066). +- **Auth from the environment** (`ZEP_API_KEY`), the SDK behind a thin, mockable + client, an optional `[zep]` extra — the standard backend shape (ADR-073). + +## Consequences + +### Positive + +- A third documents backend with no new seam — pure reuse of the documents path. +- Graph resync is unambiguously idempotent and uses Zep's first-class lifecycle + methods (`create` / `delete`), cleaner than a metadata-filtered prune. +- Provenance (`rac_id`/`status`) is preserved for the verify-in-Lore loop. + +### Negative / trade-offs + +- A resync deletes and recreates the whole graph before re-adding, so a push is a + wipe-and-rebuild: there is a window where the graph is incomplete, and Zep + re-derives the graph from scratch each sync. Accepted: Zep exposes no + per-record upsert key, the sync is a one-shot job, and the contract permits + container-level idempotency. +- Zep's stored form is an LLM-derived knowledge graph, not the verbatim artifact. + Accepted and by design: Zep's copy is an associative index; authoritative text + is always re-fetched from Lore (the verify-in-Lore loop). Mirrors how the + Supermemory copy is treated. + +## Status + +Accepted + +## Category + +Architecture + +## Alternatives Considered + +### Per-episode delete keyed on a `rac_id` metadata filter + +Rejected: Zep deletes episodes by uuid, which the connector does not persist, and +there is no documented metadata-filtered episode delete. Graph-level resync via +`create`/`delete` is the supported, unambiguous primitive. + +### Map a corpus to a Zep user/session instead of a graph + +Rejected: a graph namespace (`graph_id`) is the natural home for a body of +reference knowledge and has direct lifecycle methods; users/sessions model +conversational memory, which the corpus is not. + +### Add episodes without resync and rely on Zep dedup + +Rejected: Zep does not guarantee idempotency on the canonical `id`, so re-runs +would accrete duplicate episodes and a drifting derived graph. + +## Related Decisions + +- adr-001 +- adr-002 + +## Review Date + +Revisit if Zep introduces a per-record upsert key (move to a surgical upsert), or +when the documents-backend count makes a shared resync helper worthwhile. diff --git a/rac/decisions/adr-006-letta-documents-connector.md b/rac/decisions/adr-006-letta-documents-connector.md new file mode 100644 index 0000000..78c35e2 --- /dev/null +++ b/rac/decisions/adr-006-letta-documents-connector.md @@ -0,0 +1,97 @@ +--- +schema_version: 1 +id: LCON-KVMPF68SA4BW +type: decision +--- +# ADR-006: Letta Is a Documents Backend, Mapped to Archives, Resync-Idempotent + +## Context + +Letta is a documents-export backend (ADR-002: one module and one CLI subcommand +per backend). Letta organises long-term knowledge as **archives** — named +passage stores that agents attach as archival memory and that Letta embeds +server-side. That makes it a fit for the existing `Connector.push(records)` seam, +not a new seam (unlike the graph path, ADR-003). + +Two specifics shape the connector (verified against letta-client 1.12.1): + +- Ingestion is `client.archives.passages.create(archive_id, *, text, metadata)`, + addressed by an **opaque `archive_id`**, not by a caller-chosen name — so the + connector must resolve a name to an id. +- Like Mem0 (ADR-004) and Zep (ADR-005), there is **no per-record upsert key**, + so re-running would duplicate passages. + +## Decision + +- **Letta is a documents backend on the existing `push(records)` seam.** It + reuses the documents reader, CLI, `PushSummary`, and `--dry-run`. +- **A corpus `source` maps to a Letta archive (by name).** The thin client + resolves the name to an `archive_id` internally, so the connector pushes by + container name exactly like the other memory backends — the archive-id + bookkeeping never leaks into the seam. +- **Idempotency is an archive resync.** On a push, the first time a source is + seen its archive is cleared — list archives by that name, delete them, create a + fresh one — then every record is added as a passage carrying the canonical + `rac_id`/`type`/`status`/`title` in metadata. Re-running yields exactly the + corpus, no duplicates — the contract's "containerTag as the upsert key". +- **Letta embeds the passages; nothing is embedded here** (rac-core ADR-002, + ADR-066). +- **Auth from the environment**, supporting both Letta Cloud (`LETTA_API_KEY`) + and self-hosted (`LETTA_BASE_URL`); the SDK sits behind a thin, mockable + client, an optional `[letta]` extra (ADR-073). + +## Consequences + +### Positive + +- A fourth documents backend with no new seam; the archive-id indirection is + hidden in the adapter, so the connector and tests match the other backends. +- Archive resync is unambiguously idempotent and uses Letta's first-class archive + lifecycle (`list`/`create`/`delete`). +- Works against both Letta Cloud and a self-hosted server. + +### Negative / trade-offs + +- A resync deletes and recreates the archive before re-adding, so a push is a + wipe-and-rebuild: a brief incomplete window, and unchanged passages are + re-embedded. Accepted: Letta exposes no per-record upsert key, the sync is a + one-shot job, and the contract permits container-level idempotency. +- The connector creates an unattached archive; wiring it to specific agents is + left to the operator. Accepted: the connector's job is to keep the store fresh, + not to manage agent memory topology. + +## Status + +Accepted + +## Category + +Architecture + +## Alternatives Considered + +### Per-passage delete keyed on a `rac_id` metadata filter + +Rejected: Letta deletes passages by id (not persisted by the connector) and +exposes no metadata-filtered passage delete. Archive-level resync via +`list`/`create`/`delete` is the supported, unambiguous primitive. + +### Push into a specific agent's archival memory instead of a standalone archive + +Rejected: it couples the corpus to one agent. A named archive is agent-agnostic +and can be attached to any number of agents by the operator. + +### Add passages without resync and rely on Letta dedup + +Rejected: Letta does not guarantee idempotency on the canonical `id`, so re-runs +would accrete duplicate passages. + +## Related Decisions + +- adr-001 +- adr-002 + +## Review Date + +Revisit if Letta introduces a per-record upsert key (move to a surgical upsert), +or when the documents-backend count makes a shared resync helper worthwhile. diff --git a/rac/decisions/adr-007-cognee-documents-connector.md b/rac/decisions/adr-007-cognee-documents-connector.md new file mode 100644 index 0000000..4783371 --- /dev/null +++ b/rac/decisions/adr-007-cognee-documents-connector.md @@ -0,0 +1,109 @@ +--- +schema_version: 1 +id: LCON-KVMPXA7MQ5VR +type: decision +--- +# ADR-007: Cognee Is a Documents Backend — Two-Phase Pipeline, Content-Hash Idempotent + +## Context + +Cognee is a documents-export backend (ADR-002: one module and one CLI subcommand +per backend), but it is unlike the memory backends that came before it. Verified +against cognee 1.2.0, it is: + +- **A module-level async pipeline**, not a client with an API key: + `await cognee.add(data, dataset_name=…)` stages data, then + `await cognee.cognify(datasets=[…])` builds a knowledge graph and embeds it + locally. +- **Idempotent by content hash already** — `incremental_loading` (its default) + skips re-ingesting unchanged data, so a re-push is naturally a no-op without + any resync. +- **Without a per-record metadata filter** — Cognee derives a graph from content; + it does not store per-document metadata you can later filter on the way + Supermemory/Mem0/Zep/Letta do. + +These three properties mean the Mem0/Zep/Letta pattern (a thin add + a resync) +does not transfer directly. This ADR records how Cognee fits the seam anyway. + +## Decision + +- **Cognee is a documents backend on the `push(records)` seam**, but its client + is a **two-phase stage-then-commit**: `add(payload, container)` stages a + document into a dataset, and `commit()` runs `cognee.add(list) + cognee.cognify` + **once per dataset** — so the expensive graph build happens once, not per + record. The async pipeline is run from the sync connector via `asyncio.run` + inside the adapter; `cognee` is imported lazily so a dry run never loads it. +- **A corpus `source` maps to a Cognee dataset** (and a `node_set` tag). +- **Idempotency is Cognee's native content-hash dedup** (`incremental_loading`), + not a resync. Re-pushing unchanged records is a no-op; changed records are + reprocessed. +- **Provenance is carried as a header line in each document**, because Cognee has + no per-record metadata filter: every payload is prefixed with `Rac-Id`, type, + status, and title, keeping the verify-in-Lore handle recoverable from Cognee's + graph. +- **Cognee builds the graph and embeds; nothing is embedded here** (rac-core + ADR-002, ADR-066). Auth is `LLM_API_KEY` (Cognee needs an LLM to cognify); an + optional `[cognee]` extra (ADR-073). + +## Consequences + +### Positive + +- Cognee composes graph + vector recall over the corpus with idempotency it + provides natively — no destructive resync, and no per-record reprocessing of + unchanged content. +- The two-phase client and the provenance header are isolated in the Cognee + module; the CLI and `PushSummary` are unchanged. + +### Negative / trade-offs + +- **Deletions are not pruned.** Content-hash idempotency keeps the store free of + duplicates but does not remove artifacts deleted from the corpus, unlike the + wipe-and-rebuild backends. Accepted for this backend: Cognee's per-dataset + delete is not a clean primitive, and a global prune would wipe unrelated + datasets. Pruning deletions is a documented follow-up. +- **Provenance lives in the document text**, not in a metadata field, so it + slightly pollutes the content Cognee reasons over. Accepted: it is the only way + to keep the canonical `id` recoverable given Cognee's model, and it is a small, + structured header. +- **Heavier and async.** The pipeline is expensive and pulls a large dependency; + isolated behind the `[cognee]` extra and a lazy import. + +## Status + +Accepted + +## Category + +Architecture + +## Alternatives Considered + +### A resync like Mem0/Zep/Letta (prune then re-add) + +Rejected: Cognee's `prune` is global (it would wipe unrelated datasets), and it +exposes no clean per-dataset reset. Its native `incremental_loading` is the +intended idempotency mechanism, so the connector uses that instead. + +### Per-record `add` + `cognify` (no batching) + +Rejected: `cognify` is an expensive graph build; running it per record would be +needlessly slow. Staging all records and building once per dataset is the correct +shape. + +### Carry provenance in `node_set` tags instead of a text header + +Considered: `node_set` tags the dataset's nodes but is a poor fit for a unique +per-document `id` (it is a small set of shared tags). A header line keeps the +`id` attached to the specific document, so it was preferred; `node_set` still +carries the coarse `source` tag. + +## Related Decisions + +- adr-001 +- adr-002 + +## Review Date + +Revisit when Cognee exposes a clean per-dataset reset (to prune deletions) or a +per-record metadata filter (to move provenance out of the document text). diff --git a/rac/decisions/adr-008-calver-and-contract-version.md b/rac/decisions/adr-008-calver-and-contract-version.md new file mode 100644 index 0000000..120dec9 --- /dev/null +++ b/rac/decisions/adr-008-calver-and-contract-version.md @@ -0,0 +1,105 @@ +--- +schema_version: 1 +id: LCON-KVPTT2RNKWDV +type: decision +--- +# ADR-008: CalVer Versioning, and the Export Contract Is the Cross-Repo Dependency + +## Context + +RAC / Lore is adopting CalVer (`YYYY.M.`) across the org, so rac-core +releases as `2026.6.`. rac-connectors should align its scheme. But two +things must not be conflated: + +- **Version *scheme* vs version *number*.** Matching the CalVer scheme aids + ecosystem legibility. Matching the *number* in lock-step would be wrong: + rac-connectors and rac-core have independent release cadences (a connector + release does not correspond to a rac-core release), so any shared minor would + diverge the first time either ships alone. +- **The real cross-repo dependency.** rac-connectors does **not** depend on the + rac-core *package* — it never imports `rac`. Its only dependency on Lore is the + **export contract**: the `schema_version` carried by `rac export --documents` + and `--graph`. That contract is additive-within-a-major and stable (rac-core + ADR-007), and non-Python clients are thin consumers of it (rac-core ADR-063). + Pinning a rac-core version would over-constrain and contradict those decisions. + +## Decision + +- **Version with CalVer, independent cadence.** Tags are `YYYY.M.` + (setuptools-scm, tag-driven). The minor is an independent counter, **not** + lock-step with rac-core — the same scheme, not the same number. The minor + starts at `1` each period; there is no `.0`. +- **Tag without a zero-padded month.** PEP 440 strips leading zeros, so a + `2026.06.1` tag normalises to the package version `2026.6.1`. Tag `2026.6.1` + directly to avoid the mismatch. +- **Capture the dependency as the contract version, in code — never as a package + pin.** The connector declares `SUPPORTED_CONTRACT_VERSION` (currently `"1"`) + and checks each export's `schema_version` at read time + (`check_contract_version`): a matching major is silent; a different major emits + a `ContractVersionWarning` to stderr and still proceeds best-effort. The + package does **not** depend on `requirements-as-code` at any version; the + README documents a soft minimum rac release (the one that introduced the export + modes) as prose only. + +## Consequences + +### Positive + +- The org-wide CalVer scheme is honoured without falsely coupling two + independently-released repos. +- The genuine dependency — the contract major — is explicit, checked, and + testable, instead of being implied by a package version. +- A connector keeps working against any future rac release that still emits + `schema_version 1` (the additive-stability guarantee), and warns clearly if a + new major ever appears. + +### Negative / trade-offs + +- A contract-major bump (`schema_version "2"`) only warns, it does not hard-fail, + so a best-effort push still runs. Accepted: the contract is additive and the + verify-in-Lore loop re-fetches authoritative text, so a stale-but-parsed push + is safe; an operator who wants strictness can treat the warning as an error. +- CalVer carries no semver compatibility signal. Accepted: the compatibility + contract lives in `schema_version`, not the package number. + +## Status + +Accepted + +## Category + +Process + +## Alternatives Considered + +### Lock-step version numbers with rac-core + +Rejected: the repos release independently, so a shared minor is fictional the +moment either ships alone, and it implies a coupling that does not exist. + +### Pin `requirements-as-code` to a version in `pyproject` + +Rejected: the connector consumes the contract, not the package (rac-core ADR-007 +additive stability, ADR-063 thin clients). A pin would break against rac releases +that are in fact compatible. + +### Keep SemVer (`0.x` / `v0.0.1`) + +Rejected for alignment: the org standard is CalVer. SemVer's compatibility signal +is redundant here because compatibility is expressed by the contract version. + +### Hard-fail on an unknown contract major + +Rejected as the default: it would abort a push the contract's additive stability +makes safe to attempt; a warning preserves best-effort behaviour while staying +loud. + +## Related Decisions + +- adr-001 +- adr-002 + +## Review Date + +Revisit when the export contract introduces a `schema_version "2"`, or if the +org's CalVer convention changes. diff --git a/rac/designs/graph-connector-shape.md b/rac/designs/graph-connector-shape.md new file mode 100644 index 0000000..5a75373 --- /dev/null +++ b/rac/designs/graph-connector-shape.md @@ -0,0 +1,158 @@ +--- +schema_version: 1 +id: LCON-KVMK1G2259TS +type: design +--- +# Graph Connector Shape + +## Context + +The documents target shipped first: a flat JSONL stream pushed through the +`Connector.push(records)` seam into Supermemory. The `--graph` projection +(rac-core ADR-074) is a different shape — a single JSON object of typed **nodes +and edges**, not a stream of independent records — so it needs its own reader +and its own push seam. This design fixes that shape, and the Cypher mapping for +the first graph backend, Neo4j, before any code is written. It is the "short +design the graph projection carries when scheduled" that the rac-core export +design anticipated. + +## User Need + +- **A team running a graph database** wants Lore's real, validated relationship + graph loaded into it — typed edges with direction — so an agent can traverse + the actual decision graph rather than one inferred from prose, and still + verify any node back in Lore by its canonical `id`. +- **The connector author** needs the graph path to slot into the existing + companion without disturbing the documents path: shared CLI, shared summary, + shared dry-run, a new seam only where the shape genuinely differs. + +## Design + +### The graph reader + +`graph.py` parses the `--graph` object into frozen dataclasses mirroring the +contract: `GraphNode(id, type, status, title)`, `GraphEdge(source, target, +type, directed, resolved)`, and `Graph(source, nodes, edges, schema_version)`. +It applies the same malformed-input discipline as `records.py`: a structurally +invalid graph raises a guarded error; unknown additive fields are tolerated +(rac-core ADR-007). + +### The push seam + +A sibling protocol in `base.py`, leaving the documents `Connector` untouched: + +```python +class GraphConnector(Protocol): + name: str + def push_graph(self, graph: Graph, *, dry_run: bool = False) -> PushSummary: ... +``` + +It reuses the existing `PushSummary` (nodes and edges are counted as `pushed`; +skipped unresolved edges as `skipped`). Documents and graph stay separate seams +because their inputs differ; the CLI, summary, and dry-run are shared. + +### The Neo4j mapping + +Idempotency comes from Cypher `MERGE` keyed on the canonical `id`: + +- **Nodes:** `MERGE (n:Artifact {id: $id}) SET n.type=$type, n.status=$status, + n.title=$title, n.source=$source`. One label (`:Artifact`) plus a `type` + property keeps the schema simple and the `MERGE` key single. +- **Edges:** `MATCH (a:Artifact {id:$source}), (b:Artifact {id:$target}) + MERGE (a)-[r:REL {type:$type}]->(b) SET r.directed=$directed`. The Cypher + relationship type is a fixed label `REL` with the real kind carried in a + `type` property — because a relationship type cannot be a query parameter, and + carrying it as a property keeps every edge parameterised and injection-safe. + +A re-push of an edited corpus updates node properties in place and creates no +duplicate nodes or relationships, because both `MERGE`s are keyed on stable +identity. + +### Direction and unresolved references + +- **Undirected edges** (`directed: false`, e.g. `related_*`) are written as a + **single** relationship carrying `directed=false`; the connector does not + invent a reciprocal edge. The graph stays a faithful image of the export, and + a consumer that wants symmetric traversal reads the property. +- **Unresolved edges** (`resolved: false`, whose `target` is literal reference + text, not a canonical id) are **skipped** and counted, never written. This + mirrors the documents side's no-phantom-nodes rule: the connector will not + `MERGE` a target node that Lore could not resolve. + +### Auth and CLI + +Auth is read from the environment (`NEO4J_URI`, `NEO4J_USERNAME`, +`NEO4J_PASSWORD`), never hard-coded. The CLI adds one subcommand: +`rac export rac/ --graph | rac-connect neo4j`, with the shared `--dry-run`, +`--input`, and `--strict` flags. + +## Constraints + +- **Additive only.** The documents `Connector`, reader, and CLI are unchanged; + the graph path is new modules and a new subcommand (rac-core ADR-007, ADR-063). +- **Outbound only.** `push_graph` writes nodes and edges and never reads back, + queries, or analyses — the verify-in-Lore loop stays the agent's job, and no + embeddings or analytics run in the connector (rac-core ADR-002, ADR-066). +- **Injection-safe.** Node and edge properties are always query parameters; + the only interpolated identifiers are the fixed labels `Artifact`/`REL`, so no + corpus content reaches Cypher as code. +- **Offline-testable.** The Neo4j driver sits behind a thin client Protocol so + CI drives a fake and never connects to a database. + +## Rationale + +- A **separate seam** (not an overloaded `push`) keeps each input type honest: + documents are an `Iterable[Record]`, a graph is one `Graph`. Sharing the CLI + and summary captures the real commonality without forcing one signature over + two different shapes. +- **`MERGE` on `id`** is the simplest idempotency that exists in Cypher and maps + exactly to the canonical-id contract, so re-sync is safe by construction. +- A **single `:Artifact` label and `REL` type** keep the loaded schema small and + every value parameterised; richer per-type labels can be added additively + later without breaking the `MERGE` keys. + +## Alternatives + +- **Overload `push` for both shapes** — rejected: it blurs two genuinely + different inputs and weakens typing for no gain. +- **Reciprocal edges for undirected relationships** — rejected as the default: + it double-counts and diverges from the export; a consumer can derive symmetry + from the `directed` property. +- **Placeholder nodes for unresolved edges** — rejected: it invents graph + structure Lore deliberately did not resolve, contradicting the no-phantom rule. +- **Per-type node labels and per-kind relationship types** — deferred, not + rejected: it is a richer schema that can land additively once a real query + workload asks for it. + +## Accessibility + +A machine contract, but the provenance concern carries over: every node keeps +the canonical `id`, `type`, and `status`, so an operator auditing what an agent +traversed can always trace a graph node back to the authoritative, current Lore +artifact rather than to the backend's copy. + +## Style Guidance + +- Dataclass field names mirror the `--graph` contract exactly + (`source`/`target`/`type`/`directed`/`resolved`), lowercase snake_case. +- Cypher keeps fixed labels (`Artifact`, `REL`) and parameterises everything + else; relationship kinds live in a `type` property. +- The graph subcommand mirrors the documents one: `--dry-run`, `--input`, + `--strict`. + +## Open Questions + +- Whether to add per-type node labels (`:Decision`, `:Design`, …) once a query + workload needs them, alongside the base `:Artifact` label. +- Whether multi-corpus graphs should namespace by `source` with a label or only + a property, when more than one corpus is loaded into one database. +- The `--strict` policy for a malformed graph object versus a single bad edge. + +## Related Decisions + +- adr-003 +- adr-002 + +## Related Roadmaps + +- graph-connector diff --git a/rac/roadmaps/future/graph-connector.md b/rac/roadmaps/future/graph-connector.md new file mode 100644 index 0000000..be97e42 --- /dev/null +++ b/rac/roadmaps/future/graph-connector.md @@ -0,0 +1,71 @@ +--- +schema_version: 1 +id: LCON-KVMK1FV4M38X +type: roadmap +--- +# Graph Connector Track + +## Outcomes + +Lore's corpus is not only a set of documents — it is a typed relationship graph +(decisions supersede decisions, designs relate to requirements, and so on). The +`rac export --graph` projection already emits that graph as typed nodes and +edges (rac-core ADR-074). The outcome this track delivers is the **consumer** +side: an agent (and its operator) can load Lore's *real, validated* decision +graph into a graph backend they already run, instead of one an LLM infers from +prose — and then still verify any node back in Lore by its canonical `id`. + +This matters now because the documents target (Supermemory) has shipped, the +`--graph` producer is live, and graph/GraphRAG backends are a distinct, +high-value recall surface that the documents projection cannot serve. + +## Initiatives + +- **A graph reader and push seam.** Parse the `--graph` JSON into typed nodes + and edges, and add a sibling outbound seam (`push_graph`) alongside the + documents `push`, so graph backends slot into the same companion (rac-core + ADR-073) without disturbing the documents path. +- **A first graph connector: Neo4j.** The most widely-run graph database, with a + Cypher `MERGE` model that makes node/edge upserts idempotent on the canonical + `id`. It is the portable reference the later graph backends (Zep Graphiti, + Cognee, Microsoft GraphRAG) are measured against. +- **Dogfood the decision as RAC artifacts.** A design for the *how* and an ADR + for the locked choices, validated by the corpus gates in this repo. + +## Success Measures + +- `rac export rac/ --graph | rac-connect neo4j` upserts every node and edge, + and re-running is idempotent (no duplicate nodes or relationships). +- `--dry-run` reports the planned nodes/edges with no database connection. +- The connector is covered by offline tests against a fake driver — no live + Neo4j in CI — including idempotent re-push and unresolved-edge handling. +- The graph reader and seam are additive: the documents path and its contract + are unchanged. + +## Assumptions + +- The `--graph` contract (schema_version 1, typed edges with `directed` / + `resolved` flags) stays additive and stable (rac-core ADR-007). +- Edge `type` values come from rac's closed relationship registry, so they can + be sanitised to safe Cypher relationship types from a known set. +- Embedding, similarity, and any graph analytics live in the backend, never in + the connector (rac-core ADR-002, ADR-066). + +## Risks + +- **Backend API churn.** A young driver API could drift; mitigated by hiding the + driver behind a thin, mockable client and pinning the verified major. +- **Edge-direction modelling.** Undirected relationships and unresolved + references need explicit, recorded handling or the loaded graph misrepresents + the corpus; settled in the design and ADR rather than left implicit. +- **Scope creep into analytics.** The connector must stay an outbound upsert and + resist becoming a query/recall surface — the verify-in-Lore loop stays the + agent's job. + +## Related Decisions + +- adr-003 + +## Related Designs + +- graph-connector-shape diff --git a/scripts/sync_readme.py b/scripts/sync_readme.py new file mode 100644 index 0000000..21695c0 --- /dev/null +++ b/scripts/sync_readme.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Stitch the per-connector pages into the README's Connectors region. + +Each connector documents itself once in ``docs/connectors/.md``. This +script is the single place that assembles those pages into collapsible +``
`` sections inside the README, between the markers:: + + + ... generated, do not edit by hand ... + + +So a reader gets every connector on one page (the README) while each connector +still owns its own file (no cross-PR README conflicts). Run it after editing a +page:: + + python scripts/sync_readme.py # rewrite the README region + python scripts/sync_readme.py --check # CI: fail if the README is stale + +Each page starts with an HTML-comment metadata block (hidden when rendered):: + + + # Supermemory + ... body ... +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +# Markdown link targets: ](target) — used to rewrite page-relative links so they +# still resolve once the body is inlined into the README at the repo root. +_LINK_RE = re.compile(r"\]\(([^)]+)\)") + +ROOT = Path(__file__).resolve().parent.parent +README = ROOT / "README.md" +PAGES_DIR = ROOT / "docs" / "connectors" + +START = "" +END = "" +_NOTE = ( + "" +) + + +@dataclass +class Page: + name: str + tagline: str + order: int + body: str + path: Path + + +def _rewrite_links(body: str, page_dir: Path) -> str: + """Re-express page-relative markdown links relative to the repo root. + + A page at ``docs/connectors/x.md`` links relative to itself; once inlined + into the README at the root those targets would break, so resolve each + relative link against the page directory and rewrite it root-relative. + External (``http``), anchor (``#``) and absolute (``/``) links are left as is. + """ + + def repl(match: re.Match[str]) -> str: + raw = match.group(1).strip() + url, sep_space, title = raw.partition(" ") + if not url or url.startswith(("http://", "https://", "mailto:", "#", "/")): + return match.group(0) + path, sep_hash, anchor = url.partition("#") + if not path: + return match.group(0) + try: + rel = (page_dir / path).resolve().relative_to(ROOT).as_posix() + except ValueError: + return match.group(0) # points outside the repo — leave untouched + return f"]({rel}{sep_hash}{anchor}{sep_space}{title})" + + return _LINK_RE.sub(repl, body) + + +def _parse_meta(block: str) -> dict[str, str]: + meta: dict[str, str] = {} + for line in block.splitlines(): + if ":" in line: + key, _, value = line.partition(":") + meta[key.strip()] = value.strip() + return meta + + +def load_pages() -> list[Page]: + pages: list[Page] = [] + for path in sorted(PAGES_DIR.glob("*.md")): + if path.name.lower() in {"readme.md", "index.md"}: + continue + text = path.read_text(encoding="utf-8") + if not text.startswith(" metadata block") + _, _, rest = text.partition("") + meta = _parse_meta(meta_block) + body = body.lstrip("\n") + # Drop the leading H1 — the already names the connector. + lines = body.splitlines() + if lines and lines[0].startswith("# "): + lines = lines[1:] + body = "\n".join(lines).strip() + try: + name, tagline = meta["name"], meta["tagline"] + except KeyError as exc: + raise SystemExit(f"{path}: metadata missing {exc}") from None + pages.append( + Page( + name=name, + tagline=tagline, + order=int(meta.get("order", "999")), + body=body, + path=path, + ) + ) + pages.sort(key=lambda p: (p.order, p.name.lower())) + return pages + + +def render(pages: list[Page]) -> str: + blocks = [START, _NOTE, ""] + for page in pages: + rel = page.path.relative_to(ROOT).as_posix() + body = _rewrite_links(page.body, page.path.parent) + blocks += [ + "
", + f"{page.name} — {page.tagline}", + "", + body, + "", + f"**Full page:** [`{rel}`]({rel})", + "", + "
", + "", + ] + blocks.append(END) + return "\n".join(blocks) + + +def splice(readme: str, region: str) -> str: + if START not in readme or END not in readme: + raise SystemExit( + f"README is missing the {START} / {END} markers; add them first." + ) + head = readme.split(START)[0] + tail = readme.split(END, 1)[1] + return f"{head}{region}{tail}" + + +def main(argv: list[str]) -> int: + check = "--check" in argv + pages = load_pages() + readme = README.read_text(encoding="utf-8") + updated = splice(readme, render(pages)) + if check: + if updated != readme: + print( + "README connectors region is stale; " + "run `python scripts/sync_readme.py`.", + file=sys.stderr, + ) + return 1 + print(f"README connectors region is in sync ({len(pages)} connector(s)).") + return 0 + README.write_text(updated, encoding="utf-8") + print(f"Synced {len(pages)} connector(s) into the README.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/rac_connectors/__init__.py b/src/rac_connectors/__init__.py new file mode 100644 index 0000000..ce65cd6 --- /dev/null +++ b/src/rac_connectors/__init__.py @@ -0,0 +1,44 @@ +"""rac-connectors — outbound connectors that push Lore's export into backends. + +Companion to Lore (the product) / RAC (the engine). RAC serves a team's product +knowledge read-only over MCP; this package consumes RAC's stable export contract +(``rac export --documents`` / ``--graph``) and pushes it into the external +memory / RAG / graph backends a team already runs, so an agent can recall fuzzily +there and then verify in Lore. + +One repo, one module per backend (ADR-073). Supermemory is module one. +""" + +from __future__ import annotations + +from .base import Connector, GraphConnector, PushSummary +from .contract import ( + SUPPORTED_CONTRACT_VERSION, + ContractVersionWarning, + check_contract_version, +) +from .graph import ( + Graph, + GraphEdge, + GraphNode, + MalformedGraphError, + parse_graph, +) +from .records import MalformedRecordError, Record, parse_documents + +__all__ = [ + "SUPPORTED_CONTRACT_VERSION", + "Connector", + "ContractVersionWarning", + "Graph", + "GraphConnector", + "GraphEdge", + "GraphNode", + "MalformedGraphError", + "MalformedRecordError", + "PushSummary", + "Record", + "check_contract_version", + "parse_documents", + "parse_graph", +] diff --git a/src/rac_connectors/base.py b/src/rac_connectors/base.py new file mode 100644 index 0000000..ccb90c1 --- /dev/null +++ b/src/rac_connectors/base.py @@ -0,0 +1,93 @@ +"""The shared connector seam every backend module implements. + +One repo, one module per backend (ADR-073). Supermemory is module one; this is +the small shape the next backend (Mem0, Zep, a vector store, a graph backend) +slots into without reworking the CLI. The seam is deliberately minimal — push a +stream of records, optionally as a dry run, get back a deterministic summary — +so it does not over-generalise before a second backend exists. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field +from typing import Protocol, runtime_checkable + +from .graph import Graph +from .records import Record + + +@dataclass +class PushSummary: + """The outcome of a push, the same shape for every backend. + + ``actions`` records one line per item describing what was (or, under a dry + run, would be) sent — keyed by canonical ``id`` so a re-push is legible as + the idempotent update it is. The same summary serves the documents push (one + record per line) and the graph push (nodes and edges). + """ + + backend: str + pushed: int = 0 + skipped: int = 0 + dry_run: bool = False + actions: list[str] = field(default_factory=list) + + def record_push(self, item_id: str, detail: str) -> None: + self.pushed += 1 + self.actions.append(f"push {item_id}: {detail}") + + def record_skip_item(self, label: str, reason: str) -> None: + self.skipped += 1 + self.actions.append(f"skip {label}: {reason}") + + def record_skip(self, line_number: int, reason: str) -> None: + self.record_skip_item(f"line {line_number}", reason) + + def summary_line(self) -> str: + mode = "dry-run" if self.dry_run else "push" + return f"{self.backend} {mode}: {self.pushed} pushed, {self.skipped} skipped" + + +@runtime_checkable +class Connector(Protocol): + """Outbound-only sink for export records. + + Connectors push to the backend and never read back, re-rank, or route (the + re-rank / memory-router approach was explicitly rejected — see the interplay + design in rac-core). ``push`` must be idempotent on each record's canonical + ``id`` so re-running an export updates rather than duplicates. + """ + + name: str + + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: + """Upsert ``records`` into the backend, returning a :class:`PushSummary`. + + With ``dry_run=True`` the connector must describe what it would send + without making any network call. + """ + ... + + +@runtime_checkable +class GraphConnector(Protocol): + """Outbound-only sink for the ``--graph`` projection (typed nodes + edges). + + The sibling of :class:`Connector` for graph backends. The input shape differs + — one whole :class:`~rac_connectors.graph.Graph`, not a stream of records — + so it is a separate seam (ADR-003), but it shares the CLI, the + :class:`PushSummary`, and the dry-run contract. ``push_graph`` must be + idempotent on each node's and edge's canonical identity so re-running an + export updates rather than duplicates. + """ + + name: str + + def push_graph(self, graph: Graph, *, dry_run: bool = False) -> PushSummary: + """Upsert a graph's nodes and edges, returning a :class:`PushSummary`. + + With ``dry_run=True`` the connector must describe what it would write + without connecting to the backend. + """ + ... diff --git a/src/rac_connectors/cli.py b/src/rac_connectors/cli.py new file mode 100644 index 0000000..d923393 --- /dev/null +++ b/src/rac_connectors/cli.py @@ -0,0 +1,451 @@ +"""``rac-connect`` — the CLI entrypoint for the rac-connectors companion. + +One subcommand per backend. The documents backends read a ``rac export +--documents`` JSON Lines stream; the graph backends read a ``rac export --graph`` +object:: + + rac export rac/ --documents | rac-connect supermemory + rac export rac/ --documents | rac-connect mem0 --dry-run + rac export rac/ --graph | rac-connect neo4j +""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Iterable, Iterator +from typing import TextIO + +from .base import Connector, PushSummary +from .cognee import CogneeConnector +from .cognee.client import MissingCredentialsError as CogneeMissingCredentialsError +from .cognee.client import client_from_env as cognee_client_from_env +from .graph import MalformedGraphError, parse_graph +from .letta import LettaConnector +from .letta.client import MissingCredentialsError as LettaMissingCredentialsError +from .letta.client import client_from_env as letta_client_from_env +from .mem0 import Mem0Connector +from .mem0.client import MissingApiKeyError as Mem0MissingApiKeyError +from .mem0.client import client_from_env as mem0_client_from_env +from .neo4j import Neo4jConnector +from .neo4j.client import MissingCredentialsError +from .neo4j.client import client_from_env as neo4j_client_from_env +from .records import MalformedRecordError, Record, parse_documents +from .supermemory import SupermemoryConnector +from .supermemory.client import MissingApiKeyError, client_from_env +from .zep import ZepConnector +from .zep.client import MissingApiKeyError as ZepMissingApiKeyError +from .zep.client import client_from_env as zep_client_from_env + + +def _open_stream(path: str | None) -> tuple[TextIO, bool]: + """Return the input stream and whether the caller owns closing it.""" + if path is None or path == "-": + return sys.stdin, False + return open(path, encoding="utf-8"), True + + +def _records_with_skip_report( + lines: Iterable[str], summary: PushSummary, *, strict: bool +) -> Iterator[Record]: + """Parse lines, routing malformed ones to the summary (or raising if strict). + + Wrapping ``parse_documents`` line-by-line lets a skipped malformed line be + reported in the summary instead of silently dropped, while ``--strict`` turns + the same guard into a hard failure. + """ + for index, raw in enumerate(lines, start=1): + if not raw.strip(): + continue + try: + record = next(parse_documents([raw], strict=True)) + except MalformedRecordError as exc: + if strict: + raise MalformedRecordError(index, exc.reason, raw) from None + summary.record_skip(index, exc.reason) + continue + yield record + + +def _run_supermemory(args: argparse.Namespace) -> int: + connector = SupermemoryConnector() + stream, owned = _open_stream(args.input) + try: + if args.dry_run: + # Stream straight through the connector; it never calls the API. + summary = _push_with_skips( + connector, stream, dry_run=True, strict=args.strict + ) + else: + try: + connector = SupermemoryConnector(client_from_env()) + except MissingApiKeyError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + summary = _push_with_skips( + connector, stream, dry_run=False, strict=args.strict + ) + except MalformedRecordError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + if owned: + stream.close() + + if args.dry_run or args.verbose: + for action in summary.actions: + print(action) + print(summary.summary_line(), file=sys.stderr) + return 0 + + +def _run_mem0(args: argparse.Namespace) -> int: + connector: Connector = Mem0Connector() + stream, owned = _open_stream(args.input) + try: + if not args.dry_run: + try: + connector = Mem0Connector(mem0_client_from_env()) + except Mem0MissingApiKeyError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + try: + summary = _push_with_skips( + connector, stream, dry_run=args.dry_run, strict=args.strict + ) + except MalformedRecordError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + if owned: + stream.close() + + if args.dry_run or args.verbose: + for action in summary.actions: + print(action) + print(summary.summary_line(), file=sys.stderr) + return 0 + + +def _run_zep(args: argparse.Namespace) -> int: + connector: Connector = ZepConnector() + stream, owned = _open_stream(args.input) + try: + if not args.dry_run: + try: + connector = ZepConnector(zep_client_from_env()) + except ZepMissingApiKeyError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + try: + summary = _push_with_skips( + connector, stream, dry_run=args.dry_run, strict=args.strict + ) + except MalformedRecordError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + if owned: + stream.close() + + if args.dry_run or args.verbose: + for action in summary.actions: + print(action) + print(summary.summary_line(), file=sys.stderr) + return 0 + + +def _run_letta(args: argparse.Namespace) -> int: + connector: Connector = LettaConnector() + stream, owned = _open_stream(args.input) + try: + if not args.dry_run: + try: + connector = LettaConnector(letta_client_from_env()) + except LettaMissingCredentialsError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + try: + summary = _push_with_skips( + connector, stream, dry_run=args.dry_run, strict=args.strict + ) + except MalformedRecordError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + if owned: + stream.close() + + if args.dry_run or args.verbose: + for action in summary.actions: + print(action) + print(summary.summary_line(), file=sys.stderr) + return 0 + + +def _run_cognee(args: argparse.Namespace) -> int: + connector: Connector = CogneeConnector() + stream, owned = _open_stream(args.input) + try: + if not args.dry_run: + try: + connector = CogneeConnector(cognee_client_from_env()) + except CogneeMissingCredentialsError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + try: + summary = _push_with_skips( + connector, stream, dry_run=args.dry_run, strict=args.strict + ) + except MalformedRecordError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + finally: + if owned: + stream.close() + + if args.dry_run or args.verbose: + for action in summary.actions: + print(action) + print(summary.summary_line(), file=sys.stderr) + return 0 + + +def _push_with_skips( + connector: Connector, + stream: Iterable[str], + *, + dry_run: bool, + strict: bool, +) -> PushSummary: + """Push a stream, accumulating malformed-line skips into the one summary.""" + summary = PushSummary(backend=connector.name, dry_run=dry_run) + records = _records_with_skip_report(stream, summary, strict=strict) + pushed = connector.push(records, dry_run=dry_run) + # Merge the connector's push results onto the summary that holds the skips. + summary.pushed = pushed.pushed + summary.actions = pushed.actions + summary.actions + return summary + + +def _run_neo4j(args: argparse.Namespace) -> int: + stream, owned = _open_stream(args.input) + try: + payload = stream.read() + finally: + if owned: + stream.close() + + try: + graph = parse_graph(payload) + except MalformedGraphError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + if args.dry_run: + connector = Neo4jConnector() + else: + try: + connector = Neo4jConnector(neo4j_client_from_env()) + except MissingCredentialsError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + summary = connector.push_graph(graph, dry_run=args.dry_run) + + if args.dry_run or args.verbose: + for action in summary.actions: + print(action) + print(summary.summary_line(), file=sys.stderr) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="rac-connect", + description=( + "Push a 'rac export --documents' stream into an external memory / " + "RAG / graph backend. Outbound only — the verify-in-Lore loop is " + "the reading agent's job." + ), + ) + sub = parser.add_subparsers(dest="backend", required=True) + + sm = sub.add_parser( + "supermemory", + help="Upsert documents into Supermemory (idempotent on canonical id).", + ) + sm.add_argument( + "--input", + "-i", + default=None, + help="JSONL file to read (default: stdin). '-' also means stdin.", + ) + sm.add_argument( + "--dry-run", + action="store_true", + help="Print what would be sent without calling the API.", + ) + sm.add_argument( + "--strict", + action="store_true", + help="Fail on a malformed line instead of skipping it.", + ) + sm.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print per-record actions on a live push too.", + ) + sm.set_defaults(func=_run_supermemory) + + mem = sub.add_parser( + "mem0", + help="Upsert documents into Mem0 (idempotent by container resync).", + ) + mem.add_argument( + "--input", + "-i", + default=None, + help="JSONL file to read (default: stdin). '-' also means stdin.", + ) + mem.add_argument( + "--dry-run", + action="store_true", + help="Print what would be sent without calling the API.", + ) + mem.add_argument( + "--strict", + action="store_true", + help="Fail on a malformed line instead of skipping it.", + ) + mem.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print per-record actions on a live push too.", + ) + mem.set_defaults(func=_run_mem0) + + zep = sub.add_parser( + "zep", + help="Upsert documents into Zep (idempotent by graph resync).", + ) + zep.add_argument( + "--input", + "-i", + default=None, + help="JSONL file to read (default: stdin). '-' also means stdin.", + ) + zep.add_argument( + "--dry-run", + action="store_true", + help="Print what would be sent without calling the API.", + ) + zep.add_argument( + "--strict", + action="store_true", + help="Fail on a malformed line instead of skipping it.", + ) + zep.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print per-record actions on a live push too.", + ) + zep.set_defaults(func=_run_zep) + + letta = sub.add_parser( + "letta", + help="Upsert documents into Letta (idempotent by archive resync).", + ) + letta.add_argument( + "--input", + "-i", + default=None, + help="JSONL file to read (default: stdin). '-' also means stdin.", + ) + letta.add_argument( + "--dry-run", + action="store_true", + help="Print what would be sent without calling the API.", + ) + letta.add_argument( + "--strict", + action="store_true", + help="Fail on a malformed line instead of skipping it.", + ) + letta.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print per-record actions on a live push too.", + ) + letta.set_defaults(func=_run_letta) + + cog = sub.add_parser( + "cognee", + help="Build documents into a Cognee knowledge graph (content-hash idempotent).", + ) + cog.add_argument( + "--input", + "-i", + default=None, + help="JSONL file to read (default: stdin). '-' also means stdin.", + ) + cog.add_argument( + "--dry-run", + action="store_true", + help="Print what would be sent without running the pipeline.", + ) + cog.add_argument( + "--strict", + action="store_true", + help="Fail on a malformed line instead of skipping it.", + ) + cog.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print per-record actions on a live push too.", + ) + cog.set_defaults(func=_run_cognee) + + neo = sub.add_parser( + "neo4j", + help="Upsert the --graph projection into Neo4j (idempotent via MERGE).", + ) + neo.add_argument( + "--input", + "-i", + default=None, + help="--graph JSON file to read (default: stdin). '-' also means stdin.", + ) + neo.add_argument( + "--dry-run", + action="store_true", + help="Print the nodes/edges that would be written without connecting.", + ) + neo.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print per-node/edge actions on a live push too.", + ) + neo.set_defaults(func=_run_neo4j) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.func(args) + except BrokenPipeError: + # A downstream consumer (e.g. `| head`) closed the pipe early; exit + # quietly rather than tracebacking on the next write. + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/rac_connectors/cognee/__init__.py b/src/rac_connectors/cognee/__init__.py new file mode 100644 index 0000000..274762d --- /dev/null +++ b/src/rac_connectors/cognee/__init__.py @@ -0,0 +1,23 @@ +"""Cognee backend module for rac-connectors.""" + +from __future__ import annotations + +from .client import ( + CogneeClient, + MissingCredentialsError, + SdkCogneeClient, + client_from_env, + provenance_payload, +) +from .connector import BACKEND, DEFAULT_CONTAINER, CogneeConnector + +__all__ = [ + "BACKEND", + "DEFAULT_CONTAINER", + "CogneeClient", + "CogneeConnector", + "MissingCredentialsError", + "SdkCogneeClient", + "client_from_env", + "provenance_payload", +] diff --git a/src/rac_connectors/cognee/client.py b/src/rac_connectors/cognee/client.py new file mode 100644 index 0000000..122a2c3 --- /dev/null +++ b/src/rac_connectors/cognee/client.py @@ -0,0 +1,99 @@ +"""The thin client seam the Cognee connector pushes through. + +Cognee is unlike the other documents backends: a module-level **async** pipeline +(``await cognee.add(...)`` then ``await cognee.cognify(...)``) that builds a +knowledge graph and embeds locally, with native content-hash idempotency +(``incremental_loading``). The connector depends only on :class:`CogneeClient` +(a Protocol), so the test-suite drives an in-memory fake and CI never runs the +pipeline. :class:`SdkCogneeClient` is the real adapter; it buffers staged +payloads and flushes them — one ``add`` + one ``cognify`` per dataset — on commit. +""" + +from __future__ import annotations + +import asyncio +import os +from typing import Protocol, runtime_checkable + +# Cognee needs an LLM to cognify; this is its primary credential. +LLM_API_KEY_ENV = "LLM_API_KEY" + + +class MissingCredentialsError(RuntimeError): + """No Cognee LLM credentials were found in the environment.""" + + def __init__(self) -> None: + super().__init__( + f"set {LLM_API_KEY_ENV} in the environment so Cognee can cognify " + "(never hard-code the key)" + ) + + +@runtime_checkable +class CogneeClient(Protocol): + """What the connector needs from Cognee: stage a payload, then commit. + + ``add`` stages one prepared document into a dataset; ``commit`` runs the + two-phase ``add`` + ``cognify`` pipeline once per dataset. Idempotency is + Cognee's own content-hash dedup (``incremental_loading``) — see ADR-007. + """ + + def add(self, *, payload: str, container: str) -> None: ... + + def commit(self) -> None: ... + + +class SdkCogneeClient: + """Adapter over the real ``cognee`` package. + + Buffers staged payloads per dataset and, on :meth:`commit`, runs + ``cognee.add(list, dataset_name=…)`` then ``cognee.cognify(datasets=[…])`` + for each — so the expensive graph build happens once per dataset, not per + record. ``cognee`` is imported lazily inside :meth:`commit` so importing this + module never pulls the (heavy) pipeline. + """ + + def __init__(self) -> None: + if not os.environ.get(LLM_API_KEY_ENV): + raise MissingCredentialsError() + self._pending: dict[str, list[str]] = {} + + def add(self, *, payload: str, container: str) -> None: + self._pending.setdefault(container, []).append(payload) + + def commit(self) -> None: + if self._pending: + asyncio.run(self._ingest_all()) + self._pending.clear() + + async def _ingest_all(self) -> None: + try: + import cognee + except ImportError as exc: # pragma: no cover - exercised via message + raise RuntimeError( + "the 'cognee' package is not installed; " + "install the connector's 'cognee' extra" + ) from exc + for container, payloads in self._pending.items(): + # incremental_loading (Cognee's default) dedups by content hash, so a + # re-push of unchanged payloads is a no-op. node_set tags the dataset. + await cognee.add(payloads, dataset_name=container, node_set=[container]) + await cognee.cognify(datasets=[container]) + + +def client_from_env() -> SdkCogneeClient: + """Build the real client; requires ``LLM_API_KEY`` for Cognee's pipeline.""" + return SdkCogneeClient() + + +def provenance_payload( + *, text: str, rac_id: str, type: str, status: str, title: str +) -> str: + """Prefix a document with a provenance header. + + Cognee carries no per-record metadata filter, so the canonical ``rac_id`` + (and lifecycle) is embedded as a header line — keeping the verify-in-Lore + handle recoverable from Cognee's graph (ADR-007). + """ + header = f"Rac-Id: {rac_id}\nType: {type}\nStatus: {status}\nTitle: {title}" + return f"{header}\n\n{text}" diff --git a/src/rac_connectors/cognee/connector.py b/src/rac_connectors/cognee/connector.py new file mode 100644 index 0000000..fe0359d --- /dev/null +++ b/src/rac_connectors/cognee/connector.py @@ -0,0 +1,68 @@ +"""The Cognee connector — a documents backend (ADR-007). + +A one-way, outbound push of the ``rac export --documents`` stream into Cognee. A +corpus ``source`` maps to a Cognee **dataset**; each record is staged with a +provenance header (Cognee has no per-record metadata filter), then the whole +dataset is built into a knowledge graph once via ``cognify``. Idempotency is +Cognee's native content-hash dedup (``incremental_loading``), not a resync — +re-pushing unchanged records is a no-op. Cognee builds the graph and embeds; +nothing is embedded here (rac-core ADR-002, ADR-066). +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from ..base import PushSummary +from ..records import Record +from .client import CogneeClient, provenance_payload + +BACKEND = "cognee" +# A corpus without a source still needs a Cognee dataset; this is the fallback. +DEFAULT_CONTAINER = "main_dataset" + + +class CogneeConnector: + """Push ``--documents`` records into Cognee, idempotent by content hash.""" + + name = BACKEND + + def __init__(self, client: CogneeClient | None = None) -> None: + # The client is optional so a dry run needs no credentials and no SDK. + self._client = client + + def _container_for(self, record: Record) -> str: + return record.source or DEFAULT_CONTAINER + + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: + summary = PushSummary(backend=self.name, dry_run=dry_run) + for record in records: + container = self._container_for(record) + if dry_run: + summary.record_push( + record.id, + f"dataset={container} type={record.type} " + f"status={record.status} ({len(record.text)} chars)", + ) + continue + payload = provenance_payload( + text=record.text, + rac_id=record.id, + type=record.type, + status=record.status, + title=record.title, + ) + self._require_client().add(payload=payload, container=container) + summary.record_push(record.id, f"dataset={container}") + # Build the graph once, after every record is staged. + if not dry_run and summary.pushed: + self._require_client().commit() + return summary + + def _require_client(self) -> CogneeClient: + if self._client is None: + raise RuntimeError( + "a CogneeClient is required for a live push; " + "pass one or use dry_run=True" + ) + return self._client diff --git a/src/rac_connectors/contract.py b/src/rac_connectors/contract.py new file mode 100644 index 0000000..f792ae3 --- /dev/null +++ b/src/rac_connectors/contract.py @@ -0,0 +1,44 @@ +"""The rac export contract version this connector speaks. + +The only cross-repo dependency rac-connectors has on Lore / RAC is the export +**contract** — the ``schema_version`` carried by ``rac export --documents`` and +``--graph`` — not the rac-core *package* version (this connector never imports +``rac``). The contract is additive within a major and stable (rac-core ADR-007), +and non-Python clients are thin consumers of it (ADR-063). So the dependency to +capture here is the contract major, declared once and checked at read time — +never a pin on ``requirements-as-code`` (see this repo's ADR-008). +""" + +from __future__ import annotations + +import warnings + +#: The export-contract major this connector is built against. +SUPPORTED_CONTRACT_VERSION = "1" + + +class ContractVersionWarning(UserWarning): + """The export declares a contract major this connector wasn't built for.""" + + +def _major(version: str) -> str: + return version.split(".", 1)[0].strip() + + +def check_contract_version(version: str) -> None: + """Warn if ``version``'s major differs from the supported contract major. + + A matching major is safe because the contract only grows additively within a + major (rac-core ADR-007). A different major signals a breaking change this + connector wasn't built for: warn rather than raise, so a best-effort push + still runs and the operator decides — the warning goes to stderr, clear of + the piped payload. + """ + if _major(version) != SUPPORTED_CONTRACT_VERSION: + warnings.warn( + f"export contract schema_version {version!r} differs from the " + f"supported major {SUPPORTED_CONTRACT_VERSION!r}; this connector may " + "not handle it fully (rac-core ADR-007).", + ContractVersionWarning, + stacklevel=2, + ) diff --git a/src/rac_connectors/graph.py b/src/rac_connectors/graph.py new file mode 100644 index 0000000..ae7669e --- /dev/null +++ b/src/rac_connectors/graph.py @@ -0,0 +1,127 @@ +"""Parse the ``rac export --graph`` contract into a typed node/edge graph. + +The graph projection (rac-core ADR-074) is a single JSON object of typed nodes +and edges with ``directed`` / ``resolved`` flags — a different shape from the +``--documents`` stream, so it has its own reader. Like ``records.py`` this is a +*consumer* of a stable contract (ADR-063); it never re-derives anything from raw +Markdown. Unknown additive fields are tolerated (rac-core ADR-007). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +from .contract import check_contract_version + + +class MalformedGraphError(ValueError): + """The ``--graph`` payload could not be parsed into a valid graph.""" + + +@dataclass(frozen=True) +class GraphNode: + """One artifact as a graph node, mirroring the contract's node object.""" + + id: str + type: str + status: str + title: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> GraphNode: + for key in ("id", "type", "status", "title"): + if not isinstance(data.get(key), str): + raise MalformedGraphError(f"node missing or non-string {key!r}") + if not data["id"]: + raise MalformedGraphError("node 'id' must be non-empty") + return cls( + id=data["id"], + type=data["type"], + status=data["status"], + title=data["title"], + ) + + +@dataclass(frozen=True) +class GraphEdge: + """One typed relationship edge. + + ``type`` is the real relationship kind (``supersedes``, ``related_decisions``, + …) from rac's registry; ``directed`` carries its registry direction. + ``resolved`` is False when the reference did not resolve uniquely, in which + case ``target`` is the literal reference text rather than a canonical id. + """ + + source: str + target: str + type: str + directed: bool + resolved: bool + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> GraphEdge: + for key in ("source", "target", "type"): + if not isinstance(data.get(key), str): + raise MalformedGraphError(f"edge missing or non-string {key!r}") + if not data["source"] or not data["target"]: + raise MalformedGraphError("edge 'source'/'target' must be non-empty") + return cls( + source=data["source"], + target=data["target"], + type=data["type"], + # Defaults keep the reader tolerant of additive contract growth. + directed=bool(data.get("directed", True)), + resolved=bool(data.get("resolved", True)), + ) + + +@dataclass(frozen=True) +class Graph: + """The whole corpus as typed nodes + edges (one ``--graph`` object).""" + + source: str + nodes: list[GraphNode] = field(default_factory=list) + edges: list[GraphEdge] = field(default_factory=list) + schema_version: str = "1" + + +def parse_graph(payload: str) -> Graph: + """Parse a ``--graph`` JSON document (one object) into a :class:`Graph`. + + Raises :class:`MalformedGraphError` if the payload is not a JSON object or a + node/edge is structurally invalid. Whole-graph parsing is fail-fast: unlike + the line-oriented documents reader, a graph is a single object, so a broken + structure is not something to skip past. + """ + text = payload.strip() + if not text: + raise MalformedGraphError("empty graph payload") + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise MalformedGraphError(f"invalid JSON ({exc})") from exc + if not isinstance(data, dict): + raise MalformedGraphError("graph payload is not a JSON object") + + source = data.get("source") + if not isinstance(source, str): + raise MalformedGraphError("graph missing or non-string 'source'") + + raw_nodes = data.get("nodes", []) + raw_edges = data.get("edges", []) + if not isinstance(raw_nodes, list) or not isinstance(raw_edges, list): + raise MalformedGraphError("graph 'nodes'/'edges' must be arrays") + + schema_version = data.get("schema_version", "1") + if not isinstance(schema_version, str): + schema_version = str(schema_version) + check_contract_version(schema_version) + + return Graph( + source=source, + nodes=[GraphNode.from_dict(n) for n in raw_nodes], + edges=[GraphEdge.from_dict(e) for e in raw_edges], + schema_version=schema_version, + ) diff --git a/src/rac_connectors/letta/__init__.py b/src/rac_connectors/letta/__init__.py new file mode 100644 index 0000000..6b7a436 --- /dev/null +++ b/src/rac_connectors/letta/__init__.py @@ -0,0 +1,21 @@ +"""Letta backend module for rac-connectors.""" + +from __future__ import annotations + +from .client import ( + LettaClient, + MissingCredentialsError, + SdkLettaClient, + client_from_env, +) +from .connector import BACKEND, DEFAULT_CONTAINER, LettaConnector + +__all__ = [ + "BACKEND", + "DEFAULT_CONTAINER", + "LettaClient", + "LettaConnector", + "MissingCredentialsError", + "SdkLettaClient", + "client_from_env", +] diff --git a/src/rac_connectors/letta/client.py b/src/rac_connectors/letta/client.py new file mode 100644 index 0000000..930f0cc --- /dev/null +++ b/src/rac_connectors/letta/client.py @@ -0,0 +1,100 @@ +"""The thin client seam the Letta connector pushes through. + +The connector depends only on :class:`LettaClient` (a Protocol) — the same +``clear_container`` / ``add`` shape as the other memory backends — so the +test-suite drives an in-memory fake and CI never touches the live API. +:class:`SdkLettaClient` is the real adapter over the ``letta-client`` SDK; it maps +a corpus ``source`` to a Letta **archive** and hides the archive-id bookkeeping. +""" + +from __future__ import annotations + +import os +from typing import Any, Protocol, runtime_checkable + +API_KEY_ENV = "LETTA_API_KEY" +BASE_URL_ENV = "LETTA_BASE_URL" + + +class MissingCredentialsError(RuntimeError): + """No Letta credentials were found in the environment.""" + + def __init__(self) -> None: + super().__init__( + f"set {API_KEY_ENV} (Letta Cloud) or {BASE_URL_ENV} (self-hosted) " + "in the environment (never hard-code credentials)" + ) + + +@runtime_checkable +class LettaClient(Protocol): + """What the connector needs from a Letta client. + + Two operations: clear a container, and add a passage to it. Letta has no + per-record upsert key, so idempotency is an archive resync (clear then add) + the connector drives — see ADR-006. + """ + + def clear_container(self, *, container: str) -> None: ... + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: ... + + +class SdkLettaClient: + """Adapter over the real ``letta-client`` SDK. + + Lazily constructs ``letta_client.Letta`` so importing this module never + requires the SDK. A corpus ``source`` maps to a Letta archive (created fresh + on each resync); the connector pushes by container name and this adapter + resolves the opaque ``archive_id`` internally. + """ + + def __init__( + self, *, api_key: str | None = None, base_url: str | None = None + ) -> None: + self._api_key = api_key or os.environ.get(API_KEY_ENV) + self._base_url = base_url or os.environ.get(BASE_URL_ENV) + if not (self._api_key or self._base_url): + raise MissingCredentialsError() + self._client: Any = None + self._archive_ids: dict[str, str] = {} + + def _ensure_client(self) -> Any: + if self._client is None: + try: + from letta_client import Letta + except ImportError as exc: # pragma: no cover - exercised via message + raise RuntimeError( + "the 'letta-client' SDK is not installed; " + "install the connector's 'letta' extra" + ) from exc + kwargs: dict[str, Any] = {} + if self._api_key: + kwargs["api_key"] = self._api_key + if self._base_url: + kwargs["base_url"] = self._base_url + self._client = Letta(**kwargs) + return self._client + + def clear_container(self, *, container: str) -> None: + client = self._ensure_client() + # Delete any archive(s) by this name, then create a fresh one. + for archive in client.archives.list(name=container): + client.archives.delete(archive.id) + archive = client.archives.create(name=container) + self._archive_ids[container] = archive.id + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: + archive_id = self._archive_ids[container] + # Letta embeds the passage server-side (rac-core ADR-002, ADR-066). + self._ensure_client().archives.passages.create( + archive_id, text=text, metadata=metadata + ) + + +def client_from_env() -> SdkLettaClient: + """Build the real client from the environment. + + Reads ``LETTA_API_KEY`` (Letta Cloud) or ``LETTA_BASE_URL`` (self-hosted). + """ + return SdkLettaClient() diff --git a/src/rac_connectors/letta/connector.py b/src/rac_connectors/letta/connector.py new file mode 100644 index 0000000..c88e540 --- /dev/null +++ b/src/rac_connectors/letta/connector.py @@ -0,0 +1,85 @@ +"""The Letta connector — a documents backend (ADR-006). + +A one-way, outbound push of the ``rac export --documents`` stream into Letta. A +corpus ``source`` maps to a Letta **archive**; each record is added as a passage. +Letta has no per-record upsert key, so idempotency is an **archive resync**: the +first time a source is seen in a push, its archive is cleared (deleted and +recreated), then every record for it is added. Re-running yields the same +archive — no duplicates. Letta embeds the passages; nothing is embedded here +(rac-core ADR-002, ADR-066). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from ..base import PushSummary +from ..records import Record +from .client import LettaClient + +BACKEND = "letta" +# A corpus without a source still needs a Letta archive; this is the fallback. +DEFAULT_CONTAINER = "lore" + + +class LettaConnector: + """Push ``--documents`` records into Letta, idempotent by archive resync.""" + + name = BACKEND + + def __init__(self, client: LettaClient | None = None) -> None: + # The client is optional so a dry run needs no credentials and no SDK. + self._client = client + + def _container_for(self, record: Record) -> str: + return record.source or DEFAULT_CONTAINER + + def _metadata_for(self, record: Record) -> dict[str, Any]: + """Metadata shipped with each passage. + + ``rac_id`` carries the canonical handle for the verify-in-Lore loop; + ``type``/``status``/``title`` ride along so a reader can filter retired + or superseded items on read. + """ + metadata = dict(record.metadata) + metadata.update( + { + "rac_id": record.id, + "type": record.type, + "status": record.status, + "title": record.title, + } + ) + return metadata + + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: + summary = PushSummary(backend=self.name, dry_run=dry_run) + cleared: set[str] = set() + for record in records: + container = self._container_for(record) + metadata = self._metadata_for(record) + if dry_run: + summary.record_push( + record.id, + f"archive={container} type={record.type} " + f"status={record.status} ({len(record.text)} chars)", + ) + continue + client = self._require_client() + # Clear the archive once per push, before its first add, so the adds + # are a clean resync rather than an append. + if container not in cleared: + client.clear_container(container=container) + cleared.add(container) + client.add(text=record.text, container=container, metadata=metadata) + summary.record_push(record.id, f"archive={container}") + return summary + + def _require_client(self) -> LettaClient: + if self._client is None: + raise RuntimeError( + "a LettaClient is required for a live push; " + "pass one or use dry_run=True" + ) + return self._client diff --git a/src/rac_connectors/mem0/__init__.py b/src/rac_connectors/mem0/__init__.py new file mode 100644 index 0000000..0298dd6 --- /dev/null +++ b/src/rac_connectors/mem0/__init__.py @@ -0,0 +1,21 @@ +"""Mem0 backend module for rac-connectors.""" + +from __future__ import annotations + +from .client import ( + Mem0Client, + MissingApiKeyError, + SdkMem0Client, + client_from_env, +) +from .connector import BACKEND, DEFAULT_CONTAINER, Mem0Connector + +__all__ = [ + "BACKEND", + "DEFAULT_CONTAINER", + "Mem0Client", + "Mem0Connector", + "MissingApiKeyError", + "SdkMem0Client", + "client_from_env", +] diff --git a/src/rac_connectors/mem0/client.py b/src/rac_connectors/mem0/client.py new file mode 100644 index 0000000..b91fcb4 --- /dev/null +++ b/src/rac_connectors/mem0/client.py @@ -0,0 +1,81 @@ +"""The thin client seam the Mem0 connector pushes through. + +The connector depends only on :class:`Mem0Client` (a Protocol), so the test-suite +drives an in-memory fake and CI never touches the live API. :class:`SdkMem0Client` +is the real adapter over the ``mem0`` SDK's ``MemoryClient``, imported lazily so +the package installs and tests run without it. +""" + +from __future__ import annotations + +import os +from typing import Any, Protocol, runtime_checkable + +API_KEY_ENV = "MEM0_API_KEY" + + +class MissingApiKeyError(RuntimeError): + """No Mem0 API key was found in the environment.""" + + def __init__(self) -> None: + super().__init__( + f"set {API_KEY_ENV} in the environment (never hard-code the key)" + ) + + +@runtime_checkable +class Mem0Client(Protocol): + """What the connector needs from a Mem0 client. + + Two operations: clear a container partition, and add a memory to it. Mem0 has + no per-record upsert key, so idempotency is a container resync (clear then + add) the connector drives — see ADR-004. + """ + + def clear_container(self, *, container: str) -> None: ... + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: ... + + +class SdkMem0Client: + """Adapter over the real ``mem0`` SDK (``MemoryClient``). + + Lazily constructs ``mem0.MemoryClient`` so importing this module never + requires the SDK; the import error only surfaces on a live push without the + ``mem0`` extra installed. The corpus ``source`` maps to a Mem0 ``user_id`` + partition (the primary, broadly-supported scope for add/delete). + """ + + def __init__(self, *, api_key: str | None = None) -> None: + self._api_key = api_key or os.environ.get(API_KEY_ENV) + if not self._api_key: + raise MissingApiKeyError() + self._client: Any = None + + def _ensure_client(self) -> Any: + if self._client is None: + try: + from mem0 import MemoryClient + except ImportError as exc: # pragma: no cover - exercised via message + raise RuntimeError( + "the 'mem0ai' SDK is not installed; " + "install the connector's 'mem0' extra" + ) from exc + self._client = MemoryClient(api_key=self._api_key) + return self._client + + def clear_container(self, *, container: str) -> None: + # Wipe the partition so the subsequent adds are a clean resync. + self._ensure_client().delete_all(user_id=container) + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: + # infer=False stores the artifact text as-is — no LLM fact-extraction or + # rewrite — so Mem0 only embeds it (rac-core ADR-002, ADR-066). + self._ensure_client().add( + text, user_id=container, metadata=metadata, infer=False + ) + + +def client_from_env() -> SdkMem0Client: + """Build the real client from environment variables (``MEM0_API_KEY``).""" + return SdkMem0Client() diff --git a/src/rac_connectors/mem0/connector.py b/src/rac_connectors/mem0/connector.py new file mode 100644 index 0000000..68ee1c2 --- /dev/null +++ b/src/rac_connectors/mem0/connector.py @@ -0,0 +1,84 @@ +"""The Mem0 connector — a documents backend (ADR-004). + +A one-way, outbound push of the ``rac export --documents`` stream into Mem0. Mem0 +has no per-record upsert key (unlike Supermemory's ``custom_id``), so idempotency +is a **container resync**: the first time a corpus ``source`` is seen in a push, +its partition is cleared, then every record for it is added. Re-running yields the +same partition contents — no duplicates — which satisfies the contract's +"containerTag as the upsert key". Embeddings live in Mem0, never here (rac-core +ADR-002, ADR-066). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from ..base import PushSummary +from ..records import Record +from .client import Mem0Client + +BACKEND = "mem0" +# A corpus without a source still needs a Mem0 partition; this is the fallback. +DEFAULT_CONTAINER = "lore" + + +class Mem0Connector: + """Push ``--documents`` records into Mem0, idempotent by container resync.""" + + name = BACKEND + + def __init__(self, client: Mem0Client | None = None) -> None: + # The client is optional so a dry run needs no credentials and no SDK. + self._client = client + + def _container_for(self, record: Record) -> str: + return record.source or DEFAULT_CONTAINER + + def _metadata_for(self, record: Record) -> dict[str, Any]: + """Metadata shipped with each memory. + + ``rac_id`` carries the canonical handle for the verify-in-Lore loop; + ``type``/``status``/``title`` ride along so a reader can filter retired + or superseded items on read. + """ + metadata = dict(record.metadata) + metadata.update( + { + "rac_id": record.id, + "type": record.type, + "status": record.status, + "title": record.title, + } + ) + return metadata + + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: + summary = PushSummary(backend=self.name, dry_run=dry_run) + cleared: set[str] = set() + for record in records: + container = self._container_for(record) + metadata = self._metadata_for(record) + if dry_run: + summary.record_push( + record.id, + f"container={container} type={record.type} " + f"status={record.status} ({len(record.text)} chars)", + ) + continue + client = self._require_client() + # Clear the partition once per push, before its first add, so the + # adds are a clean resync rather than an append. + if container not in cleared: + client.clear_container(container=container) + cleared.add(container) + client.add(text=record.text, container=container, metadata=metadata) + summary.record_push(record.id, f"container={container}") + return summary + + def _require_client(self) -> Mem0Client: + if self._client is None: + raise RuntimeError( + "a Mem0Client is required for a live push; pass one or use dry_run=True" + ) + return self._client diff --git a/src/rac_connectors/neo4j/__init__.py b/src/rac_connectors/neo4j/__init__.py new file mode 100644 index 0000000..632a636 --- /dev/null +++ b/src/rac_connectors/neo4j/__init__.py @@ -0,0 +1,20 @@ +"""Neo4j graph backend module for rac-connectors.""" + +from __future__ import annotations + +from .client import ( + DriverNeo4jClient, + MissingCredentialsError, + Neo4jClient, + client_from_env, +) +from .connector import BACKEND, Neo4jConnector + +__all__ = [ + "BACKEND", + "DriverNeo4jClient", + "MissingCredentialsError", + "Neo4jClient", + "Neo4jConnector", + "client_from_env", +] diff --git a/src/rac_connectors/neo4j/client.py b/src/rac_connectors/neo4j/client.py new file mode 100644 index 0000000..4a03ae9 --- /dev/null +++ b/src/rac_connectors/neo4j/client.py @@ -0,0 +1,96 @@ +"""The thin client seam the Neo4j connector writes through. + +The connector depends only on :class:`Neo4jClient` (a Protocol), so the +test-suite drives an in-memory fake and CI never touches a real database. +:class:`DriverNeo4jClient` is the real adapter over the official ``neo4j`` +driver, imported lazily so the package installs and tests run without it. +""" + +from __future__ import annotations + +import os +from typing import Any, Protocol, runtime_checkable + +URI_ENV = "NEO4J_URI" +USER_ENV = "NEO4J_USERNAME" +PASSWORD_ENV = "NEO4J_PASSWORD" +DATABASE_ENV = "NEO4J_DATABASE" + + +class MissingCredentialsError(RuntimeError): + """Neo4j connection details were not found in the environment.""" + + def __init__(self) -> None: + super().__init__( + f"set {URI_ENV}, {USER_ENV}, and {PASSWORD_ENV} in the environment " + "(never hard-code credentials)" + ) + + +@runtime_checkable +class Neo4jClient(Protocol): + """What the connector needs from a Neo4j client: run a parameterised Cypher + statement. + + One method. The connector builds every statement with fixed labels and + parameterised values, so the client just executes ``(cypher, params)``. + """ + + def run(self, cypher: str, parameters: dict[str, Any]) -> None: ... + + def close(self) -> None: ... + + +class DriverNeo4jClient: + """Adapter over the official ``neo4j`` Python driver. + + Lazily constructs the driver so importing this module never requires the + ``neo4j`` package; the import error only surfaces on a live push without the + ``neo4j`` extra installed. + """ + + def __init__( + self, + *, + uri: str | None = None, + user: str | None = None, + password: str | None = None, + database: str | None = None, + ) -> None: + self._uri = uri or os.environ.get(URI_ENV) + self._user = user or os.environ.get(USER_ENV) + self._password = password or os.environ.get(PASSWORD_ENV) + if not (self._uri and self._user and self._password): + raise MissingCredentialsError() + self._database = database or os.environ.get(DATABASE_ENV) + self._driver: Any = None + + def _ensure_driver(self) -> Any: + if self._driver is None: + try: + from neo4j import GraphDatabase + except ImportError as exc: # pragma: no cover - exercised via message + raise RuntimeError( + "the 'neo4j' driver is not installed; " + "install the connector's 'neo4j' extra" + ) from exc + self._driver = GraphDatabase.driver( + self._uri, auth=(self._user, self._password) + ) + return self._driver + + def run(self, cypher: str, parameters: dict[str, Any]) -> None: + driver = self._ensure_driver() + kwargs = {"database": self._database} if self._database else {} + with driver.session(**kwargs) as session: + session.run(cypher, parameters) + + def close(self) -> None: + if self._driver is not None: + self._driver.close() + self._driver = None + + +def client_from_env() -> DriverNeo4jClient: + """Build the real client from environment variables (``NEO4J_URI`` etc.).""" + return DriverNeo4jClient() diff --git a/src/rac_connectors/neo4j/connector.py b/src/rac_connectors/neo4j/connector.py new file mode 100644 index 0000000..0a25fcc --- /dev/null +++ b/src/rac_connectors/neo4j/connector.py @@ -0,0 +1,90 @@ +"""The Neo4j graph connector — first graph backend (ADR-003). + +A one-way, outbound push of the ``rac export --graph`` projection: ``MERGE`` each +node by canonical ``id`` and each edge by type, so re-syncing an edited corpus +updates in place and never duplicates. Outbound only — it writes nodes and edges +and never queries, traverses, or analyses (rac-core ADR-002, ADR-066). + +Cypher is injection-safe: every node and edge value is a query parameter; only +the fixed labels ``Artifact`` and ``REL`` are interpolated, so no corpus content +reaches Cypher as code. +""" + +from __future__ import annotations + +from ..base import PushSummary +from ..graph import Graph, GraphEdge, GraphNode +from .client import Neo4jClient + +BACKEND = "neo4j" + +# Fixed, parameterised statements (ADR-003 / graph-connector-shape design). +_MERGE_NODE = ( + "MERGE (n:Artifact {id: $id}) " + "SET n.type = $type, n.status = $status, n.title = $title, n.source = $source" +) +_MERGE_EDGE = ( + "MATCH (a:Artifact {id: $source}), (b:Artifact {id: $target}) " + "MERGE (a)-[r:REL {type: $type}]->(b) " + "SET r.directed = $directed" +) + + +class Neo4jConnector: + """Upsert a Lore graph into Neo4j, idempotently on canonical identity.""" + + name = BACKEND + + def __init__(self, client: Neo4jClient | None = None) -> None: + # The client is optional so a dry run needs no driver and no credentials. + self._client = client + + def _node_params(self, node: GraphNode, source: str) -> dict[str, object]: + return { + "id": node.id, + "type": node.type, + "status": node.status, + "title": node.title, + "source": source, + } + + def _edge_params(self, edge: GraphEdge) -> dict[str, object]: + return { + "source": edge.source, + "target": edge.target, + "type": edge.type, + "directed": edge.directed, + } + + def push_graph(self, graph: Graph, *, dry_run: bool = False) -> PushSummary: + summary = PushSummary(backend=self.name, dry_run=dry_run) + + for node in graph.nodes: + params = self._node_params(node, graph.source) + if dry_run: + summary.record_push(node.id, f"node type={node.type}") + else: + self._require_client().run(_MERGE_NODE, params) + summary.record_push(node.id, f"node type={node.type}") + + for edge in graph.edges: + label = f"{edge.source}-[{edge.type}]->{edge.target}" + if not edge.resolved: + # No phantom target node — mirror the documents no-phantom rule. + summary.record_skip_item(label, "unresolved edge") + continue + if dry_run: + summary.record_push(label, "edge") + else: + self._require_client().run(_MERGE_EDGE, self._edge_params(edge)) + summary.record_push(label, "edge") + + return summary + + def _require_client(self) -> Neo4jClient: + if self._client is None: + raise RuntimeError( + "a Neo4jClient is required for a live push; " + "pass one or use dry_run=True" + ) + return self._client diff --git a/src/rac_connectors/records.py b/src/rac_connectors/records.py new file mode 100644 index 0000000..0946771 --- /dev/null +++ b/src/rac_connectors/records.py @@ -0,0 +1,130 @@ +"""Parse the ``rac export --documents`` JSON Lines contract into records. + +This module is the connector side of the export contract shipped by rac-core +(``rac export --documents``). It is intentionally a *consumer* of that +contract — it never re-derives anything from raw Markdown (ADR-073, ADR-063). +Each input line is one artifact (ADR-004, ADR-010): one document, never chunked. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Iterator +from dataclasses import dataclass, field +from typing import Any + +from .contract import check_contract_version + + +class MalformedRecordError(ValueError): + """A ``--documents`` line could not be parsed into a valid record. + + Carries the 1-based line number and the offending raw text so a caller can + report exactly which line failed without re-reading the stream. + """ + + def __init__(self, line_number: int, reason: str, raw: str) -> None: + self.line_number = line_number + self.reason = reason + self.raw = raw + super().__init__(f"line {line_number}: {reason}") + + +@dataclass(frozen=True) +class Record: + """One artifact from the ``--documents`` projection. + + Mirrors the export contract's per-line object. ``text`` is the Markdown body + (frontmatter stripped) — backends embed text, not HTML. ``id`` is the + canonical Lore handle the verify-in-Lore loop re-fetches by, and ``status`` + rides along so a reader can drop retired/superseded items. + """ + + id: str + type: str + status: str + title: str + text: str + metadata: dict[str, Any] = field(default_factory=dict) + schema_version: str = "1" + + @property + def source(self) -> str | None: + """The corpus name that namespaces this record (``metadata.source``). + + Used as the Supermemory ``container_tag``. May be absent on hand-rolled + fixtures; the connector decides how to handle a missing source. + """ + source = self.metadata.get("source") + return source if isinstance(source, str) else None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Record: + """Build a record from one decoded JSON object, validating the contract. + + Raises ``ValueError`` if a required field is missing or mistyped. The + required set is the stable contract: ``id``, ``type``, ``status``, + ``title``, ``text``. ``metadata`` and ``schema_version`` are optional + with safe defaults so the parser tolerates additive contract growth + (ADR-007). + """ + if not isinstance(data, dict): + raise ValueError("record is not a JSON object") + + required = ("id", "type", "status", "title", "text") + for key in required: + value = data.get(key) + if not isinstance(value, str): + raise ValueError(f"missing or non-string field {key!r}") + if key == "id" and not value: + raise ValueError("field 'id' must be non-empty") + + metadata = data.get("metadata", {}) + if not isinstance(metadata, dict): + raise ValueError("field 'metadata' must be an object") + + schema_version = data.get("schema_version", "1") + if not isinstance(schema_version, str): + schema_version = str(schema_version) + + return cls( + id=data["id"], + type=data["type"], + status=data["status"], + title=data["title"], + text=data["text"], + metadata=metadata, + schema_version=schema_version, + ) + + +def parse_documents(lines: Iterable[str], *, strict: bool = False) -> Iterator[Record]: + """Yield :class:`Record` objects from ``--documents`` JSON Lines. + + Blank lines (and surrounding whitespace) are skipped — JSONL files commonly + end with a trailing newline. By default a malformed line is skipped and + surfaced by raising only when ``strict`` is set; callers that want a + fail-fast guard pass ``strict=True`` and catch :class:`MalformedRecordError`. + + Skipping vs. raising is a connector policy choice, so this generator does the + minimal thing (raise in strict mode) and lets the connector layer decide how + lenient to be. The malformed-line guard is exercised in the test-suite. + """ + for index, raw in enumerate(lines, start=1): + stripped = raw.strip() + if not stripped: + continue + try: + decoded = json.loads(stripped) + except json.JSONDecodeError as exc: + if strict: + raise MalformedRecordError(index, f"invalid JSON ({exc})", raw) from exc + continue + try: + record = Record.from_dict(decoded) + except ValueError as exc: + if strict: + raise MalformedRecordError(index, str(exc), raw) from exc + continue + check_contract_version(record.schema_version) + yield record diff --git a/src/rac_connectors/supermemory/__init__.py b/src/rac_connectors/supermemory/__init__.py new file mode 100644 index 0000000..ac89f49 --- /dev/null +++ b/src/rac_connectors/supermemory/__init__.py @@ -0,0 +1,22 @@ +"""Supermemory backend module for rac-connectors.""" + +from __future__ import annotations + +from .client import ( + AddResult, + MissingApiKeyError, + SdkSupermemoryClient, + SupermemoryClient, + client_from_env, +) +from .connector import BACKEND, SupermemoryConnector + +__all__ = [ + "AddResult", + "BACKEND", + "MissingApiKeyError", + "SdkSupermemoryClient", + "SupermemoryClient", + "SupermemoryConnector", + "client_from_env", +] diff --git a/src/rac_connectors/supermemory/client.py b/src/rac_connectors/supermemory/client.py new file mode 100644 index 0000000..f93f662 --- /dev/null +++ b/src/rac_connectors/supermemory/client.py @@ -0,0 +1,110 @@ +"""The thin client seam the Supermemory connector pushes through. + +The connector depends only on :class:`SupermemoryClient` (a Protocol), so the +test-suite drives it with an in-memory fake and CI never touches the live API. +:class:`SdkSupermemoryClient` is the real adapter over the ``supermemory`` SDK, +imported lazily so the package installs and tests run without the SDK present. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +API_KEY_ENV = "SUPERMEMORY_API_KEY" +BASE_URL_ENV = "SUPERMEMORY_BASE_URL" + + +class MissingApiKeyError(RuntimeError): + """No Supermemory API key was found in the environment.""" + + def __init__(self) -> None: + super().__init__( + f"set {API_KEY_ENV} in the environment (never hard-code the key)" + ) + + +@dataclass(frozen=True) +class AddResult: + """The minimal result of an upsert the connector cares about.""" + + id: str | None + status: str | None + + +@runtime_checkable +class SupermemoryClient(Protocol): + """What the connector needs from a Supermemory client. + + One method: upsert a memory. ``custom_id`` is the idempotency key — passing + the canonical Lore ``id`` makes re-pushing an edited artifact an update, not + a duplicate. + """ + + def add( + self, + *, + content: str, + container_tag: str | None, + metadata: dict[str, Any], + custom_id: str, + ) -> AddResult: ... + + +class SdkSupermemoryClient: + """Adapter over the real ``supermemory`` Python SDK. + + Lazily constructs ``supermemory.Supermemory`` so importing this module never + requires the SDK; the import error only surfaces if a live push is attempted + without the ``supermemory`` extra installed. + """ + + def __init__(self, *, api_key: str | None = None, base_url: str | None = None): + self._api_key = api_key or os.environ.get(API_KEY_ENV) + if not self._api_key: + raise MissingApiKeyError() + self._base_url = base_url or os.environ.get(BASE_URL_ENV) + self._client: Any = None + + def _ensure_client(self) -> Any: + if self._client is None: + try: + from supermemory import Supermemory + except ImportError as exc: # pragma: no cover - exercised via message + raise RuntimeError( + "the 'supermemory' SDK is not installed; " + "install the connector's 'supermemory' extra" + ) from exc + kwargs: dict[str, Any] = {"api_key": self._api_key} + if self._base_url: + kwargs["base_url"] = self._base_url + self._client = Supermemory(**kwargs) + return self._client + + def add( + self, + *, + content: str, + container_tag: str | None, + metadata: dict[str, Any], + custom_id: str, + ) -> AddResult: + client = self._ensure_client() + kwargs: dict[str, Any] = { + "content": content, + "metadata": metadata, + "custom_id": custom_id, + } + if container_tag is not None: + kwargs["container_tag"] = container_tag + response = client.add(**kwargs) + return AddResult( + id=getattr(response, "id", None), + status=getattr(response, "status", None), + ) + + +def client_from_env() -> SdkSupermemoryClient: + """Build the real client from environment variables (``SUPERMEMORY_API_KEY``).""" + return SdkSupermemoryClient() diff --git a/src/rac_connectors/supermemory/connector.py b/src/rac_connectors/supermemory/connector.py new file mode 100644 index 0000000..7d5685d --- /dev/null +++ b/src/rac_connectors/supermemory/connector.py @@ -0,0 +1,82 @@ +"""The Supermemory connector — module one of rac-connectors (ADR-073). + +A one-way, outbound push: read ``rac export --documents`` records and upsert each +into Supermemory. The mapping is fixed by the rac-core design +``corpus-export-shape-contract``:: + + each record -> add(content=text, + container_tag=metadata.source, + metadata={id, type, status, title, path, ...}, + custom_id=id) + +Embeddings live in Supermemory, never here (ADR-002, ADR-066). The connector +keeps the backend fresh; the verify-in-Lore loop is the reading agent's job. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from ..base import PushSummary +from ..records import Record +from .client import SupermemoryClient + +BACKEND = "supermemory" + + +class SupermemoryConnector: + """Push ``--documents`` records into Supermemory, idempotently on ``id``.""" + + name = BACKEND + + def __init__(self, client: SupermemoryClient | None = None) -> None: + # The client is optional so a dry run needs no credentials and no SDK. + self._client = client + + def _metadata_for(self, record: Record) -> dict[str, Any]: + """The metadata payload shipped with each memory. + + Carries the record's own ``metadata`` (path, aliases, tags, source) plus + the load-bearing ``id``/``type``/``status``/``title`` so the + verify-in-Lore loop can re-fetch the authoritative artifact and a reader + can filter retired/superseded items on read. + """ + metadata = dict(record.metadata) + metadata.update( + { + "id": record.id, + "type": record.type, + "status": record.status, + "title": record.title, + } + ) + return metadata + + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: + summary = PushSummary(backend=self.name, dry_run=dry_run) + for record in records: + container_tag = record.source + metadata = self._metadata_for(record) + if dry_run: + tag = container_tag or "" + summary.record_push( + record.id, + f"container_tag={tag} type={record.type} " + f"status={record.status} ({len(record.text)} chars)", + ) + continue + if self._client is None: + raise RuntimeError( + "a SupermemoryClient is required for a live push; " + "pass one or use dry_run=True" + ) + result = self._client.add( + content=record.text, + container_tag=container_tag, + metadata=metadata, + custom_id=record.id, # idempotency key: re-push updates, never dupes + ) + detail = f"status={result.status}" if result.status else "ok" + summary.record_push(record.id, detail) + return summary diff --git a/src/rac_connectors/zep/__init__.py b/src/rac_connectors/zep/__init__.py new file mode 100644 index 0000000..3b8ccea --- /dev/null +++ b/src/rac_connectors/zep/__init__.py @@ -0,0 +1,21 @@ +"""Zep backend module for rac-connectors.""" + +from __future__ import annotations + +from .client import ( + MissingApiKeyError, + SdkZepClient, + ZepClient, + client_from_env, +) +from .connector import BACKEND, DEFAULT_CONTAINER, ZepConnector + +__all__ = [ + "BACKEND", + "DEFAULT_CONTAINER", + "MissingApiKeyError", + "SdkZepClient", + "ZepClient", + "ZepConnector", + "client_from_env", +] diff --git a/src/rac_connectors/zep/client.py b/src/rac_connectors/zep/client.py new file mode 100644 index 0000000..f3acad5 --- /dev/null +++ b/src/rac_connectors/zep/client.py @@ -0,0 +1,85 @@ +"""The thin client seam the Zep connector pushes through. + +The connector depends only on :class:`ZepClient` (a Protocol), so the test-suite +drives an in-memory fake and CI never touches the live API. :class:`SdkZepClient` +is the real adapter over the ``zep-cloud`` SDK, imported lazily so the package +installs and tests run without it. +""" + +from __future__ import annotations + +import os +from typing import Any, Protocol, runtime_checkable + +API_KEY_ENV = "ZEP_API_KEY" + + +class MissingApiKeyError(RuntimeError): + """No Zep API key was found in the environment.""" + + def __init__(self) -> None: + super().__init__( + f"set {API_KEY_ENV} in the environment (never hard-code the key)" + ) + + +@runtime_checkable +class ZepClient(Protocol): + """What the connector needs from a Zep client. + + Two operations: clear a graph namespace, and add a text episode to it. Zep + has no per-record upsert key, so idempotency is a graph resync (clear then + add) the connector drives — see ADR-005. + """ + + def clear_container(self, *, container: str) -> None: ... + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: ... + + +class SdkZepClient: + """Adapter over the real ``zep-cloud`` SDK. + + Lazily constructs ``zep_cloud.client.Zep`` so importing this module never + requires the SDK; the import error only surfaces on a live push without the + ``zep`` extra installed. A corpus ``source`` maps to a Zep ``graph_id``. + """ + + def __init__(self, *, api_key: str | None = None) -> None: + self._api_key = api_key or os.environ.get(API_KEY_ENV) + if not self._api_key: + raise MissingApiKeyError() + self._client: Any = None + + def _ensure_client(self) -> Any: + if self._client is None: + try: + from zep_cloud.client import Zep + except ImportError as exc: # pragma: no cover - exercised via message + raise RuntimeError( + "the 'zep-cloud' SDK is not installed; " + "install the connector's 'zep' extra" + ) from exc + self._client = Zep(api_key=self._api_key) + return self._client + + def clear_container(self, *, container: str) -> None: + client = self._ensure_client() + try: + client.graph.delete(container) + except Exception: + # The graph may not exist yet on a first sync; create it below. + pass + client.graph.create(graph_id=container) + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: + # type="text" ingests the artifact body; Zep derives its graph and + # embeds — no embedding happens here (rac-core ADR-002, ADR-066). + self._ensure_client().graph.add( + data=text, type="text", graph_id=container, metadata=metadata + ) + + +def client_from_env() -> SdkZepClient: + """Build the real client from environment variables (``ZEP_API_KEY``).""" + return SdkZepClient() diff --git a/src/rac_connectors/zep/connector.py b/src/rac_connectors/zep/connector.py new file mode 100644 index 0000000..0408fc3 --- /dev/null +++ b/src/rac_connectors/zep/connector.py @@ -0,0 +1,83 @@ +"""The Zep connector — a documents backend (ADR-005). + +A one-way, outbound push of the ``rac export --documents`` stream into Zep Cloud. +Like Mem0, Zep has no per-record upsert key, so idempotency is a **graph +resync**: the first time a corpus ``source`` is seen in a push, its graph is +cleared (deleted and recreated), then every record for it is added as a text +episode. Re-running yields the same graph — no duplicates. Zep derives its +knowledge graph and embeds; nothing is embedded here (rac-core ADR-002, ADR-066). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from ..base import PushSummary +from ..records import Record +from .client import ZepClient + +BACKEND = "zep" +# A corpus without a source still needs a Zep graph; this is the fallback. +DEFAULT_CONTAINER = "lore" + + +class ZepConnector: + """Push ``--documents`` records into Zep, idempotent by graph resync.""" + + name = BACKEND + + def __init__(self, client: ZepClient | None = None) -> None: + # The client is optional so a dry run needs no credentials and no SDK. + self._client = client + + def _container_for(self, record: Record) -> str: + return record.source or DEFAULT_CONTAINER + + def _metadata_for(self, record: Record) -> dict[str, Any]: + """Metadata shipped with each episode. + + ``rac_id`` carries the canonical handle for the verify-in-Lore loop; + ``type``/``status``/``title`` ride along so a reader can filter retired + or superseded items on read. + """ + metadata = dict(record.metadata) + metadata.update( + { + "rac_id": record.id, + "type": record.type, + "status": record.status, + "title": record.title, + } + ) + return metadata + + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: + summary = PushSummary(backend=self.name, dry_run=dry_run) + cleared: set[str] = set() + for record in records: + container = self._container_for(record) + metadata = self._metadata_for(record) + if dry_run: + summary.record_push( + record.id, + f"graph={container} type={record.type} " + f"status={record.status} ({len(record.text)} chars)", + ) + continue + client = self._require_client() + # Clear the graph once per push, before its first add, so the adds + # are a clean resync rather than an append. + if container not in cleared: + client.clear_container(container=container) + cleared.add(container) + client.add(text=record.text, container=container, metadata=metadata) + summary.record_push(record.id, f"graph={container}") + return summary + + def _require_client(self) -> ZepClient: + if self._client is None: + raise RuntimeError( + "a ZepClient is required for a live push; pass one or use dry_run=True" + ) + return self._client diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..8fec271 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,38 @@ +"""A fake Supermemory client for the test-suite — no live API in CI.""" + +from __future__ import annotations + +from typing import Any + +from rac_connectors.supermemory.client import AddResult + + +class FakeSupermemoryClient: + """In-memory stand-in for the Supermemory SDK. + + Records every ``add`` call and upserts by ``custom_id`` so a test can assert + both the record->call mapping and that re-pushing the same ``id`` updates the + stored copy instead of creating a duplicate. + """ + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + self.store: dict[str, dict[str, Any]] = {} + + def add( + self, + *, + content: str, + container_tag: str | None, + metadata: dict[str, Any], + custom_id: str, + ) -> AddResult: + call = { + "content": content, + "container_tag": container_tag, + "metadata": metadata, + "custom_id": custom_id, + } + self.calls.append(call) + self.store[custom_id] = call # upsert by canonical id + return AddResult(id=custom_id, status="queued") diff --git a/tests/fixtures_documents.jsonl b/tests/fixtures_documents.jsonl new file mode 100644 index 0000000..bf00e8e --- /dev/null +++ b/tests/fixtures_documents.jsonl @@ -0,0 +1,3 @@ +{"schema_version": "1", "id": "RAC-KTQ63DPSMF19", "type": "decision", "status": "Accepted", "title": "ADR-001 Markdown First", "text": "# ADR-001 Markdown First\n\n## Status\n\nAccepted\n\n## Context\n\nRAC needs a single canonical source format for requirements and other artifacts.\n\n## Decision\n\nMarkdown is the canonical source format.\n\n## Consequences\n\n### Positive\n\n- Human-readable\n- Git-friendly\n- Tool-independent\n", "metadata": {"path": "rac/decisions/adr-001-markdown-first.md", "aliases": ["RAC-KTQ63DPSMF19", "adr-001", "adr-001-markdown-first"], "tags": [], "source": "rac"}} +{"schema_version": "1", "id": "RAC-KTQ63DPT6008", "type": "decision", "status": "Accepted", "title": "ADR-002 AI Optional", "text": "# ADR-002 AI Optional\n\n## Status\n\nAccepted\n\n## Context\n\nRAC should be useful on its own, with AI as an enhancement rather than a dependency.\n\n## Decision\n\nRAC must provide value without AI.\n\n## Consequences\n\n### Positive\n\n- Lower complexity\n- Easier testing\n- Works offline\n", "metadata": {"path": "rac/decisions/adr-002-ai-optional.md", "aliases": ["RAC-KTQ63DPT6008", "adr-002", "adr-002-ai-optional"], "tags": [], "source": "rac"}} +{"schema_version": "1", "id": "RAC-KTQ63DPVVB37", "type": "decision", "status": "Accepted", "title": "ADR-003 Structured Outputs First", "text": "# ADR-003 Structured Outputs First\n\n## Status\n\nAccepted\n\n## Context\n\nRAC commands need to be consumable by scripts, SDKs, and future integrations — not just humans.\n\n## Decision\n\nEvery command returns typed result models.\n\n## Consequences\n\n### Positive\n\n- JSON output becomes trivial\n- SDK usage becomes trivial\n- MCP integration becomes trivial\n- CI integration becomes trivial\n", "metadata": {"path": "rac/decisions/adr-003-structured-outputs-first.md", "aliases": ["RAC-KTQ63DPVVB37", "adr-003", "adr-003-structured-outputs-first"], "tags": [], "source": "rac"}} diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..94e07fc --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,67 @@ +"""End-to-end CLI behaviour driven through stdin, against a dry run / fake.""" + +from __future__ import annotations + +import io + +import pytest + +from rac_connectors import cli + + +def _line(record_id: str = "RAC-1") -> str: + return ( + f'{{"schema_version":"1","id":"{record_id}","type":"decision",' + f'"status":"Accepted","title":"t","text":"body",' + f'"metadata":{{"source":"rac"}}}}' + ) + + +def test_dry_run_prints_actions_and_makes_no_call(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["supermemory", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr() + assert "push RAC-1" in out.out + assert "1 pushed, 0 skipped" in out.err + + +def test_malformed_line_skipped_by_default(monkeypatch, capsys) -> None: + stream = _line("RAC-1") + "\n" + "garbage{\n" + _line("RAC-2") + "\n" + monkeypatch.setattr("sys.stdin", io.StringIO(stream)) + rc = cli.main(["supermemory", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr() + assert "push RAC-1" in out.out + assert "push RAC-2" in out.out + assert "skip line 2" in out.out + assert "2 pushed, 1 skipped" in out.err + + +def test_strict_mode_fails_on_malformed_line(monkeypatch, capsys) -> None: + stream = _line("RAC-1") + "\n" + "garbage{\n" + monkeypatch.setattr("sys.stdin", io.StringIO(stream)) + rc = cli.main(["supermemory", "--dry-run", "--strict"]) + assert rc == 1 + assert "error:" in capsys.readouterr().err + + +def test_live_push_without_api_key_errors(monkeypatch, capsys) -> None: + monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False) + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["supermemory"]) + assert rc == 2 + assert "SUPERMEMORY_API_KEY" in capsys.readouterr().err + + +def test_input_file_is_read(tmp_path, capsys) -> None: + path = tmp_path / "docs.jsonl" + path.write_text(_line("RAC-9") + "\n", encoding="utf-8") + rc = cli.main(["supermemory", "--dry-run", "--input", str(path)]) + assert rc == 0 + assert "push RAC-9" in capsys.readouterr().out + + +def test_no_backend_is_an_error(capsys) -> None: + with pytest.raises(SystemExit): + cli.main([]) diff --git a/tests/test_cli_cognee.py b/tests/test_cli_cognee.py new file mode 100644 index 0000000..1c3cd66 --- /dev/null +++ b/tests/test_cli_cognee.py @@ -0,0 +1,48 @@ +"""End-to-end CLI behaviour for the `rac-connect cognee` subcommand.""" + +from __future__ import annotations + +import io + +from rac_connectors import cli + + +def _line(record_id: str = "RAC-1") -> str: + return ( + f'{{"schema_version":"1","id":"{record_id}","type":"decision",' + f'"status":"Accepted","title":"t","text":"body",' + f'"metadata":{{"source":"rac"}}}}' + ) + + +def test_dry_run_prints_actions_and_runs_nothing(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["cognee", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr() + assert "push RAC-1" in out.out + assert "dataset=rac" in out.out + assert "1 pushed, 0 skipped" in out.err + + +def test_malformed_line_skipped_by_default(monkeypatch, capsys) -> None: + stream = _line("RAC-1") + "\n" + "garbage{\n" + _line("RAC-2") + "\n" + monkeypatch.setattr("sys.stdin", io.StringIO(stream)) + rc = cli.main(["cognee", "--dry-run"]) + assert rc == 0 + assert "2 pushed, 1 skipped" in capsys.readouterr().err + + +def test_strict_mode_fails_on_malformed_line(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n" + "garbage{\n")) + rc = cli.main(["cognee", "--dry-run", "--strict"]) + assert rc == 1 + assert "error:" in capsys.readouterr().err + + +def test_live_push_without_llm_key_errors(monkeypatch, capsys) -> None: + monkeypatch.delenv("LLM_API_KEY", raising=False) + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["cognee"]) + assert rc == 2 + assert "LLM_API_KEY" in capsys.readouterr().err diff --git a/tests/test_cli_letta.py b/tests/test_cli_letta.py new file mode 100644 index 0000000..ccb0a89 --- /dev/null +++ b/tests/test_cli_letta.py @@ -0,0 +1,49 @@ +"""End-to-end CLI behaviour for the `rac-connect letta` subcommand.""" + +from __future__ import annotations + +import io + +from rac_connectors import cli + + +def _line(record_id: str = "RAC-1") -> str: + return ( + f'{{"schema_version":"1","id":"{record_id}","type":"decision",' + f'"status":"Accepted","title":"t","text":"body",' + f'"metadata":{{"source":"rac"}}}}' + ) + + +def test_dry_run_prints_actions_and_makes_no_call(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["letta", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr() + assert "push RAC-1" in out.out + assert "archive=rac" in out.out + assert "1 pushed, 0 skipped" in out.err + + +def test_malformed_line_skipped_by_default(monkeypatch, capsys) -> None: + stream = _line("RAC-1") + "\n" + "garbage{\n" + _line("RAC-2") + "\n" + monkeypatch.setattr("sys.stdin", io.StringIO(stream)) + rc = cli.main(["letta", "--dry-run"]) + assert rc == 0 + assert "2 pushed, 1 skipped" in capsys.readouterr().err + + +def test_strict_mode_fails_on_malformed_line(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n" + "garbage{\n")) + rc = cli.main(["letta", "--dry-run", "--strict"]) + assert rc == 1 + assert "error:" in capsys.readouterr().err + + +def test_live_push_without_credentials_errors(monkeypatch, capsys) -> None: + monkeypatch.delenv("LETTA_API_KEY", raising=False) + monkeypatch.delenv("LETTA_BASE_URL", raising=False) + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["letta"]) + assert rc == 2 + assert "LETTA_API_KEY" in capsys.readouterr().err diff --git a/tests/test_cli_mem0.py b/tests/test_cli_mem0.py new file mode 100644 index 0000000..804e1dd --- /dev/null +++ b/tests/test_cli_mem0.py @@ -0,0 +1,49 @@ +"""End-to-end CLI behaviour for the `rac-connect mem0` subcommand.""" + +from __future__ import annotations + +import io + +from rac_connectors import cli + + +def _line(record_id: str = "RAC-1") -> str: + return ( + f'{{"schema_version":"1","id":"{record_id}","type":"decision",' + f'"status":"Accepted","title":"t","text":"body",' + f'"metadata":{{"source":"rac"}}}}' + ) + + +def test_dry_run_prints_actions_and_makes_no_call(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["mem0", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr() + assert "push RAC-1" in out.out + assert "container=rac" in out.out + assert "1 pushed, 0 skipped" in out.err + + +def test_malformed_line_skipped_by_default(monkeypatch, capsys) -> None: + stream = _line("RAC-1") + "\n" + "garbage{\n" + _line("RAC-2") + "\n" + monkeypatch.setattr("sys.stdin", io.StringIO(stream)) + rc = cli.main(["mem0", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr() + assert "2 pushed, 1 skipped" in out.err + + +def test_strict_mode_fails_on_malformed_line(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n" + "garbage{\n")) + rc = cli.main(["mem0", "--dry-run", "--strict"]) + assert rc == 1 + assert "error:" in capsys.readouterr().err + + +def test_live_push_without_api_key_errors(monkeypatch, capsys) -> None: + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["mem0"]) + assert rc == 2 + assert "MEM0_API_KEY" in capsys.readouterr().err diff --git a/tests/test_cli_neo4j.py b/tests/test_cli_neo4j.py new file mode 100644 index 0000000..bd56866 --- /dev/null +++ b/tests/test_cli_neo4j.py @@ -0,0 +1,55 @@ +"""End-to-end CLI behaviour for the `rac-connect neo4j` subcommand.""" + +from __future__ import annotations + +import io +import json + +import pytest + +from rac_connectors import cli + +_GRAPH = { + "schema_version": "1", + "source": "rac", + "nodes": [{"id": "RAC-1", "type": "decision", "status": "Accepted", "title": "A"}], + "edges": [], +} + + +def test_dry_run_prints_actions_and_connects_to_nothing(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(_GRAPH))) + rc = cli.main(["neo4j", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr() + assert "push RAC-1: node" in out.out + assert "1 pushed, 0 skipped" in out.err + + +def test_malformed_graph_errors(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO("{not json")) + rc = cli.main(["neo4j", "--dry-run"]) + assert rc == 1 + assert "error:" in capsys.readouterr().err + + +def test_live_push_without_credentials_errors(monkeypatch, capsys) -> None: + for var in ("NEO4J_URI", "NEO4J_USERNAME", "NEO4J_PASSWORD"): + monkeypatch.delenv(var, raising=False) + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(_GRAPH))) + rc = cli.main(["neo4j"]) + assert rc == 2 + assert "NEO4J_URI" in capsys.readouterr().err + + +def test_input_file_is_read(tmp_path, capsys) -> None: + path = tmp_path / "graph.json" + path.write_text(json.dumps(_GRAPH), encoding="utf-8") + rc = cli.main(["neo4j", "--dry-run", "--input", str(path)]) + assert rc == 0 + assert "push RAC-1" in capsys.readouterr().out + + +def test_unknown_backend_is_an_error() -> None: + with pytest.raises(SystemExit): + cli.main(["nope"]) diff --git a/tests/test_cli_zep.py b/tests/test_cli_zep.py new file mode 100644 index 0000000..8ffce4a --- /dev/null +++ b/tests/test_cli_zep.py @@ -0,0 +1,48 @@ +"""End-to-end CLI behaviour for the `rac-connect zep` subcommand.""" + +from __future__ import annotations + +import io + +from rac_connectors import cli + + +def _line(record_id: str = "RAC-1") -> str: + return ( + f'{{"schema_version":"1","id":"{record_id}","type":"decision",' + f'"status":"Accepted","title":"t","text":"body",' + f'"metadata":{{"source":"rac"}}}}' + ) + + +def test_dry_run_prints_actions_and_makes_no_call(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["zep", "--dry-run"]) + assert rc == 0 + out = capsys.readouterr() + assert "push RAC-1" in out.out + assert "graph=rac" in out.out + assert "1 pushed, 0 skipped" in out.err + + +def test_malformed_line_skipped_by_default(monkeypatch, capsys) -> None: + stream = _line("RAC-1") + "\n" + "garbage{\n" + _line("RAC-2") + "\n" + monkeypatch.setattr("sys.stdin", io.StringIO(stream)) + rc = cli.main(["zep", "--dry-run"]) + assert rc == 0 + assert "2 pushed, 1 skipped" in capsys.readouterr().err + + +def test_strict_mode_fails_on_malformed_line(monkeypatch, capsys) -> None: + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n" + "garbage{\n")) + rc = cli.main(["zep", "--dry-run", "--strict"]) + assert rc == 1 + assert "error:" in capsys.readouterr().err + + +def test_live_push_without_api_key_errors(monkeypatch, capsys) -> None: + monkeypatch.delenv("ZEP_API_KEY", raising=False) + monkeypatch.setattr("sys.stdin", io.StringIO(_line() + "\n")) + rc = cli.main(["zep"]) + assert rc == 2 + assert "ZEP_API_KEY" in capsys.readouterr().err diff --git a/tests/test_cognee.py b/tests/test_cognee.py new file mode 100644 index 0000000..a946ce1 --- /dev/null +++ b/tests/test_cognee.py @@ -0,0 +1,102 @@ +"""The Cognee connector: stage-per-record, commit-once, provenance, dry-run.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from rac_connectors.cognee import DEFAULT_CONTAINER, CogneeConnector +from rac_connectors.records import Record + + +class FakeCogneeClient: + """In-memory stand-in: ``add`` stages a payload, ``commit`` flushes once. + + Emulates Cognee's content-hash dedup on commit so a test can assert that a + re-push of the same payloads does not grow the built store. + """ + + def __init__(self) -> None: + self.staged: list[dict[str, str]] = [] + self.commits = 0 + self.built: dict[str, set[str]] = {} + + def add(self, *, payload: str, container: str) -> None: + self.staged.append({"payload": payload, "container": container}) + + def commit(self) -> None: + self.commits += 1 + for item in self.staged: + self.built.setdefault(item["container"], set()).add(item["payload"]) + self.staged = [] + + +def _record(**overrides: Any) -> Record: + base = { + "id": "RAC-ABC", + "type": "decision", + "status": "Accepted", + "title": "ADR-001: Example", + "text": "## Context\n\nbody", + "metadata": {"path": "rac/decisions/adr-001.md", "source": "rac"}, + } + base.update(overrides) + return Record.from_dict(base) + + +def test_each_record_staged_then_committed_once() -> None: + client = FakeCogneeClient() + summary = CogneeConnector(client).push([_record(id="RAC-1"), _record(id="RAC-2")]) + + assert summary.pushed == 2 + assert client.commits == 1 # one graph build for the whole push + assert len(client.built["rac"]) == 2 + + +def test_payload_carries_provenance() -> None: + client = FakeCogneeClient() + CogneeConnector(client).push([_record()]) # push commits internally + payload = next(iter(client.built["rac"])) + assert "Rac-Id: RAC-ABC" in payload # the verify-in-Lore handle + assert "Status: Accepted" in payload + assert "## Context\n\nbody" in payload # the artifact text is preserved + + +def test_repush_dedups_by_content() -> None: + client = FakeCogneeClient() + connector = CogneeConnector(client) + connector.push([_record(id="RAC-1"), _record(id="RAC-2")]) + connector.push([_record(id="RAC-1"), _record(id="RAC-2")]) + # Same payloads — the built set stays at two (content-hash dedup). + assert len(client.built["rac"]) == 2 + assert client.commits == 2 + + +def test_dry_run_stages_nothing_and_does_not_commit() -> None: + client = FakeCogneeClient() + summary = CogneeConnector(client).push([_record()], dry_run=True) + assert summary.dry_run is True and summary.pushed == 1 + assert client.staged == [] and client.commits == 0 + + +def test_dry_run_needs_no_client() -> None: + assert CogneeConnector().push([_record()], dry_run=True).pushed == 1 + + +def test_empty_push_does_not_commit() -> None: + client = FakeCogneeClient() + summary = CogneeConnector(client).push([]) + assert summary.pushed == 0 + assert client.commits == 0 # nothing to build + + +def test_live_push_without_client_errors() -> None: + with pytest.raises(RuntimeError, match="required for a live push"): + CogneeConnector().push([_record()]) + + +def test_missing_source_uses_default_dataset() -> None: + client = FakeCogneeClient() + CogneeConnector(client).push([_record(metadata={"path": "x.md"})]) + assert DEFAULT_CONTAINER in client.built diff --git a/tests/test_cognee_client.py b/tests/test_cognee_client.py new file mode 100644 index 0000000..d9338a5 --- /dev/null +++ b/tests/test_cognee_client.py @@ -0,0 +1,73 @@ +"""The live adapter (`SdkCogneeClient`) against a stubbed async ``cognee``. + +CI runs offline, so this stubs the ``cognee`` module (async ``add`` / ``cognify``) +into ``sys.modules``. The stub mirrors the signature verified against cognee +1.2.0: ``await cognee.add(list, dataset_name=…, node_set=[…])`` then +``await cognee.cognify(datasets=[…])``. The adapter buffers and flushes on commit. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import pytest + +from rac_connectors.cognee.client import MissingCredentialsError, SdkCogneeClient + + +class _StubCognee(types.ModuleType): + def __init__(self) -> None: + super().__init__("cognee") + self.adds: list[dict[str, Any]] = [] + self.cognifies: list[dict[str, Any]] = [] + + async def add(self, data: Any, **kwargs: Any) -> None: + self.adds.append({"data": data, **kwargs}) + + async def cognify(self, **kwargs: Any) -> None: + self.cognifies.append(kwargs) + + +@pytest.fixture +def stub_cognee(monkeypatch: pytest.MonkeyPatch) -> _StubCognee: + monkeypatch.setenv("LLM_API_KEY", "sk-test") + module = _StubCognee() + monkeypatch.setitem(sys.modules, "cognee", module) + return module + + +def test_commit_runs_add_then_cognify_per_dataset(stub_cognee: _StubCognee) -> None: + client = SdkCogneeClient() + client.add(payload="doc-a", container="rac") + client.add(payload="doc-b", container="rac") + client.commit() + + # One batched add (both payloads) and one cognify for the dataset. + assert len(stub_cognee.adds) == 1 + add_call = stub_cognee.adds[0] + assert add_call["data"] == ["doc-a", "doc-b"] + assert add_call["dataset_name"] == "rac" + assert add_call["node_set"] == ["rac"] + assert stub_cognee.cognifies[0]["datasets"] == ["rac"] + + +def test_commit_is_noop_when_nothing_staged(stub_cognee: _StubCognee) -> None: + client = SdkCogneeClient() + client.commit() + assert stub_cognee.adds == [] and stub_cognee.cognifies == [] + + +def test_commit_clears_buffer(stub_cognee: _StubCognee) -> None: + client = SdkCogneeClient() + client.add(payload="doc-a", container="rac") + client.commit() + client.commit() # second commit has nothing left to flush + assert len(stub_cognee.adds) == 1 + + +def test_missing_llm_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LLM_API_KEY", raising=False) + with pytest.raises(MissingCredentialsError): + SdkCogneeClient() diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..03fd9b1 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,80 @@ +"""The export-contract version guard (the real cross-repo dependency, ADR-008).""" + +from __future__ import annotations + +import json + +import pytest + +from rac_connectors import ( + SUPPORTED_CONTRACT_VERSION, + ContractVersionWarning, + check_contract_version, + parse_documents, + parse_graph, +) + + +def _doc(schema_version: str) -> str: + return json.dumps( + { + "schema_version": schema_version, + "id": "RAC-1", + "type": "decision", + "status": "Accepted", + "title": "t", + "text": "body", + "metadata": {"source": "rac"}, + } + ) + + +def _graph(schema_version: str) -> str: + return json.dumps( + {"schema_version": schema_version, "source": "rac", "nodes": [], "edges": []} + ) + + +def test_supported_version_is_one() -> None: + assert SUPPORTED_CONTRACT_VERSION == "1" + + +def test_check_is_silent_on_supported_major() -> None: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") # any warning would fail the test + check_contract_version("1") + + +def test_check_warns_on_different_major() -> None: + with pytest.warns(ContractVersionWarning, match="schema_version '2'"): + check_contract_version("2") + + +def test_documents_reader_warns_on_unknown_version() -> None: + with pytest.warns(ContractVersionWarning): + list(parse_documents([_doc("2")])) + + +def test_documents_reader_silent_on_version_one() -> None: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") + records = list(parse_documents([_doc("1")])) + assert [r.id for r in records] == ["RAC-1"] + + +def test_graph_reader_warns_on_unknown_version() -> None: + with pytest.warns(ContractVersionWarning): + parse_graph(_graph("2")) + + +def test_graph_reader_silent_on_version_one() -> None: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error") + graph = parse_graph(_graph("1")) + assert graph.source == "rac" diff --git a/tests/test_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..de32e13 --- /dev/null +++ b/tests/test_graph.py @@ -0,0 +1,104 @@ +"""Parsing the ``rac export --graph`` contract into a typed graph.""" + +from __future__ import annotations + +import json + +import pytest + +from rac_connectors.graph import ( + Graph, + GraphEdge, + GraphNode, + MalformedGraphError, + parse_graph, +) + +_GRAPH = { + "schema_version": "1", + "source": "rac", + "nodes": [ + {"id": "RAC-1", "type": "decision", "status": "Accepted", "title": "A"}, + {"id": "RAC-2", "type": "design", "status": "Proposed", "title": "B"}, + ], + "edges": [ + { + "source": "RAC-1", + "target": "RAC-2", + "type": "related_designs", + "directed": False, + "resolved": True, + }, + { + "source": "RAC-2", + "target": "adr-999", + "type": "related_decisions", + "directed": False, + "resolved": False, + }, + ], +} + + +def test_parses_nodes_and_edges() -> None: + graph = parse_graph(json.dumps(_GRAPH)) + assert isinstance(graph, Graph) + assert graph.source == "rac" + assert graph.schema_version == "1" + assert [n.id for n in graph.nodes] == ["RAC-1", "RAC-2"] + assert graph.nodes[0] == GraphNode("RAC-1", "decision", "Accepted", "A") + first = graph.edges[0] + assert first == GraphEdge("RAC-1", "RAC-2", "related_designs", False, True) + + +def test_unresolved_edge_keeps_literal_target() -> None: + graph = parse_graph(json.dumps(_GRAPH)) + unresolved = graph.edges[1] + assert unresolved.resolved is False + assert unresolved.target == "adr-999" # literal reference, not a canonical id + + +def test_edge_flags_default_when_absent() -> None: + payload = { + "source": "rac", + "nodes": [], + "edges": [{"source": "A", "target": "B", "type": "supersedes"}], + } + edge = parse_graph(json.dumps(payload)).edges[0] + assert edge.directed is True # additive-tolerant defaults + assert edge.resolved is True + + +def test_empty_payload_is_malformed() -> None: + with pytest.raises(MalformedGraphError, match="empty"): + parse_graph(" ") + + +def test_non_object_payload_is_malformed() -> None: + with pytest.raises(MalformedGraphError, match="not a JSON object"): + parse_graph("[1, 2, 3]") + + +def test_invalid_json_is_malformed() -> None: + with pytest.raises(MalformedGraphError, match="invalid JSON"): + parse_graph("{not json") + + +def test_missing_source_is_malformed() -> None: + with pytest.raises(MalformedGraphError, match="source"): + parse_graph(json.dumps({"nodes": [], "edges": []})) + + +def test_node_missing_field_is_malformed() -> None: + payload = { + "source": "rac", + "edges": [], + "nodes": [{"id": "RAC-1", "type": "decision", "status": "Accepted"}], + } + with pytest.raises(MalformedGraphError, match="title"): + parse_graph(json.dumps(payload)) + + +def test_nodes_not_a_list_is_malformed() -> None: + with pytest.raises(MalformedGraphError, match="arrays"): + parse_graph(json.dumps({"source": "rac", "nodes": {}, "edges": []})) diff --git a/tests/test_letta.py b/tests/test_letta.py new file mode 100644 index 0000000..d4ebe5d --- /dev/null +++ b/tests/test_letta.py @@ -0,0 +1,91 @@ +"""The Letta connector: record->passage mapping, archive-resync idempotency.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from rac_connectors.letta import DEFAULT_CONTAINER, LettaConnector +from rac_connectors.records import Record + + +class FakeLettaClient: + """In-memory stand-in: ``clear_container`` wipes an archive, ``add`` appends.""" + + def __init__(self) -> None: + self.adds: list[dict[str, Any]] = [] + self.clears: list[str] = [] + self.store: dict[str, list[dict[str, Any]]] = {} + + def clear_container(self, *, container: str) -> None: + self.clears.append(container) + self.store[container] = [] + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: + call = {"text": text, "container": container, "metadata": metadata} + self.adds.append(call) + self.store.setdefault(container, []).append(call) + + +def _record(**overrides: Any) -> Record: + base = { + "id": "RAC-ABC", + "type": "decision", + "status": "Accepted", + "title": "ADR-001: Example", + "text": "## Context\n\nbody", + "metadata": {"path": "rac/decisions/adr-001.md", "source": "rac"}, + } + base.update(overrides) + return Record.from_dict(base) + + +def test_record_maps_to_add_with_metadata() -> None: + client = FakeLettaClient() + summary = LettaConnector(client).push([_record()]) + + assert summary.pushed == 1 + call = client.adds[0] + assert call["text"] == "## Context\n\nbody" + assert call["container"] == "rac" # source maps to the Letta archive + assert call["metadata"]["rac_id"] == "RAC-ABC" + assert call["metadata"]["status"] == "Accepted" + + +def test_archive_cleared_once_before_adds() -> None: + client = FakeLettaClient() + LettaConnector(client).push([_record(id="RAC-1"), _record(id="RAC-2")]) + assert client.clears == ["rac"] + assert len(client.adds) == 2 + + +def test_repush_is_idempotent_via_resync() -> None: + client = FakeLettaClient() + connector = LettaConnector(client) + connector.push([_record(id="RAC-1"), _record(id="RAC-2")]) + connector.push([_record(id="RAC-1"), _record(id="RAC-2")]) + assert len(client.store["rac"]) == 2 + assert client.clears == ["rac", "rac"] + + +def test_dry_run_makes_no_calls() -> None: + client = FakeLettaClient() + summary = LettaConnector(client).push([_record()], dry_run=True) + assert summary.dry_run is True and summary.pushed == 1 + assert client.adds == [] and client.clears == [] + + +def test_dry_run_needs_no_client() -> None: + assert LettaConnector().push([_record()], dry_run=True).pushed == 1 + + +def test_live_push_without_client_errors() -> None: + with pytest.raises(RuntimeError, match="required for a live push"): + LettaConnector().push([_record()]) + + +def test_missing_source_uses_default_container() -> None: + client = FakeLettaClient() + LettaConnector(client).push([_record(metadata={"path": "x.md"})]) + assert client.adds[0]["container"] == DEFAULT_CONTAINER diff --git a/tests/test_letta_client.py b/tests/test_letta_client.py new file mode 100644 index 0000000..93b6980 --- /dev/null +++ b/tests/test_letta_client.py @@ -0,0 +1,114 @@ +"""The live SDK adapter (`SdkLettaClient`) against a stubbed ``letta_client``. + +CI runs offline, so this stubs ``letta_client.Letta`` into ``sys.modules``. The +stub mirrors the signature verified against letta-client 1.12.1: archives.list +(by name) / create (-> object with .id) / delete, and +archives.passages.create(archive_id, text=, metadata=). The adapter resolves the +opaque archive_id from the container name, which the stub exercises. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import pytest + +from rac_connectors.letta.client import MissingCredentialsError, SdkLettaClient + + +class _Archive: + def __init__(self, archive_id: str, name: str) -> None: + self.id = archive_id + self.name = name + + +class _Passages: + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + + def create(self, archive_id: str, **kwargs: Any) -> None: + self.created.append({"archive_id": archive_id, **kwargs}) + + +class _Archives: + def __init__(self) -> None: + self._by_name: dict[str, _Archive] = {} + self.deleted: list[str] = [] + self.passages = _Passages() + self._counter = 0 + + def list(self, *, name: str) -> list[_Archive]: + return [a for a in self._by_name.values() if a.name == name] + + def create(self, *, name: str) -> _Archive: + self._counter += 1 + archive = _Archive(f"arch-{self._counter}", name) + self._by_name[name] = archive + return archive + + def delete(self, archive_id: str) -> None: + self.deleted.append(archive_id) + self._by_name = {n: a for n, a in self._by_name.items() if a.id != archive_id} + + +class _StubLetta: + last: _StubLetta | None = None + + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + self.archives = _Archives() + _StubLetta.last = self + + +@pytest.fixture +def stub_letta(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType("letta_client") + module.Letta = _StubLetta # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "letta_client", module) + _StubLetta.last = None + + +def test_clear_creates_archive_and_add_uses_its_id(stub_letta: None) -> None: + client = SdkLettaClient(api_key="k") + client.clear_container(container="rac") + client.add(text="body", container="rac", metadata={"rac_id": "RAC-1"}) + + archives = _StubLetta.last.archives + passage = archives.passages.created[0] + # The passage was written to the freshly-created archive's id. + assert passage["archive_id"] == "arch-1" + assert passage["text"] == "body" + assert passage["metadata"] == {"rac_id": "RAC-1"} + + +def test_clear_deletes_existing_archive_of_same_name(stub_letta: None) -> None: + client = SdkLettaClient(api_key="k") + client.clear_container(container="rac") # creates arch-1 + client.clear_container(container="rac") # must delete arch-1, create arch-2 + archives = _StubLetta.last.archives + assert "arch-1" in archives.deleted + assert client._archive_ids["rac"] == "arch-2" + + +def test_api_key_passed_to_client(stub_letta: None) -> None: + client = SdkLettaClient(api_key="k-123") + client.clear_container(container="rac") + assert _StubLetta.last.kwargs == {"api_key": "k-123"} + + +def test_base_url_only_is_allowed( + monkeypatch: pytest.MonkeyPatch, stub_letta: None +) -> None: + monkeypatch.delenv("LETTA_API_KEY", raising=False) + client = SdkLettaClient(base_url="http://localhost:8283") + client.clear_container(container="rac") + assert _StubLetta.last.kwargs == {"base_url": "http://localhost:8283"} + + +def test_missing_credentials_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("LETTA_API_KEY", raising=False) + monkeypatch.delenv("LETTA_BASE_URL", raising=False) + with pytest.raises(MissingCredentialsError): + SdkLettaClient() diff --git a/tests/test_mem0.py b/tests/test_mem0.py new file mode 100644 index 0000000..716ca1a --- /dev/null +++ b/tests/test_mem0.py @@ -0,0 +1,104 @@ +"""The Mem0 connector: record->add mapping, container-resync idempotency, dry-run.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from rac_connectors.mem0 import DEFAULT_CONTAINER, Mem0Connector +from rac_connectors.records import Record + + +class FakeMem0Client: + """In-memory stand-in for the Mem0 client. + + Emulates a partitioned store: ``clear_container`` wipes a partition and + ``add`` appends to it, so a test can assert that a re-push (which clears then + re-adds) leaves no duplicates. + """ + + def __init__(self) -> None: + self.adds: list[dict[str, Any]] = [] + self.clears: list[str] = [] + self.store: dict[str, list[dict[str, Any]]] = {} + + def clear_container(self, *, container: str) -> None: + self.clears.append(container) + self.store[container] = [] + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: + call = {"text": text, "container": container, "metadata": metadata} + self.adds.append(call) + self.store.setdefault(container, []).append(call) + + +def _record(**overrides: Any) -> Record: + base = { + "id": "RAC-ABC", + "type": "decision", + "status": "Accepted", + "title": "ADR-001: Example", + "text": "## Context\n\nbody", + "metadata": {"path": "rac/decisions/adr-001.md", "source": "rac"}, + } + base.update(overrides) + return Record.from_dict(base) + + +def test_record_maps_to_add_with_metadata() -> None: + client = FakeMem0Client() + summary = Mem0Connector(client).push([_record()]) + + assert summary.pushed == 1 + call = client.adds[0] + assert call["text"] == "## Context\n\nbody" + assert call["container"] == "rac" # source maps to the Mem0 partition + assert call["metadata"]["rac_id"] == "RAC-ABC" # the verify-in-Lore handle + assert call["metadata"]["status"] == "Accepted" + assert call["metadata"]["path"] == "rac/decisions/adr-001.md" + + +def test_container_cleared_once_before_adds() -> None: + client = FakeMem0Client() + Mem0Connector(client).push([_record(id="RAC-1"), _record(id="RAC-2")]) + # One clear for the partition, then both adds — not a clear per record. + assert client.clears == ["rac"] + assert len(client.adds) == 2 + + +def test_repush_is_idempotent_via_resync() -> None: + client = FakeMem0Client() + connector = Mem0Connector(client) + + connector.push([_record(id="RAC-1"), _record(id="RAC-2")]) + connector.push([_record(id="RAC-1"), _record(id="RAC-2")]) + + # The partition was cleared each push, so it holds exactly two memories. + assert len(client.store["rac"]) == 2 + assert client.clears == ["rac", "rac"] + + +def test_dry_run_makes_no_calls() -> None: + client = FakeMem0Client() + summary = Mem0Connector(client).push([_record()], dry_run=True) + + assert summary.dry_run is True + assert summary.pushed == 1 + assert client.adds == [] and client.clears == [] # nothing touched + + +def test_dry_run_needs_no_client() -> None: + summary = Mem0Connector().push([_record()], dry_run=True) + assert summary.pushed == 1 + + +def test_live_push_without_client_errors() -> None: + with pytest.raises(RuntimeError, match="required for a live push"): + Mem0Connector().push([_record()]) + + +def test_missing_source_uses_default_container() -> None: + client = FakeMem0Client() + Mem0Connector(client).push([_record(metadata={"path": "x.md"})]) + assert client.adds[0]["container"] == DEFAULT_CONTAINER diff --git a/tests/test_mem0_client.py b/tests/test_mem0_client.py new file mode 100644 index 0000000..28b4dcb --- /dev/null +++ b/tests/test_mem0_client.py @@ -0,0 +1,80 @@ +"""The live SDK adapter (`SdkMem0Client`) against a stubbed ``mem0``. + +CI runs offline, so this stubs ``mem0.MemoryClient`` into ``sys.modules`` rather +than importing the real SDK. The stub mirrors the signature verified against +mem0ai 2.0.7: ``MemoryClient(api_key=...)`` with ``.add(messages, user_id=, +metadata=, infer=)`` and ``.delete_all(user_id=)``. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import pytest + +from rac_connectors.mem0.client import MissingApiKeyError, SdkMem0Client + + +class _StubMemoryClient: + last: _StubMemoryClient | None = None + + def __init__(self, api_key: str | None = None) -> None: + self.api_key = api_key + self.adds: list[dict[str, Any]] = [] + self.deletes: list[dict[str, Any]] = [] + _StubMemoryClient.last = self + + def add(self, messages: Any, **kwargs: Any) -> dict[str, Any]: + self.adds.append({"messages": messages, **kwargs}) + return {} + + def delete_all(self, **kwargs: Any) -> dict[str, Any]: + self.deletes.append(kwargs) + return {} + + +@pytest.fixture +def stub_mem0(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType("mem0") + module.MemoryClient = _StubMemoryClient # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "mem0", module) + _StubMemoryClient.last = None + + +def test_add_maps_to_sdk_with_infer_false(stub_mem0: None) -> None: + client = SdkMem0Client(api_key="k") + client.add(text="body", container="rac", metadata={"rac_id": "RAC-1"}) + call = _StubMemoryClient.last.adds[0] + assert call["messages"] == "body" + assert call["user_id"] == "rac" # container maps to the user_id partition + assert call["metadata"] == {"rac_id": "RAC-1"} + assert call["infer"] is False # store as-is; no LLM rewrite + + +def test_clear_container_deletes_the_partition(stub_mem0: None) -> None: + client = SdkMem0Client(api_key="k") + client.clear_container(container="rac") + assert _StubMemoryClient.last.deletes[0] == {"user_id": "rac"} + + +def test_api_key_passed_to_client(stub_mem0: None) -> None: + client = SdkMem0Client(api_key="k-123") + client.add(text="b", container="rac", metadata={}) + assert _StubMemoryClient.last.api_key == "k-123" + + +def test_api_key_read_from_env( + monkeypatch: pytest.MonkeyPatch, stub_mem0: None +) -> None: + monkeypatch.setenv("MEM0_API_KEY", "from-env") + client = SdkMem0Client() + client.add(text="b", container="rac", metadata={}) + assert _StubMemoryClient.last.api_key == "from-env" + + +def test_missing_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MEM0_API_KEY", raising=False) + with pytest.raises(MissingApiKeyError): + SdkMem0Client() diff --git a/tests/test_neo4j.py b/tests/test_neo4j.py new file mode 100644 index 0000000..98f00c9 --- /dev/null +++ b/tests/test_neo4j.py @@ -0,0 +1,117 @@ +"""The Neo4j connector: node/edge MERGE mapping, idempotency, dry-run.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from rac_connectors.graph import Graph, GraphEdge, GraphNode +from rac_connectors.neo4j import Neo4jConnector + + +class FakeNeo4jClient: + """In-memory stand-in for the Neo4j driver. + + Records every statement, and emulates MERGE by keying nodes on ``id`` and + relationships on ``(source, type, target)`` so a test can assert that a + re-push updates in place rather than duplicating. + """ + + def __init__(self) -> None: + self.statements: list[tuple[str, dict[str, Any]]] = [] + self.nodes: dict[str, dict[str, Any]] = {} + self.rels: dict[tuple[str, str, str], dict[str, Any]] = {} + self.closed = False + + def run(self, cypher: str, parameters: dict[str, Any]) -> None: + self.statements.append((cypher, parameters)) + if cypher.startswith("MERGE (n:Artifact"): + self.nodes[parameters["id"]] = dict(parameters) + else: # edge MERGE + key = (parameters["source"], parameters["type"], parameters["target"]) + self.rels[key] = dict(parameters) + + def close(self) -> None: + self.closed = True + + +def _graph() -> Graph: + return Graph( + source="rac", + nodes=[ + GraphNode("RAC-1", "decision", "Accepted", "A"), + GraphNode("RAC-2", "design", "Proposed", "B"), + ], + edges=[ + GraphEdge("RAC-1", "RAC-2", "related_designs", False, True), + ], + ) + + +def test_nodes_and_edges_merge_with_parameters() -> None: + client = FakeNeo4jClient() + summary = Neo4jConnector(client).push_graph(_graph()) + + assert summary.pushed == 3 # 2 nodes + 1 edge + assert client.nodes["RAC-1"] == { + "id": "RAC-1", + "type": "decision", + "status": "Accepted", + "title": "A", + "source": "rac", + } + rel = client.rels[("RAC-1", "related_designs", "RAC-2")] + assert rel["directed"] is False + # Every statement is parameterised — no corpus content interpolated as Cypher. + for cypher, params in client.statements: + assert "$" in cypher and params + + +def test_repush_is_idempotent() -> None: + client = FakeNeo4jClient() + connector = Neo4jConnector(client) + + connector.push_graph(_graph()) + # Re-export with an edited node title, same ids. + edited = _graph() + edited.nodes[0] = GraphNode("RAC-1", "decision", "Accepted", "A (edited)") + connector.push_graph(edited) + + assert len(client.nodes) == 2 # not duplicated + assert len(client.rels) == 1 + assert client.nodes["RAC-1"]["title"] == "A (edited)" # updated in place + + +def test_unresolved_edges_are_skipped() -> None: + client = FakeNeo4jClient() + graph = Graph( + source="rac", + nodes=[GraphNode("RAC-1", "decision", "Accepted", "A")], + edges=[GraphEdge("RAC-1", "adr-999", "related_decisions", False, False)], + ) + summary = Neo4jConnector(client).push_graph(graph) + + assert summary.pushed == 1 # the node only + assert summary.skipped == 1 # the unresolved edge + assert client.rels == {} # never written — no phantom target node + assert "unresolved" in " ".join(summary.actions) + + +def test_dry_run_writes_nothing() -> None: + client = FakeNeo4jClient() + summary = Neo4jConnector(client).push_graph(_graph(), dry_run=True) + + assert summary.dry_run is True + assert summary.pushed == 3 + assert client.statements == [] # nothing sent to the database + + +def test_dry_run_needs_no_client() -> None: + summary = Neo4jConnector().push_graph(_graph(), dry_run=True) + assert summary.pushed == 3 + + +def test_live_push_without_client_errors() -> None: + with pytest.raises(RuntimeError, match="required for a live push"): + Neo4jConnector().push_graph(_graph()) diff --git a/tests/test_neo4j_client.py b/tests/test_neo4j_client.py new file mode 100644 index 0000000..dec6072 --- /dev/null +++ b/tests/test_neo4j_client.py @@ -0,0 +1,115 @@ +"""The live driver adapter (`DriverNeo4jClient`) against a stubbed ``neo4j``. + +CI runs offline, so this stubs ``neo4j.GraphDatabase`` into ``sys.modules`` rather +than importing the real driver. The stub mirrors the official driver's shape: +``GraphDatabase.driver(uri, auth=...)`` -> a driver with ``.session()`` (a context +manager exposing ``.run(cypher, params)``) and ``.close()``. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import pytest + +from rac_connectors.neo4j.client import DriverNeo4jClient, MissingCredentialsError + + +class _StubSession: + def __init__(self, driver: _StubDriver) -> None: + self._driver = driver + + def __enter__(self) -> _StubSession: + return self + + def __exit__(self, *exc: object) -> None: + return None + + def run(self, cypher: str, parameters: dict[str, Any]) -> None: + self._driver.runs.append((cypher, parameters)) + + +class _StubDriver: + def __init__(self, uri: str, auth: tuple[str, str]) -> None: + self.uri = uri + self.auth = auth + self.runs: list[tuple[str, dict[str, Any]]] = [] + self.session_kwargs: list[dict[str, Any]] = [] + self.closed = False + + def session(self, **kwargs: Any) -> _StubSession: + self.session_kwargs.append(kwargs) + return _StubSession(self) + + def close(self) -> None: + self.closed = True + + +class _StubGraphDatabase: + last: _StubDriver | None = None + + @staticmethod + def driver(uri: str, auth: tuple[str, str]) -> _StubDriver: + _StubGraphDatabase.last = _StubDriver(uri, auth) + return _StubGraphDatabase.last + + +@pytest.fixture +def stub_neo4j(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType("neo4j") + module.GraphDatabase = _StubGraphDatabase # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "neo4j", module) + _StubGraphDatabase.last = None + + +def test_adapter_constructs_driver_with_auth(stub_neo4j: None) -> None: + client = DriverNeo4jClient(uri="bolt://h:7687", user="neo4j", password="secret") + client.run("MERGE (n:Artifact {id: $id})", {"id": "RAC-1"}) + driver = _StubGraphDatabase.last + assert driver is not None + assert driver.uri == "bolt://h:7687" + assert driver.auth == ("neo4j", "secret") + assert driver.runs[0] == ("MERGE (n:Artifact {id: $id})", {"id": "RAC-1"}) + + +def test_database_is_forwarded_to_session(stub_neo4j: None) -> None: + client = DriverNeo4jClient(uri="bolt://h", user="u", password="p", database="lore") + client.run("RETURN 1", {}) + assert _StubGraphDatabase.last.session_kwargs[0] == {"database": "lore"} + + +def test_no_database_means_default_session(stub_neo4j: None) -> None: + client = DriverNeo4jClient(uri="bolt://h", user="u", password="p") + client.run("RETURN 1", {}) + assert _StubGraphDatabase.last.session_kwargs[0] == {} + + +def test_credentials_read_from_env( + monkeypatch: pytest.MonkeyPatch, stub_neo4j: None +) -> None: + monkeypatch.setenv("NEO4J_URI", "bolt://env") + monkeypatch.setenv("NEO4J_USERNAME", "envuser") + monkeypatch.setenv("NEO4J_PASSWORD", "envpass") + client = DriverNeo4jClient() + client.run("RETURN 1", {}) + assert _StubGraphDatabase.last.auth == ("envuser", "envpass") + + +def test_close_closes_the_driver(stub_neo4j: None) -> None: + client = DriverNeo4jClient(uri="bolt://h", user="u", password="p") + client.run("RETURN 1", {}) + client.close() + assert _StubGraphDatabase.last.closed is True + + +@pytest.mark.parametrize("missing", ["NEO4J_URI", "NEO4J_USERNAME", "NEO4J_PASSWORD"]) +def test_missing_any_credential_raises( + monkeypatch: pytest.MonkeyPatch, missing: str +) -> None: + for var in ("NEO4J_URI", "NEO4J_USERNAME", "NEO4J_PASSWORD"): + monkeypatch.setenv(var, "x") + monkeypatch.delenv(missing, raising=False) + with pytest.raises(MissingCredentialsError): + DriverNeo4jClient() diff --git a/tests/test_records.py b/tests/test_records.py new file mode 100644 index 0000000..9eb12ae --- /dev/null +++ b/tests/test_records.py @@ -0,0 +1,80 @@ +"""Parsing the ``rac export --documents`` contract into records.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from rac_connectors.records import ( + MalformedRecordError, + Record, + parse_documents, +) + +FIXTURE = Path(__file__).parent / "fixtures_documents.jsonl" + + +def test_parses_real_export_fixture() -> None: + lines = FIXTURE.read_text(encoding="utf-8").splitlines() + records = list(parse_documents(lines)) + assert len(records) == 3 + first = records[0] + assert first.id.startswith("RAC-") + assert first.type == "decision" + assert first.source == "rac" # from metadata.source + assert first.text # Markdown body, not empty + + +def test_blank_lines_are_skipped() -> None: + lines = [ + '{"id":"RAC-1","type":"decision","status":"Accepted",' + '"title":"t","text":"body"}', + "", + " ", + ] + records = list(parse_documents(lines)) + assert [r.id for r in records] == ["RAC-1"] + + +def test_malformed_json_skipped_by_default_raises_in_strict() -> None: + lines = ["not json at all"] + assert list(parse_documents(lines)) == [] + with pytest.raises(MalformedRecordError) as exc: + list(parse_documents(lines, strict=True)) + assert exc.value.line_number == 1 + + +def test_missing_required_field_is_malformed() -> None: + # No 'text' field — recognisable JSON, but not a valid record. + lines = ['{"id":"RAC-1","type":"decision","status":"Accepted","title":"t"}'] + assert list(parse_documents(lines)) == [] + with pytest.raises(MalformedRecordError): + list(parse_documents(lines, strict=True)) + + +def test_empty_id_is_rejected() -> None: + with pytest.raises(ValueError, match="non-empty"): + Record.from_dict( + { + "id": "", + "type": "decision", + "status": "Accepted", + "title": "t", + "text": "b", + } + ) + + +def test_metadata_defaults_and_source_typing() -> None: + record = Record.from_dict( + { + "id": "RAC-1", + "type": "decision", + "status": "Accepted", + "title": "t", + "text": "b", + } + ) + assert record.metadata == {} + assert record.source is None # absent source is None, not a crash diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py new file mode 100644 index 0000000..2ed46f2 --- /dev/null +++ b/tests/test_sdk_client.py @@ -0,0 +1,101 @@ +"""The live SDK adapter (`SdkSupermemoryClient`) translates to the real +``supermemory`` call shape. + +CI runs offline, so this stubs ``supermemory.Supermemory`` into ``sys.modules`` +rather than importing the real SDK. The stub mirrors the signature verified +against supermemory 3.48.0: ``client.add(content=, container_tag=, metadata=, +custom_id=)`` returning an object with ``.id`` / ``.status``. The test exists so +that if the adapter drifts from that shape, it fails here instead of only on a +live push. +""" + +from __future__ import annotations + +import sys +import types + +import pytest + +from rac_connectors.supermemory.client import ( + MissingApiKeyError, + SdkSupermemoryClient, +) + + +class _StubResponse: + def __init__(self, id: str, status: str) -> None: + self.id = id + self.status = status + + +class _StubSupermemory: + """Stand-in for ``supermemory.Supermemory`` — records construction + calls.""" + + def __init__(self, **kwargs: object) -> None: + self.kwargs = kwargs + self.calls: list[dict[str, object]] = [] + + def add(self, **kwargs: object) -> _StubResponse: + self.calls.append(kwargs) + return _StubResponse(id="mem_abc", status="queued") + + +@pytest.fixture +def stub_sdk(monkeypatch: pytest.MonkeyPatch) -> None: + module = types.ModuleType("supermemory") + module.Supermemory = _StubSupermemory # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "supermemory", module) + + +def test_adapter_constructs_client_with_api_key(stub_sdk: None) -> None: + client = SdkSupermemoryClient(api_key="k-123") + client.add(content="body", container_tag="rac", metadata={}, custom_id="RAC-1") + assert client._client.kwargs == {"api_key": "k-123"} + + +def test_adapter_passes_the_real_add_shape(stub_sdk: None) -> None: + client = SdkSupermemoryClient(api_key="k") + result = client.add( + content="## Context\n\nbody", + container_tag="rac", + metadata={"id": "RAC-1", "status": "Accepted", "aliases": ["adr-001"]}, + custom_id="RAC-1", + ) + call = client._client.calls[0] + assert call == { + "content": "## Context\n\nbody", + "container_tag": "rac", + "metadata": {"id": "RAC-1", "status": "Accepted", "aliases": ["adr-001"]}, + "custom_id": "RAC-1", + } + # The SDK response maps into our minimal AddResult. + assert result.id == "mem_abc" + assert result.status == "queued" + + +def test_adapter_omits_container_tag_when_none(stub_sdk: None) -> None: + client = SdkSupermemoryClient(api_key="k") + client.add(content="b", container_tag=None, metadata={}, custom_id="RAC-1") + # container_tag is left out entirely rather than sent as None. + assert "container_tag" not in client._client.calls[0] + + +def test_adapter_forwards_base_url( + monkeypatch: pytest.MonkeyPatch, stub_sdk: None +) -> None: + client = SdkSupermemoryClient(api_key="k", base_url="https://self-hosted.example") + client.add(content="b", container_tag=None, metadata={}, custom_id="RAC-1") + assert client._client.kwargs["base_url"] == "https://self-hosted.example" + + +def test_api_key_read_from_env(monkeypatch: pytest.MonkeyPatch, stub_sdk: None) -> None: + monkeypatch.setenv("SUPERMEMORY_API_KEY", "from-env") + client = SdkSupermemoryClient() + client.add(content="b", container_tag=None, metadata={}, custom_id="RAC-1") + assert client._client.kwargs["api_key"] == "from-env" + + +def test_missing_key_raises_before_any_call(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False) + with pytest.raises(MissingApiKeyError): + SdkSupermemoryClient() diff --git a/tests/test_supermemory.py b/tests/test_supermemory.py new file mode 100644 index 0000000..6d1b51c --- /dev/null +++ b/tests/test_supermemory.py @@ -0,0 +1,98 @@ +"""The Supermemory connector: record->call mapping, idempotency, dry-run.""" + +from __future__ import annotations + +import pytest + +from rac_connectors.records import Record +from rac_connectors.supermemory import SupermemoryConnector +from rac_connectors.supermemory.client import MissingApiKeyError, SdkSupermemoryClient + +from .fakes import FakeSupermemoryClient + + +def _record(**overrides) -> Record: + base = { + "id": "RAC-ABC", + "type": "decision", + "status": "Accepted", + "title": "ADR-001: Example", + "text": "## Context\n\nbody", + "metadata": { + "path": "rac/decisions/adr-001.md", + "source": "rac", + "aliases": ["adr-001"], + "tags": [], + }, + } + base.update(overrides) + return Record.from_dict(base) + + +def test_record_maps_to_add_call() -> None: + client = FakeSupermemoryClient() + connector = SupermemoryConnector(client) + + summary = connector.push([_record()]) + + assert summary.pushed == 1 + assert len(client.calls) == 1 + call = client.calls[0] + assert call["content"] == "## Context\n\nbody" # Markdown body, not HTML + assert call["container_tag"] == "rac" # from metadata.source + assert call["custom_id"] == "RAC-ABC" # idempotency key is the canonical id + # Load-bearing metadata rides along for the verify-in-Lore loop. + assert call["metadata"]["id"] == "RAC-ABC" + assert call["metadata"]["status"] == "Accepted" + assert call["metadata"]["type"] == "decision" + assert call["metadata"]["path"] == "rac/decisions/adr-001.md" + + +def test_repush_is_idempotent_on_id() -> None: + client = FakeSupermemoryClient() + connector = SupermemoryConnector(client) + + connector.push([_record()]) + # Re-export with edited body, same canonical id. + connector.push([_record(text="## Context\n\nedited body")]) + + assert len(client.calls) == 2 # both adds were issued + assert len(client.store) == 1 # but only one stored item — an update + assert client.store["RAC-ABC"]["content"] == "## Context\n\nedited body" + + +def test_dry_run_makes_no_calls() -> None: + client = FakeSupermemoryClient() + connector = SupermemoryConnector(client) + + summary = connector.push([_record()], dry_run=True) + + assert summary.dry_run is True + assert summary.pushed == 1 + assert client.calls == [] # nothing sent to the backend + assert "RAC-ABC" in summary.actions[0] + + +def test_dry_run_needs_no_client() -> None: + connector = SupermemoryConnector() # no client at all + summary = connector.push([_record()], dry_run=True) + assert summary.pushed == 1 + + +def test_live_push_without_client_errors() -> None: + connector = SupermemoryConnector() + with pytest.raises(RuntimeError, match="required for a live push"): + connector.push([_record()]) + + +def test_missing_source_uses_no_container_tag() -> None: + client = FakeSupermemoryClient() + connector = SupermemoryConnector(client) + connector.push([_record(metadata={"path": "x.md"})]) + assert client.calls[0]["container_tag"] is None + + +def test_sdk_client_requires_api_key(monkeypatch) -> None: + monkeypatch.delenv("SUPERMEMORY_API_KEY", raising=False) + with pytest.raises(MissingApiKeyError): + SdkSupermemoryClient() diff --git a/tests/test_zep.py b/tests/test_zep.py new file mode 100644 index 0000000..becca6f --- /dev/null +++ b/tests/test_zep.py @@ -0,0 +1,102 @@ +"""The Zep connector: record->add mapping, graph-resync idempotency, dry-run.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from rac_connectors.records import Record +from rac_connectors.zep import DEFAULT_CONTAINER, ZepConnector + + +class FakeZepClient: + """In-memory stand-in for the Zep client. + + ``clear_container`` wipes a graph namespace and ``add`` appends a text + episode, so a test can assert that a re-push (clear then re-add) leaves no + duplicates. + """ + + def __init__(self) -> None: + self.adds: list[dict[str, Any]] = [] + self.clears: list[str] = [] + self.store: dict[str, list[dict[str, Any]]] = {} + + def clear_container(self, *, container: str) -> None: + self.clears.append(container) + self.store[container] = [] + + def add(self, *, text: str, container: str, metadata: dict[str, Any]) -> None: + call = {"text": text, "container": container, "metadata": metadata} + self.adds.append(call) + self.store.setdefault(container, []).append(call) + + +def _record(**overrides: Any) -> Record: + base = { + "id": "RAC-ABC", + "type": "decision", + "status": "Accepted", + "title": "ADR-001: Example", + "text": "## Context\n\nbody", + "metadata": {"path": "rac/decisions/adr-001.md", "source": "rac"}, + } + base.update(overrides) + return Record.from_dict(base) + + +def test_record_maps_to_add_with_metadata() -> None: + client = FakeZepClient() + summary = ZepConnector(client).push([_record()]) + + assert summary.pushed == 1 + call = client.adds[0] + assert call["text"] == "## Context\n\nbody" + assert call["container"] == "rac" # source maps to the Zep graph_id + assert call["metadata"]["rac_id"] == "RAC-ABC" + assert call["metadata"]["status"] == "Accepted" + assert call["metadata"]["path"] == "rac/decisions/adr-001.md" + + +def test_graph_cleared_once_before_adds() -> None: + client = FakeZepClient() + ZepConnector(client).push([_record(id="RAC-1"), _record(id="RAC-2")]) + assert client.clears == ["rac"] + assert len(client.adds) == 2 + + +def test_repush_is_idempotent_via_resync() -> None: + client = FakeZepClient() + connector = ZepConnector(client) + + connector.push([_record(id="RAC-1"), _record(id="RAC-2")]) + connector.push([_record(id="RAC-1"), _record(id="RAC-2")]) + + assert len(client.store["rac"]) == 2 # graph holds exactly the two records + assert client.clears == ["rac", "rac"] + + +def test_dry_run_makes_no_calls() -> None: + client = FakeZepClient() + summary = ZepConnector(client).push([_record()], dry_run=True) + + assert summary.dry_run is True + assert summary.pushed == 1 + assert client.adds == [] and client.clears == [] + + +def test_dry_run_needs_no_client() -> None: + summary = ZepConnector().push([_record()], dry_run=True) + assert summary.pushed == 1 + + +def test_live_push_without_client_errors() -> None: + with pytest.raises(RuntimeError, match="required for a live push"): + ZepConnector().push([_record()]) + + +def test_missing_source_uses_default_container() -> None: + client = FakeZepClient() + ZepConnector(client).push([_record(metadata={"path": "x.md"})]) + assert client.adds[0]["container"] == DEFAULT_CONTAINER diff --git a/tests/test_zep_client.py b/tests/test_zep_client.py new file mode 100644 index 0000000..1ceee7f --- /dev/null +++ b/tests/test_zep_client.py @@ -0,0 +1,103 @@ +"""The live SDK adapter (`SdkZepClient`) against a stubbed ``zep_cloud``. + +CI runs offline, so this stubs ``zep_cloud.client.Zep`` into ``sys.modules`` +rather than importing the real SDK. The stub mirrors the signature verified +against zep-cloud 3.23.0: ``Zep(api_key=...)`` with ``.graph.add(data=, type=, +graph_id=, metadata=)``, ``.graph.create(graph_id=)``, and ``.graph.delete(id)``. +""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +import pytest + +from rac_connectors.zep.client import MissingApiKeyError, SdkZepClient + + +class _StubGraph: + def __init__(self) -> None: + self.adds: list[dict[str, Any]] = [] + self.created: list[str] = [] + self.deleted: list[str] = [] + self.raise_on_delete = False + + def add(self, **kwargs: Any) -> None: + self.adds.append(kwargs) + + def create(self, **kwargs: Any) -> None: + self.created.append(kwargs["graph_id"]) + + def delete(self, graph_id: str) -> None: + if self.raise_on_delete: + raise RuntimeError("not found") + self.deleted.append(graph_id) + + +class _StubZep: + last: _StubZep | None = None + + def __init__(self, api_key: str | None = None) -> None: + self.api_key = api_key + self.graph = _StubGraph() + _StubZep.last = self + + +@pytest.fixture +def stub_zep(monkeypatch: pytest.MonkeyPatch) -> None: + pkg = types.ModuleType("zep_cloud") + client_mod = types.ModuleType("zep_cloud.client") + client_mod.Zep = _StubZep # type: ignore[attr-defined] + pkg.client = client_mod # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "zep_cloud", pkg) + monkeypatch.setitem(sys.modules, "zep_cloud.client", client_mod) + _StubZep.last = None + + +def test_add_maps_to_graph_add(stub_zep: None) -> None: + client = SdkZepClient(api_key="k") + client.add(text="body", container="rac", metadata={"rac_id": "RAC-1"}) + call = _StubZep.last.graph.adds[0] + assert call == { + "data": "body", + "type": "text", + "graph_id": "rac", + "metadata": {"rac_id": "RAC-1"}, + } + + +def test_clear_deletes_then_creates(stub_zep: None) -> None: + client = SdkZepClient(api_key="k") + client.clear_container(container="rac") + graph = _StubZep.last.graph + assert graph.deleted == ["rac"] + assert graph.created == ["rac"] + + +def test_clear_tolerates_missing_graph(stub_zep: None) -> None: + client = SdkZepClient(api_key="k") + # Force delete to fail (graph absent); create must still run. + client._ensure_client().graph.raise_on_delete = True + client.clear_container(container="rac") + assert _StubZep.last.graph.created == ["rac"] + + +def test_api_key_passed_to_client(stub_zep: None) -> None: + client = SdkZepClient(api_key="k-123") + client.add(text="b", container="rac", metadata={}) + assert _StubZep.last.api_key == "k-123" + + +def test_api_key_read_from_env(monkeypatch: pytest.MonkeyPatch, stub_zep: None) -> None: + monkeypatch.setenv("ZEP_API_KEY", "from-env") + client = SdkZepClient() + client.add(text="b", container="rac", metadata={}) + assert _StubZep.last.api_key == "from-env" + + +def test_missing_key_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ZEP_API_KEY", raising=False) + with pytest.raises(MissingApiKeyError): + SdkZepClient()