From 6a68a4c03eb4be8319e39066a7e36599fc83ae55 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:33:25 +0000 Subject: [PATCH 01/30] chore(release): initialize repository From 52c368567c46e1c2644a021d0fcfd1e47219e07d Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:02:22 +0000 Subject: [PATCH 02/30] chore(release): scaffold the lore-connectors companion repo Establishes the Lore/RAC companion that pushes the corpus export into external memory, RAG, and graph backends (rac-core ADR-073). Apache-2.0 to match rac-core; CI runs ruff, mypy, and the offline test-suite. Dogfoods Lore via an LCON-keyed rac/ corpus. --- .claude/settings.json | 7 ++ .github/workflows/ci.yml | 59 ++++++++++++ .gitignore | 19 ++++ .rac/config.yaml | 1 + LICENSE | 201 +++++++++++++++++++++++++++++++++++++++ NOTICE | 6 ++ pyproject.toml | 63 ++++++++++++ 7 files changed, 356 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rac/config.yaml create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 pyproject.toml 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..5d0d415 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +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: + +permissions: + contents: read + +jobs: + lint: + name: lint (ruff + mypy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - 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/ + + - name: ruff format --check + run: python -m ruff format --check src/ tests/ + + - name: mypy + run: python -m mypy src/ + + 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 + + - 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/.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..5354881 --- /dev/null +++ b/NOTICE @@ -0,0 +1,6 @@ +lore-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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c069aac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "lore-connectors" +version = "0.1.0" +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/lore-connectors" +Repository = "https://github.com/itsthelore/lore-connectors" +Issues = "https://github.com/itsthelore/lore-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. +supermemory = ["supermemory>=3.0"] +dev = ["pytest>=7.0", "ruff", "mypy"] + +[project.scripts] +lore-connect = "lore_connectors.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[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 = ["lore_connectors"] + +# The Supermemory SDK is an optional extra, imported lazily; CI type-checks +# without it installed, so its missing stubs are not an error. +[[tool.mypy.overrides]] +module = ["supermemory.*"] +ignore_missing_imports = true From 478d99df441997667791502a26bd42e662c3e34f Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:02:34 +0000 Subject: [PATCH 03/30] docs(decisions): record ADR-001 (Python stack) and ADR-002 (connector seam) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-001 fixes lore-connectors as a Python package matching the RAC ecosystem. ADR-002 fixes the shared, outbound-only push seam — one module per backend (rac-core ADR-073). Both validate under rac validate rac/. --- rac/decisions/adr-001-python-stack.md | 98 ++++++++++++++++ rac/decisions/adr-002-connector-interface.md | 115 +++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 rac/decisions/adr-001-python-stack.md create mode 100644 rac/decisions/adr-002-connector-interface.md diff --git a/rac/decisions/adr-001-python-stack.md b/rac/decisions/adr-001-python-stack.md new file mode 100644 index 0000000..59739ed --- /dev/null +++ b/rac/decisions/adr-001-python-stack.md @@ -0,0 +1,98 @@ +--- +schema_version: 1 +id: LCON-KVKGQD318KM8 +type: decision +--- +# ADR-001: lore-connectors Is a Python Package + +## Context + +`lore-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 + +`lore-connectors` is a **Python package** (`lore_connectors`, Python 3.11+), +matching the RAC ecosystem. + +- One installable package with a `lore-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. `lore-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..3501aef --- /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** `lore-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** (`lore-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. From d240c2646413a2969ec6aafb72b4e268d39deca8 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:02:34 +0000 Subject: [PATCH 04/30] feat(supermemory): add the documents reader, push seam, and Supermemory connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads a 'rac export --documents' JSON Lines stream and upserts each record into Supermemory, idempotent on the canonical id (custom_id) so a re-push updates rather than duplicates. Outbound only — no pull, re-rank, or route; no embeddings here (rac-core ADR-002, ADR-066). Auth via SUPERMEMORY_API_KEY; --dry-run previews without an API call. The lore-connect CLI wires stdin or --input to the connector. Implements the corpus-export-shape-contract / lore-supermemory-interplay designs in rac-core. --- src/lore_connectors/__init__.py | 23 +++ src/lore_connectors/base.py | 65 ++++++++ src/lore_connectors/cli.py | 155 +++++++++++++++++++ src/lore_connectors/records.py | 127 +++++++++++++++ src/lore_connectors/supermemory/__init__.py | 22 +++ src/lore_connectors/supermemory/client.py | 110 +++++++++++++ src/lore_connectors/supermemory/connector.py | 82 ++++++++++ 7 files changed, 584 insertions(+) create mode 100644 src/lore_connectors/__init__.py create mode 100644 src/lore_connectors/base.py create mode 100644 src/lore_connectors/cli.py create mode 100644 src/lore_connectors/records.py create mode 100644 src/lore_connectors/supermemory/__init__.py create mode 100644 src/lore_connectors/supermemory/client.py create mode 100644 src/lore_connectors/supermemory/connector.py diff --git a/src/lore_connectors/__init__.py b/src/lore_connectors/__init__.py new file mode 100644 index 0000000..5f0fe30 --- /dev/null +++ b/src/lore_connectors/__init__.py @@ -0,0 +1,23 @@ +"""lore-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, PushSummary +from .records import MalformedRecordError, Record, parse_documents + +__all__ = [ + "Connector", + "MalformedRecordError", + "PushSummary", + "Record", + "parse_documents", +] diff --git a/src/lore_connectors/base.py b/src/lore_connectors/base.py new file mode 100644 index 0000000..a4e3d7a --- /dev/null +++ b/src/lore_connectors/base.py @@ -0,0 +1,65 @@ +"""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 .records import Record + + +@dataclass +class PushSummary: + """The outcome of a push, the same shape for every backend. + + ``actions`` records one line per record 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. + """ + + backend: str + pushed: int = 0 + skipped: int = 0 + dry_run: bool = False + actions: list[str] = field(default_factory=list) + + def record_push(self, record_id: str, detail: str) -> None: + self.pushed += 1 + self.actions.append(f"push {record_id}: {detail}") + + def record_skip(self, line_number: int, reason: str) -> None: + self.skipped += 1 + self.actions.append(f"skip 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. + """ + ... diff --git a/src/lore_connectors/cli.py b/src/lore_connectors/cli.py new file mode 100644 index 0000000..1779025 --- /dev/null +++ b/src/lore_connectors/cli.py @@ -0,0 +1,155 @@ +"""``lore-connect`` — the CLI entrypoint for the lore-connectors companion. + +Reads a ``rac export --documents`` JSON Lines stream (stdin or ``--input``) and +pushes it into a backend. One subcommand per backend; Supermemory is the first:: + + rac export rac/ --documents | lore-connect supermemory + rac export rac/ --documents | lore-connect supermemory --dry-run + lore-connect supermemory --input corpus.jsonl +""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Iterable, Iterator +from typing import TextIO + +from .base import PushSummary +from .records import MalformedRecordError, Record, parse_documents +from .supermemory import SupermemoryConnector +from .supermemory.client import MissingApiKeyError, 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 _push_with_skips( + connector: SupermemoryConnector, + 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 build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="lore-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) + 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/lore_connectors/records.py b/src/lore_connectors/records.py new file mode 100644 index 0000000..0d3f807 --- /dev/null +++ b/src/lore_connectors/records.py @@ -0,0 +1,127 @@ +"""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 + + +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 + yield record diff --git a/src/lore_connectors/supermemory/__init__.py b/src/lore_connectors/supermemory/__init__.py new file mode 100644 index 0000000..7c322b2 --- /dev/null +++ b/src/lore_connectors/supermemory/__init__.py @@ -0,0 +1,22 @@ +"""Supermemory backend module for lore-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/lore_connectors/supermemory/client.py b/src/lore_connectors/supermemory/client.py new file mode 100644 index 0000000..f93f662 --- /dev/null +++ b/src/lore_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/lore_connectors/supermemory/connector.py b/src/lore_connectors/supermemory/connector.py new file mode 100644 index 0000000..00714e1 --- /dev/null +++ b/src/lore_connectors/supermemory/connector.py @@ -0,0 +1,82 @@ +"""The Supermemory connector — module one of lore-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 From c07a66ffeda80d47519442dfd8ff366e8bbdb289 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:02:34 +0000 Subject: [PATCH 05/30] test(supermemory): cover mapping, idempotency, dry-run, and the malformed-line guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives a fake Supermemory client — no live API in CI. Covers record->add mapping, idempotent re-push on id, dry-run making no call, env-auth guard, and malformed/blank-line handling. Fixture captured from a real rac export. --- tests/__init__.py | 0 tests/fakes.py | 38 +++++++++++++ tests/fixtures_documents.jsonl | 3 ++ tests/test_cli.py | 67 +++++++++++++++++++++++ tests/test_records.py | 80 +++++++++++++++++++++++++++ tests/test_supermemory.py | 98 ++++++++++++++++++++++++++++++++++ 6 files changed, 286 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/fakes.py create mode 100644 tests/fixtures_documents.jsonl create mode 100644 tests/test_cli.py create mode 100644 tests/test_records.py create mode 100644 tests/test_supermemory.py 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..c772fad --- /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 lore_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..558e56c --- /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 lore_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_records.py b/tests/test_records.py new file mode 100644 index 0000000..95e00ac --- /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 lore_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_supermemory.py b/tests/test_supermemory.py new file mode 100644 index 0000000..7126c66 --- /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 lore_connectors.records import Record +from lore_connectors.supermemory import SupermemoryConnector +from lore_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() From 027b42172dbd440069eeefbd1c511b99910fa1d7 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:02:34 +0000 Subject: [PATCH 06/30] docs(readme): frame the repo as the Lore companion Explains the fuzzy-recall/verify-in-Lore loop, the Supermemory usage and flags, the export contract consumed, and how a new backend slots in. --- README.md | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac39a74 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# lore-connectors + +**Outbound connectors that push [Lore](https://github.com/itsthelore/rac-core)'s +product knowledge into the memory, RAG, and graph backends your team already +runs** — so an agent can recall fuzzily there, then **verify in Lore**. + +This is a companion to **Lore** (the product) / **RAC** (the engine — package +`requirements-as-code`, repo +[`itsthelore/rac-core`](https://github.com/itsthelore/rac-core)). RAC keeps a +team's requirements, decisions, designs, roadmaps, and prompts as typed Markdown +and serves them read-only over MCP. This repo holds the **one-way, outbound** +connectors that ship RAC's export payloads into external backends. + +It is a *consumer of a stable export contract*, not part of the engine. No +embeddings, vectors, or LLM ever run here — those live in the backend. If a +connector needs the contract to change, that is a change in `rac-core`, never +worked around from this side. + +## How it fits together + +``` +rac export rac/ --documents # RAC emits one JSON line per artifact + │ + ▼ +lore-connect supermemory # this repo: upsert each record into the backend + │ + ▼ +Supermemory (fuzzy, associative recall) + │ + ▼ agent recalls a candidate, then… +get_artifact / rac resolve # …verifies the authoritative text in Lore +``` + +The connector only keeps the backend **fresh**. The *verify-in-Lore* loop — re-fetch +the authoritative artifact by `id`, drop retired ones by `status`, act on Lore's +verbatim text — is the reading agent's job, not this connector's. + +## First connector: Supermemory + +A one-way push: read a `rac export --documents` JSON Lines stream and upsert each +record into [Supermemory](https://supermemory.ai). + +```bash +pip install -e '.[supermemory]' +export SUPERMEMORY_API_KEY=sk-... + +# Push the whole corpus (idempotent — re-running updates, never duplicates): +rac export rac/ --documents | lore-connect supermemory + +# Preview without calling the API: +rac export rac/ --documents | lore-connect supermemory --dry-run + +# Read from a file instead of stdin: +lore-connect supermemory --input corpus.jsonl +``` + +Each record maps to a Supermemory upsert: + +``` +record → add(content=text, + container_tag=metadata.source, + metadata={id, type, status, title, path, …}, + custom_id=id) +``` + +- **One-way / outbound only.** Pushes to the backend; never pulls, re-ranks, or + routes. +- **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. +- **`--dry-run`** prints what would be sent without calling the API. +- **Auth via `SUPERMEMORY_API_KEY`** — never hard-coded. + +### Flags + +| 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. | + +## The export contract consumed + +`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/superseded items. The contract is additive and stable — connectors +depend only on it. + +## Adding a backend + +One repo, one module per backend (see `rac/decisions/`, ADR-002). A new backend +is a module under `src/lore_connectors/` implementing one seam: + +```python +class Connector(Protocol): + name: str + def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: ... +``` + +Record parsing, the CLI, dry-run, and the summary shape are shared; a module +provides the upsert mapping behind a thin, mockable client. 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. + +## Development + +```bash +pip install -e '.[dev]' +pytest # no live API — drives a fake client +ruff check . +``` + +This repo dogfoods Lore: its own decisions live in `rac/decisions/` and are +validated with the `rac` CLI (`rac validate rac/`). + +## License + +Apache-2.0 — see [LICENSE](LICENSE). Matches `rac-core`. From 6554653fea4386410a3e60627e65b5eaf3483750 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:10:14 +0000 Subject: [PATCH 07/30] docs(readme): align structure and headers with the rac-core README Adopt the rac-core house style: centered nav, badge row, tagline, and the same section headers where they map (How it compares, Quickstart, Install, How it works, Python API, Who it's for, Documentation, Origin, Repository layout, Test, Project status, License). --- README.md | 232 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 154 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index ac39a74..9668e86 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,104 @@ # lore-connectors -**Outbound connectors that push [Lore](https://github.com/itsthelore/rac-core)'s -product knowledge into the memory, RAG, and graph backends your team already -runs** — so an agent can recall fuzzily there, then **verify in Lore**. +

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

-This is a companion to **Lore** (the product) / **RAC** (the engine — package -`requirements-as-code`, repo -[`itsthelore/rac-core`](https://github.com/itsthelore/rac-core)). RAC keeps a -team's requirements, decisions, designs, roadmaps, and prompts as typed Markdown -and serves them read-only over MCP. This repo holds the **one-way, outbound** -connectors that ship RAC's export payloads into external backends. +

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

-It is a *consumer of a stable export contract*, not part of the engine. No -embeddings, vectors, or LLM ever run here — those live in the backend. If a -connector needs the contract to change, that is a change in `rac-core`, never -worked around from this side. +> **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.** -## How it fits together +lore-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**. -``` -rac export rac/ --documents # RAC emits one JSON line per artifact - │ - ▼ -lore-connect supermemory # this repo: upsert each record into the backend - │ - ▼ -Supermemory (fuzzy, associative recall) - │ - ▼ agent recalls a candidate, then… -get_artifact / rac resolve # …verifies the authoritative text in Lore -``` +## How it compares -The connector only keeps the backend **fresh**. The *verify-in-Lore* loop — re-fetch -the authoritative artifact by `id`, drop retired ones by `status`, act on Lore's -verbatim text — is the reading agent's job, not this connector's. +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. -## First connector: Supermemory +| | 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 | -A one-way push: read a `rac export --documents` JSON Lines stream and upsert each -record into [Supermemory](https://supermemory.ai). +## Quickstart -```bash -pip install -e '.[supermemory]' -export SUPERMEMORY_API_KEY=sk-... +1. **Install** the connector (with the Supermemory backend extra): -# Push the whole corpus (idempotent — re-running updates, never duplicates): -rac export rac/ --documents | lore-connect supermemory + ```bash + pip install -e '.[supermemory]' + ``` -# Preview without calling the API: -rac export rac/ --documents | lore-connect supermemory --dry-run +2. **Authenticate** the backend via the environment (never hard-coded): -# Read from a file instead of stdin: -lore-connect supermemory --input corpus.jsonl -``` + ```bash + export SUPERMEMORY_API_KEY=sk-... + ``` -Each record maps to a Supermemory upsert: +3. **Push** the corpus — pipe a `rac export --documents` stream straight in: -``` -record → add(content=text, - container_tag=metadata.source, - metadata={id, type, status, title, path, …}, - custom_id=id) -``` + ```bash + rac export rac/ --documents | lore-connect supermemory + ``` + +4. **Preview** first if you like — `--dry-run` calls no API: + + ```bash + rac export rac/ --documents | lore-connect supermemory --dry-run + ``` + +Re-running is idempotent: each record upserts on its canonical Lore `id`, so a +re-push updates rather than duplicates. + +## Install -- **One-way / outbound only.** Pushes to the backend; never pulls, re-ranks, or - routes. -- **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. -- **`--dry-run`** prints what would be sent without calling the API. -- **Auth via `SUPERMEMORY_API_KEY`** — never hard-coded. +| Command | Gets you | +|---|---| +| `pip install -e .` | the `lore-connect` CLI + the connector library | +| `pip install -e '.[supermemory]'` | + the Supermemory backend SDK (live push) | +| `pip install -e '.[dev]'` | + ruff, mypy, and pytest for development | -### Flags +Requires Python 3.11+. 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 + │ + ▼ +lore-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 +``` -| 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. | +- **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). -## The export contract consumed +## The export contract `rac export --documents` emits JSON Lines, one record per artifact: @@ -93,13 +110,29 @@ record → add(content=text, `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/superseded items. The contract is additive and stable — connectors -depend only on it. +retired or superseded items. The contract is additive and stable (rac-core +ADR-007) — connectors depend only on it. + +## Python API + +The connector is a library too. Parse a `--documents` stream into records and +push them through any backend module: + +```python +from lore_connectors import parse_documents +from lore_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. -## Adding a backend +## Add a backend -One repo, one module per backend (see `rac/decisions/`, ADR-002). A new backend -is a module under `src/lore_connectors/` implementing one seam: +One repo, one module per backend (rac-core ADR-073; this repo's ADR-002). A new +backend is a module under `src/lore_connectors/` implementing one outbound seam: ```python class Connector(Protocol): @@ -108,22 +141,65 @@ class Connector(Protocol): ``` Record parsing, the CLI, dry-run, and the summary shape are shared; a module -provides the upsert mapping behind a thin, mockable client. Named future targets +supplies the upsert mapping behind a thin, mockable client. 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. -## Development +## 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 + +lore-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 +lore-connectors/ + src/lore_connectors/ the connector library: the documents reader, the shared + push seam, the lore-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]' -pytest # no live API — drives a fake client -ruff check . +pip install -e .[dev] +python -m pytest ``` -This repo dogfoods Lore: its own decisions live in `rac/decisions/` and are -validated with the `rac` CLI (`rac validate rac/`). +`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-2.0 — see [LICENSE](LICENSE). Matches `rac-core`. +[Apache License 2.0](LICENSE). Matches `rac-core`. From 8189b0cce582aa2e8a065e750120c0e6c5bc7050 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:14:43 +0000 Subject: [PATCH 08/30] docs(readme): add the Lore header asset to the masthead Vendor the Lore header (light/dark) from rac-core under rac/assets/images/ and wire it into the README via a prefers-color-scheme , matching the rac-core masthead. --- README.md | 6 ++++++ rac/assets/images/lore-header-dark.png | Bin 0 -> 113569 bytes rac/assets/images/lore-header-light.png | Bin 0 -> 121298 bytes 3 files changed, 6 insertions(+) create mode 100644 rac/assets/images/lore-header-dark.png create mode 100644 rac/assets/images/lore-header-light.png diff --git a/README.md b/README.md index 9668e86..827b017 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # lore-connectors + + + + Lore — agents that know why. Deterministic. Read-only. No RAG, no guessing. + +

Quickstart · How it compares · diff --git a/rac/assets/images/lore-header-dark.png b/rac/assets/images/lore-header-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..036e9e862e2a226b349c0b2c9bb686f046950568 GIT binary patch literal 113569 zcmeFZRajixwk}G95In&(Bsc_j3aJDM?(SMR0fK802qA?AcL)xJd+-z*JXm4DgG125 zsXAn>$y{si{oQl!!@b{rIJ-a5Ygiji{`U9Q+W?}fEQ5^;**_eRm!skY2|B>;~*Uvio~)EzYel?v=!IE;UPIx**7- z4`>e_83aZ~%HcBtVsHYy(w{{v!%Sl%tGF4-pTK&0rcJ*|QO3XmF=?6UwpJECrj$37 z_;{2Ts-G1!&u8q1TBKwYmzQt&^c=F_r9{)KkJ_J6w`x}jn?%{{O zC*R0k{LiEQ(#HQN@PFLm9~$@*S6}TI8JqMHS@N%=YvIyrgCm@56^GTKF(B`Y8s^Iq zo(ROEZgFY)?ydvsA&!L{{KzieWO-OWg8WX04%#WE30)G@+^_^zppnO_J5L1em=KV= zQ#ZD*h2nxD^54$J1g|-F>D@bn@ zVX}C1cVk_q|0`0)e+}65r5?S+Bw%+~7YfhK-euoyJ zLSWZJ9q6?iwSaLfE)UGlA7}O9Jkgy71M_K9boupnPP5A6dK9XkGJW0pH@m5;z5Z&q zzP@guWxf*fim9NgrmLuwEnGj8&@KMh2)|R;?C-z~@m!Hpl#&9Iud&Ro>a7@b0K5y&~GIv^VWYyg1w_yYO=n%^__Jo`{$D@Q(tY4azmc6_bF z?$ZX5!Oo`o9{vp|aLoS}8s0MnZar}j_WphSljd5%>hHBYADxLauf9cl>{QdSY0h^Mg+y1#?eoX5jp0WKv%7 zhTlx*^e3Iav!!XVEMwvu1Av6IU|MZBcsc#$yxdJ4FUY#6b2zxONHFePHTb#*A!5*~ENTUd zjG1UrX=NhR<@xHp z%I(LGHG;ffaXofP71qcQIsGb{gh-x8BCh-4Tir`W;{^?%j+hYn8&75EFN{!v z{--+?gmoqe@nF&y?8zpH7003_f@B_RzhabqbiiUQZxL@nd`PNO!}jl0x}%Nz1gr+K zf_A5OwSZC@vIzJ!tT$F+qos3d_e-@E4JL>H>?G9E!bah%%k<}L{q1?;D% ztu<7%=hDKM?)-<6^q!eKTa|((I{~Zj(Og$l0!guGUX<9X9b;V$KYm8G}gl;%^%p4H+E+>U5X&( zEX2-b_yyp3!GB2;I207pdI8Ik=9flC%65?tvaZ{$E`skI?gsK(MkzAqZ90nQ6-Pr>! zeEQt*KJ}sxm$s__+b^h2^rVSUWGM9`6$;8%l~MAvePJ3D#BA6Kp1;$jL+gXWaeK0S zEf(O@U&J$da>vvXvaG5xxtYQ&9l-e+r6 zig%;VRpuk_Seb%wurU09j#{r^%!w^q>IY4`olac=EpbYf_)X`yV>+6U7Z&lNX7z$@ z$Y}x&9#3+;KwI3ogdBZr1Sk>t9sSNcLm`0&Urj6X=GZlvWAeIK^wpK@^$T8hruRVZ z@o8&Z^gc2fP$$uM<46i~z|sZRfG%@Zn}5h%kI5C8R&iH2QYD|l@0Xz{2FpLvBcD(D zjG3pk6IT^X42TCiqfuC8)Ty7~ZY{64ytFK~={%K_+MdJml)Tp>ST!y6 zoL1=#5Iw_a^xkJXPTRkZvSE+elglrHJ%1w(o>*%RKdd8tH?_f0mh>XweClf~zKz{tH4eu-$MT~Zz3|Ns6bzxUmr|9Abw;0MqkQb zA&lvc5>4v@g7U|6MX=$EC&$E08kmn1VC6wf&?yUH&J!DVeYOy+OzwFhA^!~}rZTm| zZc42pU0x5Yy*+$VMM$jdf_4 z+<#9A9W9U$bV{cZQ^)PTDEJu7tLQn^Q|uCYO^PCYO(9{-lq{#MbMrlQ{;~n)8|g-&iD>Q| z;YVQ?n$-?!Ax9IJ?_tN6Vpl4*Tml{e+N6v7^1cb)$~vq>!X@83*f|>WNFsD_Y5N^da)mGb%3EqMTkH)bVyujb9rdtX$C) zlX$zXjQgbZysb;xdep3*9oDzxnjaUEfE=>B_$(+;V(n|kr+|m&b*_9+uqo?eccAf? zFlYiC_dD)UbNaZheDHD9u5>xj*kdOpA53(6$1jVSuVm*Y*R!N%R~t6mw-7{gG0(VO zLTj+Y|BT-uPRK~CP%YP9T0XC~T$~QfwH3Y_!G3Ogqf>}7rCMv(?mMqpcq60zMk|KT z=iCk5LV{Y)ir0f<BlSnIB$rO7c|8*bL+x1!cLoTfv!1 zK7UT7s=&q*m!C&13DC?Eyk<^(^>C*)Jzmy3!4;qfCfWUo;uD(a$-TCM^Z?pA3i%LJ z;nIYMnYGN$4x$yu$_GPTFL)^W3k)u^3Myi0X65J`ju|ptZ9N6&KA|^2;-I7EQp7BF zCxmT1lrd3Z!GffBW{QluO=&zwfl{-i4s0=OT)Np>C|TOR&n8a_Bwk8h&dd8f5Wpm! zyFj=8W2rnhHX)hzRy@ZeQqf{?sT2VlkoMhzilr=M35ry{sb_p6a?O9}wfW<+C;$(d z8au4}A<;`FmEh;)d}(_**k04pMIU9%hgX1%0e;kF&vkV;$SMAzUR7ma;-sQ`w?{Bd z1-M^g{J9}d(tN*nh4v8cTS`NzknnLY_(r6HLGwC~uDPoY^Va!Wm=9k*A*2ir(+m_| z#XMakJ&@G2s{bDutNI92XL3iG26v~(=ToTkBOHc_1Im{YF^Lgz|ZfeJ~5@7ab zPOs^W5he#r>`=?wjH>DN^ZtQu)A1iSdK=UUDrIp3J_(nQx_fBpUK(fq!D!zwZz=OD zKuVk?w;nCJoMs+FEIR~$48jpq2SUiRKosedv(R_2Gy3F@^z&CIb!(F$f zl}7@kC`8rRnj7M`zZC*2Xsnmn=|1-Yy4pCk9Lyr&OtmatJTRC=AZm;|^* zTjOiR&pR5S)$!lqDC8Y)|C3e9dDhBc$+Dnp9E&0lPj;$&s-~r*9MeQ8n9ol`Mtj## z-C;F7CtvEMsAWZzxER~vmKiPtrF@PmMd*V^y-2Ui)47FQzPQQ`ASoH1eHUoxgCu6W zC6serEmWqY)|V0V`Wja1OzD>gf{ZNN8F}2r5uEaVNBt~@R{(Y9F+jJoP{z~6UeBB% zTa;y*O^tS4Cs?lKq|66M;s_n>uQyxmNiPW4o_=_PEi!m~?a;Ty<)Lx1sqI?z_{Wy( z`FP_J`*EQU5aF3uPiA$qTQ$f-W-PB!o$y$Gj!K9sHtOLVa?b;xmR&}&BUa>PR2)^& zZ{Soafu01YJu3u8ToRTfR$LsTeF98#qXDth%z)uNasKYh#M>Golc`vJZdMs0jO1655fS5 z^>zSJyO3jnh0DZ|HwR{_F|-p)s~esx(T0MPvv8H!f~s2Irl8Kpj7fL4`Bw1i6&I{Z&PPZtb2U&Ycq3zJnqVqBp!9Sb z{U+cVvPl7nUn|x(`ZB@MMx(@F#T4_()5|U+!gnXvkx;Mu<5Mb2+AIYV57c4#;%k2W z1O4`n;Z-PYer;9mH7}g9moI?DJaT7fo4fExoBJ-+n&$32wo5PSgfLuhm;3z8Plc|y zvjTJFyx+DqKN$1Jt`n20u{n zG8N8EmxeaR06905Hq_6ld!9us-N*x_Z_#t?((78P5hP1OL}|VI^7Geo#Dk1eq*tPU znYl^6Tf@pck2K$(=#F5xjjB0;$;JFl;6x151y$)7x7?!Ep>b*=-l{RY4C;S4hP|P7L3|;oOZMlz}9xFC8=YlP788_T;qufv7W!?N_K^7 zJg@RvLhi+xEbZAWaNn60`GAC~J_;BL)1b#G%%$rq2WBoE7Z_N!BGm}`zq|kPhR4VV z9+u#hfhd~U`mxX5cOFvwC0rmPg5gc{aVOdV=st8OPG+Gb>8Bezh(4UQW;zhNCbO6<!PR;rhqPKe5O}>t{)b9^zQsrv$gX9w-FaX71P@btn>_ninGPe4!0Ap=P^YqvD#L=x z31{{%HtngH$mhA9jiu7EDga!YxBnFo`f?oW-OhxepU)QF!=n|>sWQGH?L zUT-^-{fAS zo4soNS!Qez{~dd$9eQ5=4xo9mKEcZnC2teK5wA0nBxGB;VmBQPC6@2E(MqQYlw)=| zRN2_DLn5B4+(cCADPZ@PWS*$dAI>Fx4XkpIi`HJnofB%^@>w zIBBEaoIeYHme=oU<-NN|me z=VB|!hm&8BO1)Wax(xRzpxhltX>z)(DKeoch&~u@wI}j}2CICb!GdQ!*+>UOhoZu=sU)JmKpu(o>a5f&Df%1LIa2o;A&RZ@dI1ml#{7Twnx+=X@Ue-BQB@ zIsrH6>`&lw4*WQ?zQ8C*vKldZ%fvJS@Xl<=@U1@{#$P-QxcB4Nn?m<6^W$r+GfA)q z=SBvG_>M*A)TBv8CCOVieI4ccZj`uwNVDG-Klc4}hii2E4>V2Z#jnIJ4tSrDv$JRz z_vzexlX6&5s%Nwwv9{$mZb?o(Drumbiy$r?Be#F#aP;ar8`;5RA#`-=V)5h%N^}-7x z)Yl4b~N?MG{KEI@5$8T}4=u>W>JzG{L_(MDGJ5V?W4 z{)(6|tR^o@5TcUjLjOk-0)h!DeLh+@kyt2tIy48y{r2-co@dWaKZQU126**{IGjTDh z2OICsI&Y+2O>19W77AQQT^{u0YWgL?dWfIyV8H3J1U8hSd6h!pj}aC%x~cpQxvgdE zK`~mi9$~}_!jYk??gx%)DTyCT5Y1{nUg}B) z4jpq*q?`Gfnl!8Y4a-&bmn=eir$b%2sJl*wK-@@%HyGx{fmmhOtT9dHQhMsEGOTUu zW>MkR<%N!qpPPZH96minK5E*kTDfv70#0U78tqsO!GI&$4T{ z%&+x*=H~Z$0%VxJ#B!ZJ(#NP(y{@kRlYc{r*d)5v#h9J*%lKCpEcRRHZ|(5?lR!&j zDy~h3y*D~dGK-BZ|FL`377@zEi$Zf#BT{dEjA@;2^f+;)33VNtF1mUu`hapaQh5gs zW=CCKtDyEXtBd_NM}DuhxSq=_l9bj1uj*RgRkQm-_M>!)>a2Z#x44eJEWagPC#mT7 z-~{)~!sN?;la&}#9Mu$=3KdF7yF1>8m=>qd99Lm}pdQoBx$1y7=*y3UCXvo%Sy$K=Zn7CHe4n_94go zn0H|&pAaM@0-i5iXiqL(L`@nEd(@-0{|N5xzHS`B*rXb30^U1c3Uvl8`S z%w0trIv=bI>2$BUf{V6l?qsQ4R(&m0nTKYo>y;K@V*XFv;#+%k*)DvP%CFek|QL)Mpxos!#)62I^J^vUW)vhEWHAvA0k zh02-{=#nfswh}EMq%p6VPinu^;Pz;}%PKNdBwF<#NQl!9>F-vMf*dOz z)ii!XqrdU3%V_-2r@vB3d5cn!V)j3~ME0+BM6NEIsZ%ih6T_&$;h1dGJDqY>Tq+34x>7%S7pHSFF_A{|d zvurF!Kjs&##%deZ0acdzzFPhWZj8{4XnGG^_dF>7>E40liqFWfAmCgV(2$EXm%}H! zv*r$=Ld3_dQDW?FY*zZ%L^%y5gUg7{@*qr&%c-UFj57-tR6VcWs1iU?59+ zloah7K!a^iWM6bE8aeL0QN80+{o3yzp?v(!+TYm?IuRjn1L7 z(gRgwiW2DFkkFYeE|=Wy@J9@MOI<7k(QX5J7)-wxEAl`34I`Y6asIA;zj*Sb>6+{t z=hJYI)fT}^Ok%5xq1l$i^P>b9R*S9|VTA5>`?l;03x~GYLG66GrbL&6wv&Og3G=z? zspTg;2{R_)-94QzQWl{}y}gZJ3!9DoOy*FVJIc;)U@r0+;;EZ?N{0IF&k33mstkE! zj(TPvZWOJ&uFE29ki!Zd8RS`r&JTt?f-D5-@OUW6V*J6@KO^nIz3i7U8~M>kon=13 zraBP6;eGA85;J*g35aBTgPXNKZIynfZUCXEg2k$o2R~JxN&CTUbJqC&FY}j;TH5t} zwKIw}IJg6z@vXc1q^IgBsZMj_=GRQYQdO5qJH{qS7#@neT14F~Zcc|?<3V}_z?&|& z*-&uRuL^N{psS;3{f)mIzItqa)vWp+LGR@>y}J1()5_NMnCaji})t zVIv6tnWt~$j)9@rXwyOq;KMmkuJ<$@2LT!-$-U)IZigDFG-bF~ntcSdZnmZtZ=Yg^ zo@}b8L%Ny#0|dsCKXj8c5pj;IFD!f*_P&|HrA#7Uz*XLJZvrtE)$q_w=rO&dr~C!h zlkpLF$p&RI*iyue?O?^L9W^CqgE~}Q4dRu>fkL#U%d5==xHA)r4SX6EP-Ey|_ogs@ z?Qq|tZoDL~)Aja5ugHwBiRX{#1R;YGZ*I|8Y4^7Xc_V%2_a(`-Ga8&D20oTQFZ5>! zq$sMc$`HA4O~2xdRds+T^YZr)R9P};U*{#NdopLQiS2B->&yGO4CpQtOLRNKzSww? z?`*Aqv5s+^8~Dsw1IsMCE`ejQ54QWvJ`7m1&fwV|7(i(j^j9iAb~oF>#U8z>e%-4{ zuHBoQB_PE%C#y|V(|bP-%Q&oS=v@bwb-{4BDQn`_^I^!?+S~q;a)ox~EN=fr5DO?A z%ECdt?#=p4bBcx-^7H8)@zVBam@e^U|4SZ;e@c(`t>6v1neJ6l?r`6G0%@aRwXyOJ zl{($GYJz)fiDU^`oZG+3!(iM@9fhV$Fk9r+K(w;E6#$f~6XKT>#xp&j$nn*4jE%`o zhmc>-)U4Mb{Jzp~(ptgXj&*%_|A4kUNuDlQDQ9YmWvkFx5PF^fvXHemcBV4SgM|M zJo(90FvOie0VCrP%qa(NFDZr0&R@1lh)aNFk{0qOS^gQO4_-W{iur!}5m!@#JWERA zQ{tMcvQy-;lzA=ebx+SY+3C4a{@hh?H2*P}{lT{mqElkfwoV(r?)`~pMr|Cf&4X=~ zzF2k!R7JbP!Tp+)R`Qy)sM_9zvxBNewr1_HHmbCmWsCOR3XM$r?4-S9vf>(t!7NRU zLy$!|yx{$Zb1{0>UqNwSvpeo;0dzR>GMO($Ri#Nz9eDE+x4!U+HNdV2nm_(iU^Fxb z)rTKAh)qhko^A_0m8e|f8H+zzb|jFYUgYLT#Obd7?8C*#4u6gq$(O`-Us!*HQOH=m z#Uj@5C2Cl1L9MwS(eMj<##H6C?}3#>0!L;xCTIG4nZF6gKRE0Dwo-h zTG0)x4KGR9O)WoSx$V&a;`??<-$!G5a&P%DFj1Dt8bMaDAWj`7Bxdgf8a;WhU{}y0 zwjHF|QhmGT;GNY42wm4z9U7Q8TW-L4KI~Yg=smAZ-uHY*h(uiKU1*zmQnFce48~(_ zOQdaolh{!%he}mqp)0A|XwzzW+*p^jV0B3S(f}X(>Ns4Ds#uNu1PH3V`QSbE@$=1X zSF4gh@h|a){rP?6&<)@D;$k{$!&-Ks;>)xxsmdw>wUvvK_WRac0WXi^uTTOSFw18g|s&Fe4L?M z?}xaMVYdoZ)`5g#?f4ENe&t`oHAI80LHjtIF&dv(`r-!~8x!8)tciFp$WgV0jW|1K z!p=e~k}fmkAG%NS;~QfYy4>g`boW-@cvd30oHlKoEpFEQe+nF0{rk5i4NAg`gGi(P zO_9c9b451yw)bGgar>HB)>cI6(Bks>)W$DywVhwy%6_ZbS5kgp!5Sr?$wnGM`ab5B zK!(@tx+P0?XL**x7YE5(pXXz83njT-=*QWQnwq1yt5QDX5!1Th^ zC93|Dsyj~{_H4Th?@j#H(uzodJ>f>OmaKc>CTd8dqG`LRX+2OsSqFT|SvV=PVxr66 zgqTNfZ#6i1>-#9$W|3o zLOh}RPdvUQ_TuhgIVRH}@?M#=3+*Zm<}o4U_y!p2-fCE}&=YNNzqd#EMK2+#TvS(W z@%m0Jl|WH|(V%j=!243>jA(g3t~&0WtA(~{@p*2P>W9>klO>wSO6*W8Mz-3WkXgQR z=B{5sAi(qvsX3B?y5>ufWBP6J)RcTSB}AtCx5l(u_xye`v3H?PQDe>-3fah+8=jje z(;e0`$Q6Yer-bStH)o=cAZ#X|?NY{blC!2re|jrxo_$Zhj5|9m(Xt{W9RkLH87!jH zyXKwBntTDcJ7#VNdw^NS%2#LBgsjSDAAEBh3wsr@sr1f;PVVp z80VA?$jmDpIY|~J3O@!kJVnh8gJ~T9!`Dn7wUK*}GiBi6PQg?_8xrW&tha;i?`XAc zl7%>6%Q)fY3%GSNw|}^qs7XzSj%ym>rDMl>Wx%soP(&BXc=J`7`(^9Wt&5Qne=rNU zr>y{}olo_G$Xak~m}htU;KOo*CPQK6V`^Ev-`%p1%fl7nwuu*wAgCa}**kthmAUE` zJ9!XRvR3ab9ai&kRx#Z~I5Av5>Ag{blH&ABHvt=46P=KdQ+h(e8wrpe*gVJgx}xP4 z+lk1)GrpN4BZz*R%0VtBfL+J#=OW*3Kdm1{_RVT=d@7`v)&caHza(PF6_Uq3(sr zl?-k21v>T-0k_#o9?UF{1|cxwv0^;I+tK6q^!9zet>+%G=v6@5g~_v^GH7HNe`-?I zI^BZ)n%QoZO6m(j$)+`?K-k-xW56Qz-3rAtnC>~1td6H#_`C8ZH>_!|@71oK zqBJS68&m(Iu^b*i!86-;tm9T`)Ss3YR zpMIel!AmH|!r1e0FL=gHn6cK+CZfM->}b#iiAppUAKh|Hfrlzi2=Zi@YBKhfmfLXw zOR62QGemciBM;YoytzkHK7YPMK=N%M8VWlbFAi<^8H1|gF1Za>1sMdOOBz)Zz2fyt zg(w70W(SRKWjq{c%LV(_&IavsDw_yG`piWk$FW0|(o5)q5SbA@G&fJKRJnv(#5PW- zz`kskZ7{kl?nb#G&K;04^UIZc?G3L}yEoF9{X)kpC$P2Y443*V4}%D_ZyqxOnrLB(a@ zu<0tK{ue~JDvJ5sO?U&**?k)A=1dRm_DBn08lIBxM%e~l=KtN(E%5Yy$5V)Nj?(*> zcAIRk(Oi@J`F6hZ-SE@+d`%4ah`2+6WLhUf2NPr>fT4gh`}v0B2&!+$sY$iRqq)I7 zEM<^`D0Na$o|#195-Ns{(T-bB#+MN2p{E8SAz*(LSJCe9Lk8;^uy`6kYD|f=}8v#ecv-rl^EMO3YVLX@uj%nk38+ zWR$j=0Cu355f*4LS~CFf@Xq+Tm=o&MOY&bcYiQ`Z`y5NRCma3X$?E1%YwbMIqaZBq zu^I2{r087gk>Z83QRkG5LSd#r$Xf=2k}E>eeaa1d$yA9hftr8Wm*>{Lk@ps2eN+$K zsVydLA%hiFo2+Us3`!cRN>_A`rrs&WJL9gGfP8Wy&H#+p<013rl~C09A}T7~ z9)D19rVS-BVXq`XniNx-2befTBMLCA8v(Q>|}XxMzLv---x zEFk-PH7##=JV^>0F9z;5V#2S2%5Y&HvF_Hn?g3$9_bN`}yX`E-7qIcpS=urm{Sw#H z%}CsWcUzd6a+c%OS4Ykxb^qiG%Gp1C(lbIqsM|JlQ%T={)DVQ;KYomn9@MUj^ZC=^ zbu|+vI}K}0KX9$=QWwm7-a!-SrKLA!KH7b$8p`B;ne@HK2~bV>bNty!oO0&;b?HLu z@7m|m4Y@-C??bB7uv5KM&K%%LAjV$xeRb-zjA7b8A#cfw|la{fgv2R!72K{GCA`-*!mSY+ab!$^evXaRRR0x=d8L z&m|hBXO&`8LO)cCfooy(Jt-N4c{e>4J{aX0>AT9ry?~i6zdA)5s|Ss2NygcaW)iiv zY)wbt#~^uX6P^hHF8d%>jkn1&x7bYPNSr;B;2iMcYW(b11!`$wd74Kn(psn~h5*+6 zyj=WJJ3rBppzdfWRD3_e4By13b8W=CtF-9%@9{}Wl(73(`9z$0ww)7jf@*w8?Aphz zSQ*==gizI2nZN0U)|L)FW?Y#xb@?AsKCD?t)VM3ZnYuoIj`InGul$IyFO{8`MStuN zBut2V-BZ=V#2qf>R23teXT|N2W43DC-fr>E%BNbNQ#0k}J}&vjU$W7Ty6_UcygAL|=mgT7z zm7U(H*DI}oR#>R>C3c((H28qkxQqs!SQ5m=O&aX?S9N!(OTJNhsB@|%6vQ{Rc9q3^ zUkWWYtEAbGZo1oqL0b(Bbbem{%%U{KByCNKFzp!8JRKC}8VHM=Vb& zlQ7fKm7Hn}v|smxwys;2iP4QaDJAtayA{70s07u2P*M5R^u=vHOUe83D~J*Kva6ia z&Uo&6L_k0|$4Fmui*020D6eU11lrMY5g<515=0kYV57RyXJau)(o!>=v8`A%bHTDT zaI%|zBRiL6M@HDA-n2xleMqG*v8?T29GT!TybQxA1wEN&Bvon2$}DxYzgA!}wl&Qm zv?aZ5^L4{cX(@yeSCb*V{bt#z-Q_5 z0WYtNl~oiq!0lkWq`Ykzgxghq57LuBiIz*$Y!MZ{l@|Bt{oFP7x` z0|9+Y7K3?>F!+P0*dEuue<7lO}%53_5ZEgT@Y zU`nDT$f`({)GQM~^xMk(+V3tm8{?WOj5Oz`{*2~2G^0yRh;h%>9ix%XQLGrrG3$VR5`?-}bw8&rz*?$1X1 zt5e6hddIAE(Ew2Ymy>U>2jGj#saBN#euCCJRF?lMcha{$HE9f~d((|I*Q@Q|lg&|1 z|HJSkKV&;{&jf|=UA&38|mt4_Uf$DNh@K3DsHK3pw zbGS9dw2sH9S`CYtm2zrISEa*CZ};*x0Zz?L9(k$TwlG7CLmuHtO;PJ!P|lD;^_+;| ziPhyXFE(v6133XkR@^1KJx*2E%d7F2491;EhG(|nXUX}T@ryN5P^;k9?ni`a7NswN zu{a*YeiOkrMFk-qE-UjF9G(|57^0gCXN_mKj`lf$#iCneVQY9Ti%D#)IO*oRm*(m$ zI$$#kbrEjzRSN#CSNx(_Lq$v}aqV)aqPf=y3^?7w9cfzTyg8~6J?{wp{j-jDUWGV5 z(`S|^RL%3u_vi6@QbXqo-^J!#HEq)!c$sJO)%*Z(#0y^2eu$_5|5`*M9_)ir2|9)x zw+gmjKccygcCgg5LPmXMzPyh%o&4 zUSjDVSuF{{(HBt~Z*p3IWVI37Z%?Y4`mK3grC-r28 z!Wekkl?F+9vaRu{CVVR4o3czW#Q~rZ8ype+&L`?-8?TN<+Wwk~THQUt%kEWley13=XAo>Tiy#(vAW z7#Jub!n89N>GbY*T-H(it#yLlHoRo&M<*5k)u(b}vXR-`@0ooaFux6N;6RD)ZH=6F z^#V^51yMubn+@<^-v$IuzIo-9uHnKEL&m9Qnzs_-GxqkdQ;HJufdlfb+K#4_A|jGi z0EDTPc@eom!n1XT1%f|hkLS5CAwNoRG1V?3($~UGO$@2#US3%aspsr}5Pu=W8xjnP zPj;V>sCt=Z^u311Ds6W_d(R|ASF>IePz$vy^%(>!Z&&4bJE3A6idfqCo!moP()XG}X zhq^=*4q&?NU;W&e$~r7fTU;TjP6T8O`~UWBKN`_#KS}_C5n2A*K&0ef@m%`se#dIO zj(Gw{5f&pif`<`OD#Uu&Pfu*3IHppzoyAB{deV}Pck?RWpI)y%JwvBJyK)fCG27IN z6nAJaC?{5{_va+)>ZoMWu4%z?{MFYopDEw1y;M@+=n*jnLQe#DJSeW%~BInFaST21#CTHl%P z&9?49R08^gIa>~*kxOT&n_QX%_jON`>vr%VcXSM4J`OtgxFO4T>UboHa8qC-1-l(Az5Z*7TS>w5+OUav=(ZhieESPxQ=&I1eqtdtWh=n^@C{{C@d1ZW z=5&QY;Z^JgXgX=83J3ckZ`_#HK-esngyVhTt2W{H)2ct(%+6S9vm<8IU$5mv<9B9R zcv~55LT?nRKE>>L7G~=d|;FM~@baZ5WF+A~s73DBD zmcnWnV7EGlk&VsDYI4v)fHyZh zSlV5{NC)Sr>nJzZ6~s{lpuXbO0WndFobsgYCKone+HRAUOe%@2OF=I+%3Sod09IOc zxkSNnT7|lUVd+?nORdM8PB%9@_*=ofN4dQ3=0_bm5;?Usei&<+l%jEXh4sK>d442} zik2cx&oeL<|5R}e=~Ke5_Cg~wLb(nZl<;%Km8fU$sYS!F;dCQCu4a+(E)yb)u7|Dr zt~7cWzfm>#=U3s2zE>hY)!^@ak%O(zuD+VY5}Hqy8-dTFJTt#@@oY|LsQ7kgwQlQ; zT;Sfhn>iad;EM!<2B3P_?B+?K3)}8f&U3ZwjJ+VoSxpUw9Bd|&LyRp&ZQlWj?E-%+ z)ZQA#nM2A;x_atyo`f`qAr5Z4q~E0;RJAXoJa{yo>ms?a0}`tY$zxd3h!VHvpYyPd z!yK%#zh1F^OMK90*Q z@Fah9Gs;gUY);Hwf916);BstoI7(?dxMHIX!rSxPoo!P4kU`AdGGW5h7d(iZdfoC< zziwfdSKukNiT5N88Tm5gq_z;4drh~~hrGF3JgRR%ZDqBbI4zv$ygN#71EXk=izr|7 z%pS_cb4D<-Nig00RIeKEmp3x6P!s`g5Z-S9ug+lLQT=maEbz%w?6C5-c|j6=`IG6x z%_&L-=$A2m^{UcgQXF2lyl108TW8wN@vR@R5M|TiRfb?>vdaAITyt5wr%!-{kH@@9 z43x^*MQHL(Fo;42r=E;${B`puo0NB3uR~0UsS;86&V^}{8~Jwq0iKQ;*1P)Cyk!mU zlM9YD;};TYC%lp~IUR$Rkx>2BiA45Pq~nGFDsH)>u)cP*jkm~>PlSYZrnT($v#p;C zWxWN*jeGOL#LI?HP&vXM8C<-zcW2cySEd&-jtTUd$11j6#S_khUu08^n|&^j_Zp$ey_N#+0sx;(go}P6`i}aYbenU^g);@n*T41dYRH*9 zvZnnpA_^rsL!Zf{okT#ft5$U~u$fGmWt4`y@tAB8F60cv!q^yv({n) z#Fp+M1j6OBfBsevgyPz>`rtKb>3_aZ79QGHVe`BxEmNS7N`Lc=+R7~h^Q-kShzh~c z*%3;>IHg67|-pu zG0H1V%n?t<6!1jCCPv0%JK<=xK%x z?Iz?pd1Z=iJf6=&r(^iyCDjTLUq_z~@vi7q&DqxcQ;e;BL%pvqlj(9-`3yY{IOC{m z-P0Zh`vKm_s&q;x@%tjZzs%;c;@(#wqSw%?CB3-Mi||oIEK(mYVpp zjk))KPG=q0O9XLF!E-_e5cl|9vI7$mR z6J|8^oUQpgt1=I}em!O^R&)BYp}`_e>D}b0(3zoK%ao*7^LG`~6ff$H{J>dh5<&09 zy};A8m=@q}+U+1vguoNw$ph(bw^7W{tzW9(@~=OxBlPH+_pMVFy-06B#1N zgDN0G;aZVR1dXn1Y`=f;rRQF9qg_xVhS>`KoxBoC(uVar_-*13M*JKbfuKIkmSdp* z!E4&qcgN$zx95IiZ~bb^<}%V`Xx)7Il|;jhnCjF2w1AN;)b^3K*X3_(o7KPcLaS~$ z#lnY*X{6Fc*Ra!R&SJph*k_8Nwa*cri%nlTUpLgAnOL?~C_VPj46zvkq|5<**MUsg z0MO*OSHt6T*zy5})f6%1El~qLr)$Y6Jniu7Zy5c@zRUXLxX2|k( z>#Tgiyb1|Y@J@7h)#H8&oG|2!sMVCeSAIhU0atRgr_`jaRUGkvOx!LlsP+1UK6Tz* zH#g^DzD!N2zgxg21lyyiHDJ+`g!kI>M7hjG`um7Zzj@C$g`XP-tdw>|{v^b_i{ zJ9zziNy_cM%%f`J^9kDVUZ8Z)SMlhg)r?ny5)r!KdZv3TB6m^T(d9uBm^~8Z!cHIhm+Z< zt@Fq7n*E0xC4Qm&Q9crf+$D!L*pmI|aw+;|IGY@~!p1LC7plbD`&f(1pw{F;7>|#? z@sJH-kDT8fNMjJ}3Iu{{CcmlDQ3wKtN|tQxjw+3qz%^%Jtaf*YFXU|_>YILs77+3d zLFSI(i|2-A7?DRtRA|!6uZTw`INZWhyR_g8Ve2nSE$Jg^J)Tx?Rkl6d3@pNY?o(*~ zsV^38@}WqBlB&^h!MqdzbKh?&H@;_G@Kt!Ds4PG)Fcz~rmQi?@0?CVP5A1Lt zyqwv5vYmQRoJD-XvjvD~7JYITFj@7IetP|liW>7#4`tCEjr|bk2@W|R!am_i;#KL2 zhOumON0avp!7)S(R}cB&=KSe^GvP$ykCIel+Z3l#G`4QG`-=(^)x3{EO8xFh4X(4&k_7b{Na@!pE`&~$X5m`32$sC``WLvfPOt2 znbHo)Q~vlY9e^)VLN9%Td@TZDI0IAoJ2#Sp<}|`vQ&O5Wisf#FZ_2tgn22)={M%&SC;iWE>S*xtIg9q$Kw^L>k`3 zoEKTMDWw7ITz`n5H;?w!&3yfJ_!1nYA$iyVcVx0@GHOHggbih$i=6?*%w8-TfOWLC z)RE^SkD8ZRhh&vQ6Xze)FO|Ms@46^!T6TQ9V98z`oiXAq=diA_v;G0@b<3>qrLk=n z&0+U?cOsgGu|iftb|Xa4ZzKZpwy$!NmT;OM<2UsZi6TgY)MxlX>O+3Vu7izRjwCRz zt11)EmUY^3(sIG__aGXk`Ip4g$9+hp26N#mF5w)j%H=M+88o<_5J!^nsVa+PkdD;7 zW4rviBR@}6jN4}pYHq*o;AeAH*6~2`K+D?x9-6bcgE?2M^bHCfK zRg|u`wdEm08&)_Z7H4iJX5968;fD0%8UrBnD{Njmv1k8+v~<<#lfxXI#M}d{eyY%}s5144Tb6*=O?M4a#+| z0o`?>8qXq1tJj|$SGAg7bX+ZY$@{Z_PQ`SlQ%EvS{6!7X^gZjT+aOK@%l2~jjEmcZ zV>#F#iw4(Ej7+9ZH+t@{p$@e(&zHFRUU_uO%%1&GDk|YLtdghAJN~P`+FpjzvhfgW zAU;0b(fj&4Q*}FsRnBg;C2_^Y#rx;PA43X?|Ld0isebuflAR%2Dp=orz2u1wOFlc~ zOWXsqhEZX!#9w!EZQGV~n_SvO#+ge-ZE-lbm~v>u7lD;v$OFUD-umx5-?jjoc3Hs3 z)|C4WOZu4GeS4J8ftUv#E9koF?IX+&MYFEZ*&B3nW}c7ZM;bYLp9;JVomf3gW8rR? zCBtmp#aw|y60tjJZQ5FgRX5~h3JW$x;Gh~mTWjkv-tcJM_*pxz_R{^IzQP7p2P%*Y zasI8!hWx=CP3rV$zU=jD(6;x&UUP0DNtv%pK6%fHz}7n0fV-8?%v5hY33e1w=bL%U zdZAv|GT>-MP0CX=bzcS=n0z>cB$?_Hd{$Cyw&w%7TE zzhsB75|tWdgG6({+Ems%u8h)){|*8}|8)Y&mY2HI8BED~q&a zd?6NAU?o#{;C$KXJ}MSbVhs|m6C&CR&KzgW{}h(pVaar{!ydjAY_TR%i>^v{yL^Wb zai}jWao^Q=0D$|E%Yb+2e!~Taa%>#y?}blsP4C*A==S_BES30%#>bcFvi7Y&K$1Z3 zDSxr99dP-Z?c%46{Mb&TJ3x z+(4SwuV1~hrz^e0OyJqGR9gKy6&6^misuNZf4!>I9**crcoLh1@nvqQmobfnIOhH`MWSVbo6tZVeUVT)KK`G&+(G#mrG5FdXg79v;m1ukXYok;j~ zy-MCLAcyYWL2+YW7RxIBe9O@88GVzQaK*#&IpdOm8^L~2Z=>br20c}R!>?qJ{4F;o zeks9K6hjl|H1jem4;KN|ho8CR&#K3c`*`a2`*^|oi;VbgF8R!NdapvV2uoaOh2;&B z@@oU#Azl*J3$p|e3tbW|y&P;uYa+RE@ix}hD|Bqc)BJhjT-#NOG8Cb;zkdXWXOXGR za>3b>Hj}~qCZE*}@C9zS$f?eF?m8$X=jq&DZC%SHBtS$$qAu?$NyroY<0{!F2`Z(}1cf-!l4WuKf z-I!|3<;6Kw!PFZ_X!N>Iedgnrf-(`Ydv)oJMC)=mpND4Icdcmtg!uy54L4tWFaPY> zRz`c=sx`engwx;{Nhk6)7uwNp(`4Gv!iEg`z@f?Q?>pVe|C~ewK?CtPC@){1zDHV_ZKhw zp6;!(?*CeqCkE37bl@DoCrhMjy%pK#tcy(>h20jC7DbwRB0$q;)U&be)78C0lYWOB|S@99hUR(Aw4Bkqx`)XwzCSsxFP@dJjZNxj_rFpbv5IWIprAd&9d1>tIl&g;ZOfhAh7Iw&F8(^$=LkM zd;YRZBJ<=2W7(xZmUj@d>{7PE2`!sfR{HZS(vb)zPEY#nQ6=?MCR9N;f3tG&3W9_zse>;AOZxMZ%+B+;E^(w=?y>8T>B=ZlPcJ^^JQ#e5mS{5jTk^+{K;^J+uf2mc z+s_{tHWbY{oEC#4feJz%qi$(FLyH)%eCqf#^AbZ^<@7KBLwK80pe{c}>I_EA(bT*E zfQhBp-bodB9~H0{QYgE31Fc1&PTR7ZUAUw0GSS|`VxcbcU=v1?T!ScR<&RWR9vGlOuZ!H_ zD(jyi)1OZ&$@isLhq#j9m7ym@g8L%rnRS_Y%y!mJiWecl6+XoauWL0D*9}srQ!j_e zKLy1&?sH+Zjm0Gl0o6kM6o|-FMd{*+pU>O_L!+0gsEqpn75&}kcqf%;)gsFww)(7k zX^E%8_Io^CZk76i!uP`NzQZZvm)>w~B-Gnv`%)dlmkPI1`E5+i;=-a;RXsJ1!1%n4 z6}JGN%BfmJKN$uvxw&j;hAnw_@3~M5<=j9=a;Is7o=h|UZ%epQn9c5#_)wu&aAun= z{7oNkEHYcvd{6CW5dTa-Vfjs3@3nUQ?ni$b!YD1GtGh^U`g!H@Mc_81U;lWeA9 zo=TjJB08LgKG}A_^qx8DTxQr$T9$<%yF zdHBG0JVX3Wv8P7N8y)%Y;;j4SS@HB2i;M!^Gsg4mh$Vh!LK+pT--6?yjJEztY>dz0 zjqBIvNf#(1Z)vXs5g|ps(i%Ht~9fATcdcU>pz{C1K zEr5zuQplu|HqBu9tS_+pDS7Vk(Q zYwEl+!5WwdOj!nL98ex4#OmH=X^Obx1%9m)@Ln$CX1 z#R$Umb}9fPe)Ne;JRNzMypUhWQFEL8qS$jWfRrFGd9dD5Oop&J*_QKcL7Q4Ze46Lj zF0+z);OD;;E`wl#&(28=6%**~pIy&vuTU_a-naEC+hg9{R09-lEtaE_+>B6})aNq0 zwSDp*d!_kG=EZ%h>8(tV1uC zh+M3o8=SBFGGO%rBOmDTM2DFG1O|Da$-^`YwFV2C!>HolKDUSvFNy4)p@+@r5;9Zd`4<)KGl8yM}jGX?v%YHPSD*#5O8MC zHB`Xqiegi^7csjrm39OUnj8@9z8gd1IL(;5NHQ)KT@jH4pm+-_oGKpLrFmYKeDK2k z`uz#!)f&V)wO&&F8Q;9)Jyh3nrU?Rq&-Rn#cL^uxzD=xa-jlusx- zV+NmV1#t>bl!gmdaeoNX9;gjc9@(J}t{oaEN5EOjD8KjhqaQ|f;li$sZ4&VIGU1Nh@a{-Z+%)?qE7hGz0A8DloLg>6>s3POwqV=3fL0_ z^E@^HrCeudC?8q>6&_Y=Vavq8ki*b7l)*acDUNTD?@?l|`5t86?5}u{NO;6vny7UY zx+>vBw_U68H2ChWQvGD}HV5zVv_e2l$tU3YvHVFhsw71lx=1&FG3N>Cbn=ULbw1Wp zc!ECCX1g@M*dd5q{-%(Bh<4Aqm^1j_w*5~>1qCVbE_H1<@H6yZJt6Q1c)=Z#j90Q8 z)3(EP+Th=lot9`OTQ*t6`z3V=o?F>g8yLLK&b(w?0JP366*=Kiz)g|8mMQo=4Cp!1 zfSKsCp8b%>oVG4%3m3@v**It`yUE4CL3UBRYE>~K=aAbpU1QE*Tzi%lfYtLD8}Ox- z-{H#mNix)>)Wb`f)RsF%YhcT;egJY_FaUa?X)w3W-AK!?A;tJOQJz!4^tbC&<NK>r=jvi;D0WoQ1321I>gh~}=Rz7&*sU!9y)<+$I}e;uuEgDm9f$CU(wtN|NuyQTg8 zFDC$b#a)BKvTE$GV%QX2PLnCQ`#x1pbEkv*zLZ9_PVZBlQ2ke!8bn@m=nIv_=foP; zfjIZ2v@fvlm{MmsIXQmbgEG$+y@~!#MwE*F3g>F3jF=d={jYsAv}vQ13&GlzXR^)>!4Ym?BhHr0>MIE_yXqS#g zF?at;vRU`Co$PV%spB(A7N7N@K6tjGdph2 zJ&R%ZxzPD4&g+p`b+ID-XC8n|@^>D$C9-Tg&G4a)8->d>E1~^>Xir zo3>`^hTc17Dx6(f7oX@nnlCMDEsr6RdIfltpdR`IP%PI=7!P#)An#IEMJygeDUBPe zJw2deWzP-E7}M^|I^FdT_-F9occj`YlwjDpxvEpZ1x$aE1d`=vUB+nlq@L}D63+T~ z1(N93+@w9fk7Yop{RjtHNkA90F~5TH1>vNH%YFxi+M@;~iexlzZ8-`4tq;4Q<)I|d z_~CdA-D3bt(QhgP`0403o=U}ndB+lpu#%R04!!dxIMd1}U}PPlK#b$h!cWlH8YPKHVmtER=X~=K5>F4- zDig$|zYf)>rZ!^=`w0mHTOG9YWgW(i!4s33w%68YtRvdu6S4fvnh1@3VfO!4ULpLempwBMXnW!J#_Z^F6p&nxjVG}#!q7Gq~u2eAL zd$#Y|VRSWvx7oAXukhx-7q!ymJZ@EQd81=q!ogdM*%0Y>22AwEl0{TiycXHkj0s8t zf%j*bwCq0E{?1GHOrOI|4%n`k0B?|~xBY1RRpe%6+V|^YC>PJl>cA5W{O!bjOfVKP zk_QjI0jdg9O&NAQ0D(930*Ag(@;gp@)dzm6I4BrG>5VFGN&zhMDngYNU&KoN~6rs`1mQVO&8pGsOmHv9@u_0yP`01f?iekX5r z&?nM`p4HiR_0I7_4I({f!OT{lONRe()Zcl=^J^p(ROr{Ap}VDUnparx=fxuPyrg@o zmeo-yDKz{uMRz(K+n{|-a0#&0%2npm;uoxj_`*73ZOo}oShxzK)}tpe#&*V z+HVXDiEs8M3gY;Vq2*EGHi#oqPMf98*Fl-1%(sOI1)~|Qb$1WU`R5z`Uy-8D zZwRsO4n!tI0RDd)?nlj_kMCQ1MeiTP{Yry2Em0V>0bYepkio1n(w`0FPr!UBZH1F8 z0xcP-wq{_;qU-imjlUj_jLLy-QaSd1xExlIExpqb_|o}x+&#Zd>2aIKng;Dibb?n- zOg_$|kwjI+)BoiudF(gii04E5RJIairUm{N`U>BqU6#I-#ECk;FL1ECFsN1J``=zs zUe8Y?p8vEO1?iNhKdm^;?$G=COBG+P#?O!<#h>BYsjqbSWKp>JyKfd<{uCI<#VZPr zwgO&Drf-PEyCq^z)%qK9JT_z-TZ$@`o$I-u@x{RP(a&xtN&G`EIN-*ExqQ^A)>vIv zQuI0Q^=i}0FyGq`P~%SF(Ip4#=}$)s&x$68R#q5&@PvmpTUNoUmM~QcjFs4X;(HtE zwu;VYgE~#sL*A~Zdcped-3uN(uv^@0w-Gr-j5(+_cWx`=pRc~4l#ep3Wu9N+tQ1Lt z5`FPd@Pr}LOS9wOXe%OGgoHO%b!yoU4A%!cv;2(hySeiKaBPcp4LRwvad<#P*7OW^(?9{M85~ z=13H~aK^8~P;2e&B$(TLN_xH~_VolngqF3-Wh-dud}{n%v*}`r!@+|I8P%J79$!9J z4q2G<^*ZaFqvPaxX35dzHbbnSl$W72y*ED09!>U9YG`U^dQ9`!077Yz(?7MPj_zW| zJ+`L#|G&ea`jef(*TOV?mB=SRt?aaDzNvfal5Q>o?T3YgDctI81>{&e;OMpdBMG^x zNgYcya;o0V^CS@cwP};#yEN7q9qZEmO3m!5LBIz@_qOTg*4?Trwk|+bl$WqLBg?e? zn^4MGfYpnh^9$)pZL9Z1cN%0CXWJ|^@|#_Bm+8}3opbkn<#wK3AP8MxMwx~K-9ujw zQN?xsj{W*BW8|w1r5RZTEnj+YZq4$&z=1nhI3+NeJSGa5!P&*JrRF>KyNz=BOpWME z(j8xEeF>tV>FL{?tGYELs1#P65h+?pyf&D0=lweiZ+-dvUcT{;#0rI? z`qn_q%k(9Vi3JfCDq^d-ngj;p+3fClaad8p!_G$z3uGY;wIg2a74x${6V9%@VVQDU z4Y}sF4{!wgoUe-?JriJ+4rz3BeK|AFY1@apgXI@}Dqnn4clln-bd|Jj=cS^r@Dd%fJKVYaNKe}p4+`LksC;>Y(S zG^)}p#d3MT>12EMzbDn6lkC?Ype)8EnL{H5LI_O{Lt35myB$LY_vupiGvKwz3VH2d zyS##if_vp05zah%6i+>x(TeI^q7;XHyxg}ss8taOeY05mKj37mZjR=T(Go>9Ra+IajIK!Tcg$J zPI?|0{3MD&R6G`nn%qeuvbk8J$uzmMzhE-lnt9vVWO zr}jEIo%6v@2Iy~zy?%+FT+a8Jy;!OtHpzH290p~-+F*>yl1}TdlyU`X671B(W$d6l zJ3UIAdk@FF%MuJ8tb;>;y$)lU!J<4NG?>PqZk$v3^jb`f`;l0W}&5A%@IgugqQ zB_4)ta8FCWWnAkJ3rhhTem$}yC@q(8-3A-~uOJUYJ__+9<^ra#Y25+c9g*TOy*;(B zR8d7s>oBj4nF`@b2`h$Y>>fTmt=da2=HS~*%g{QD#;uDq@Q!sTE*kVox%=E~kKIY% z)?PcyaaM^xLDMCDO1TRRe(T)@)n0A?Si1ZJ=9sYMsVS)8&e)%T_&5yEz*)XGbx?yJ z_!rW^&o8mI&dY{203SD?2Usz;3diwfVWnLh% z1==?^Nx$yDH@A*kw?g>2>0ghplDW2!@;$?n01=es3o7lCcCi2*BS&uZ!bALIJ7d0+ zfQN++FysSEK80`!HQa&Bg6Vo{UilhKHCw=AAaM@{B6I!9!Ab%yu;=eGxF=_5;Oio@ z8^FEfjIgkW18RlV5%Q3T`w`FO4Tu zScx!Od0?2e8{QM11WXFX|L}1pn2+i}@SFLoD2U6DK(lY%W!Fot!hP)Ew}kSq!Ow%U z0=#F&uh)7vWyp+reThzJmVWC&c@3YNPEj)_V)zI*KX2|>R(&d>mN#Wk4*ye@`Igb2 zW%;GNX5Uz z_dRvsNn)Xc@W5;;zjqYykMaU0iY*l@sG=B@Z7Myxf^DYtLc4GE8wK*CCzG0R`yxSU zYkh|a$zLjaL@}biY&7iB8CZdVBKT;RM!yazu^9@=S}X7p6^UWNCM3(}yUR#|#*UaV{gWTgzOFfP%~H7{Syni1XPpFr*J zo3Ex`qLVxl6}a}#zyZHD4<|%eo*^O=yN8C>JCyF!6l&eb{hYIw*!iX9*T4~nG10Dr z^#-xXydYT;{(*fEqOLRC&32MC;lCEwn2Di$7@BWp9T8%hJJI8II zj_K+lK%j7l*?Fx^+e@5qNLKE(jpIOWBS__&nYz2RRzh^9%kLu|rI6+e<6BsmN(3}E zMYGnb{IV!KaiDW-x9=-`stv^xJ})j18z~y+2EW~ZJ3~7|q{MuA zVzDg_Vm+X7(B5UfQd65olNu;D%iAucr($5`@o6Z`NkKBgXDi9|cnZd?scBVUIGmYN zp2m{MLXW8pSoue<*}&1=FeH(LUiuWmitV3~`Da*`2|}EtCQE&Q*_W6bP`J2^Yi~jI zx;Da&Nk_DCzZw$v;UZ=AaWAjK{@Vb)j4u*T4jSvIkSsz zOnu=t>o$N2X;a_g>WE5l+l5Nm#104XXIG-8+6$)nvzUEFZwsrfHIA*cDjlatGA1td zEVTcb?EPWU>j$MbN=U{ry7J(;dWq(glGe-1OHzrRY+zspW{-Gxa~NfJni;`ATl|l} zyuq_Ne}$4()ubD;S|dQy_q}aXsi7F@`1P(q)wj;l|7vTLCkRdOO~YqGf8b3vp!Zq#k%f+ym<3e{_)U8hk1p%(d2sav$D&#KVH*yVy~6SHjXOS&}CCE z!JoX6i4gu){O;M1ivn+-f{1O^z`_w*%loc33dmWN@|$OUw#Qpx4)^Vdj_4<_sLlQo znd6Y;*3of>#JxN$S;d37Lx6A-4dKB?oya|SeMLUgOMSq(a>a0()X%dYGDxOc)%p2z z{^On3Y2Vbjq&Sd8%8RofRx8(rPhLNHsmEgEWKi>^D`?>z-(*QZf~1)K8l2bQZW;vS z@qY~Z6u-0khDZoWfZMEOLrZ&{EhnKVH)lN?{vO&>m z$u6t0_d5#D0 zB`F71H2fLKcT$5sJ-slSjMoC6_~L(beC7PaOYx?37nCtVo){!|oq{UsSi?QTZPR%9 zouhxYt!VM#P^PC_S)nGarDAtjG*hdhpy1F%-AOGe9J35jzr@n(Llxorj4W_vvsSm< zNVD4LWsTa?s^4!Bb`LbUAne|4j{Df4P#d341%GIgYb@Q#lZjT7RX-8E?moRkpIT@| z;X$ufN$7%d;u`_xrpXGM0=r|;Rn{KP@!Dd-cpmO(X-d(%us>fwPz~9nEshwd+K-v= zcmI`qK3?ZB(MTUwCV&5O+&1wPw>~UgBwcyvRPHhCd@J%FSlu&(b=U~dWf^8Lcgaxq zmHvAtzcW5XJHTjS)VofOG->JMo!B|g$tUT0IhXq+?w{^&*-61@*$CnG-Hr6X6eV;= z&RkV=i7Yq2Vu#!Nd$MXVPH!YM;FNT|+{X3#%a&ImhVLY<+-+0<3(y_R<3|Aa&2>^{ z^oMlyy*RGE&1??qq4pN>2;WqH0^I18=$nc#^O|jIi~02>kG7$O@Z5q=_s84e4m@Bl zyz6=Gob#@kg>D-UZVYoJyP%o>-)DG6e2K#w{&c(Fy_V%OmM}Fn*c*W|K50Yy@#8W^ zih@CuJF##`^wFXMzOutfNoazyP6y)yU%3f#s`V`i`E|H%GUW49eY8K&%Dduh^dR z#ESjO8QP$|0!O-74P1!7Xo%xhHU3cXYW%=Vrz;*q0!^nQDix=_$7pLl65q zxuvmwx6iaXGp})Fu!xt}!~{1}dAN^h&{H*IvC&@Mq&xtOqY&rKISyyp>2b&7h%i;( ze_>GuK=GDPGEEzQHi3j4#CAlG9qER< z#U8^113>yKG@d30PhRbKt;&!Tu_IpIjFnVrpQRp8)I~mM7Rri!)SYlE^*7?wzVSZA46|-!*pOewvClV#cO))o@&izOlH{g zrWbD}JlHF+Fy~_*ssg8SgpxrTk(zQ6?lvrN#4UdRSxWxYevLy#(@2MJ3a-fRUtP${ z0XuYSr?xxnnEFR)=aRNh=VC#~afX(43yp`4Zq--Yp^|bt`K_CFD-LspESB>XG8}OD zXa%KC{(KhrX<&R5%oqN;xmYfW`ns#HX5SaBljsNNeC37W_#e2_{p5N+zjb~JEv)1H zfp`96p&5i=RCA@|_>NO=-62EX+O%a~8;tkRJQ%~1P-^)jQ`P9`aA`AUemZUID5k%! zBQw}o9;cyD$4t|!TKS|p+2*}8A&+EUVkAN8-EloW3Is;+J^!|SzirX1SQs{-E9InR z$p4SEEVkwY30GHGO=l)md)qY&cC?i?f7(@f^~e7C=1koV9HC#G@%Vq${uuv+F7ke& z+1bw8@GD5$p`y1jy;|eP7g0{tD_Oj^9bRu>81Zc}>4W-#sxT^=@YTmvXlP=9gKp7= z%)wc67~|I3uv+1fgpD=ArHOCjX;n_aO*;n7Su=6NnX&OJ`t-ga`h8gYQxyp5`J|T0 zERTKo)W{{@LDN1S{%lx_Z^H@%+Xdp2I}mWj&81+uIVX>IIrVM%LNY8kIK+bCA@EqO zs7*S5CAWWECUq=@jvihF{&zJ{a+1fe)xh4C@&N^hurWWpzP=UXn#sfhUey!4PcVvZ zkdg7l(;rk#9!1!rKh}7{1qy21Y{H{UA3RepO4L?_^`?3!8GLK{k>Y(Pq4-HnaOQG% zb4hZRezPMBc4FYtLK3;5ka5a?@AE~B|E^DXQbL5m`}GGKp~dY~`HH>_l=|)NlctS7 zsCU(3PR~?&Q_s#+h$P~Ko`f7O(;M`hAJ>r7ZT(-FCk;t|H`l4;>(@=&p0evGCJ?Ic zpy>lu^X6xGy6!;JnQ%W7`!LWU_*4lcl_PZu|4772!eVLny(XojD$ntUbIj=@U0R?r zjZ6KT#~geP7FknmN8#{hZsBU3EIB)244Rz&3+F#o0SPy$4mt|_F?^U`Up1)rJ4N2~ zldHeOS;U$!!LWFmE$(V>Q$Uu$cBCP_Wa>Y_;%L*4?q0Xk9}4LtFnm`MKdSz>kXof` zJ8}E<%|z*mfQOEiGG7AFW&`&Nd~y)0=VuEXxYq3LAAW^z1!^->0b_a%4SC`gHSX~R z(p60^9yj#qnBfc85k8uYYxZXrb(1?ct9lknAkTc`-*`Q^DaO)f4s5#hEHS1_nyF<+ zB}^JH{RMWhpVab{*Y%ID^p7x1o$fI`=R12LUnv46EUIyNUAbKCvwN`Pl9ct|l!=)X zMoY7HO8%ChQc?79O0@xwG<#PR7ZIUm#oam88d#ANp6er7_n#Kv{M<{nuy~ZsQQ}yy zK)lT0Cc_!uSWa|tQ56Er(5=T1a1+Xs{Fv@rF~2F@MT(`^n74IRqZz8m<$I@?76K<* z#Wwe_K!pA?#={>&_~P+f-cju*x&5cg^?}<+XDptysb9_nxRLxF8Vu25WRyS&p;+L0XcT&w7-T8wV)Scm4$Gd$Qepjzj!+B)%e1 z$6urO9@npeav(wr?`eu{?n$>~SZulxm@@i%nS$oi%C-jdn{|S%flOvhL^qd;V4onx z3LZq&8PNRRQ5m2T?s5;4Tq7Vur|c|W)#>f=Vpyci_$S}6lfXNQe1zj|M)bc3{u^6` z9>}(SLTc|rZazqTZ@e_fI=3Z)U1m zh5c8W{Z6QR&c5=zTV~SR!B93CPbn`{y8R*wQ?*_o=g;)AAC5$@a$PP#%B52~rS$j><$VOzY{Lg21Ju13^Iq%=xSc*j4J_E-HG2~WFZfv% z8Hr#V&Dvh$pc-9oQ2wm-^}z!VcID{c;v;$do##wZ%x^PfXEg}*KSro?kHa|E+JFz4iUtOX=~B)^CsfB zbNAIw5)2^8+oiQ`MPc1}@I*x7yot%x@ut{%VJmj-k}SD&7F-MW8=01xY;k`|rJSSQ6BX*@{D zk`+<#w<^076&6hz7)%P_wDy^yN)hm_X%yu#_Om_qOYb?*t2;qgtilU567FeH(AC!{ z>Hk}b($A)U!F=F*gWglx@lvp{`gHqSb8v95b^tEEQe+u_K0EKLS~mj&>Utdc5?5*W zWs_jsh2o@m0p>OovSf7ccFg6O<4heom0Q#tR3>uqAG~}Ua7*A*_|ExCC|g?syM81| z?k6QD7rKU<+X9+3-`~{g{l+NBzhdXnUe7lM`K%8yZ-&-TeY;;a2cqS4f7hmg5)qNi zv!{jN3AH9ZjfF8&McmB#!ZiA))x1#ah*i@E1psbqeg5ZmlkI#nLnn)KbnLYLTKtWJ2HT}`1 zqQ|=eQPi7jR~1!5iV4*GeTd;5!9<-%{u8gJb|A&f5C6k=#hH095ruwZh~Mf77}@UV)gTcUgat-59=k zW)L7;k_?E=nfdj^#q46fz+3}Ca-Ue%`0cV076=L#|A10l2c=d$Gfx5)F7Zh{c3vb% z@B5}pLqou2D}`0i>86eRL6CaD`{Ss=L@aY(_rBM-;N=g3=wJfmjBtVq zAR&%7{&q{(ul#`6=@s2Y(*yiswQY@1Zl^{uPGt-m{8K?$N#hsKAo(6*I*6jh=T*lA z)KlT`&GEaWv`G4!VjE(yZMjYdd2*PIot6&;W55C-ZNWtT6(A>|(-&^!KTewGc+kqu zgn9F07IPqdz{{iVN`=cb)6pOAUxNei{e%4VQb`%M*Z?k3B*h1Sm}%p5A-`O8k}~at zB!7E_2&3QcZcgIP{C4aqO9?ni>D6~hA>Yc%9TRB~bY^wawPC*zwK_nXIN4T4&O^2G zV-1^Pd6Fm0*5bu^DPD%?$Pn zHXpUkg%X}EGeVv{t3R?So`1^p5n2*kP_I_NwNiMvxtYGL;ek(4d$(t-kdI)~bzVte zg%`NYC}rdv1Y_oCqS>j{V=TQ%ERKTT8AL(;k8!H+k2lRM0Pl{(9~hwf{x5f}?>l;a zhcquQzry|f**l?lI>D`5$jhRNN+HR|poViS-lwD^!h)h;yr29n=gkd!t3B0_vr7%FuU9H3&YzQ|HPV)QhA0a6k&gLSh|pECQ%?D07&| zlDgl5YP(Z+P8KG62eK;(TgLBGx^Yt7xa%Wr6FW(a9t;nLFOCX?O=qp~iWa))vKTKX zr=S~$htSxRIouPNkBbl7MopPY4t$qIv~a&iBo$zH=n}@5jX%Xzx+8^(zt!sp^An!I zk_xU=ZnDpamS(V~Z_uR~lt>SwLI}A}Vjr5Y1Jj^d*cHL+8`x+TnQhnki(cu28YFZX zhd{Ct&PgDSfa7h_tn)e6K{Z>v?N`&8 zT{?p}z76moDjYba(9rCw8!y>ZN;^)}-i^0EUb&c^tKjnz3zR5p_~i=ec&D$srPZUz zBx&vQH9+6yS*NGRb}(y+rQJ)(UTF|A`Ds<1855nrHTI{49hyc(sW7f_Yid1Fq{ED& zKf4y$+&BM3P0wf#5{LY@WjvV09vP_{@zde^RNV|E&P<%Uu`cziyGU9Zh7N)jY{pf} zH5Q6s=w^joH|d*O9&Dj2G}xr7g-ppK>_V0F*Gr&y9KJ1_#-K!1aV zE_YFW?@l*l<_X7KvU|Ox2B6YX@N|}OeoxC2id6^uEYl|muD3F0y0_1tXROYiKu?k| zLNYQJ(RIy0@dS=g+JImFJHGuSa2{RbzA+)uvOa)1OSgd@ldhEXGxe^k_Vc@pq(v6Z zH(7@|Dn$lt+5es4K8*f242)q<@6dX+F=w^@=~J{Ifr-yIyjgFD-|)gvp|(H#KH}S} zx&FRyMG{2=2weZBNmb|9{G#JEy-%sH6`I26p{jXy2MR5*^P64gKlM7Db`3Hb$`L-( z34?d3Qss#KcfQq|>OVuT_$n7LM)zyBZ0McG%+=O8A7fqha?bw=lsHTxn9ppp*j;EH zo!bnZSOZkBwmimBo=yE_sa`-|8|dI*Z==Ep@yE#lNAo%oKhy(>(L_OWGsa8L*O;Bp z_sU9_H@U)tMcDahSTeq2g0yabm~57XTvVu=$%u}uGb&5{fEHYcE|+sTezm>YZ}vz* zO1d7RyRRiIWq8^&ci;4PXKK_$6a2YqMI-)O?cxwLH*B+E}9}x)i#1^9H?t z_{nqVQe1|_x$?mZg!DcE=^@T`3>X2ZTQl{7?hX5zWIAR{5nT(5hNHuztJmJ)@Al1< zzFCA7hXJ&`=@D&%8OC}6;Bt6b^D)3$7?!YqU%swrI0nu z6qGczM(uwFlT0YYKKSDVSY6AqGcCzp!aP7S{iKG<9%j#YZhl>!uD4&cHzSgu*uYid z0R>S<|ChDGksi_yV?X$Z(fU+(xs~?c$|Y^6AD|yH+31T5F&UFF@n&lUN6K_<*=qXh zQcM;lH_nA@#TaMbWu|`^k*6yQe#9r}gIQL9b+!GaC?t!AD!m`(<7Ez1P{n zoz@KTf`ZyC7~Vpwbv)0)?#JQ&RiCIPbKYr!7B)r%TD)2OglhRFsWc&Tq-pVHeLRbL z+s6zX{L_p0l|?Q;y=7c|rGXu>2fCf6US{9ul=Ypi;K|CgM%D(3OuxA2e&P5(ldf`3 zy*14E*ZFS>&DtP-(}n*JSzj3x*|KztySuwP4DRmk?(Xg~xI^Ra?lZVMgEI{73=Hn> z^7@?n-S53~Uc}xV6&?Mf_pZ!bxmIOX@rAfRR_K3lIT`so@-&7H{K#h`SlXcWV8AgI zohEaFf{w}717FpU>`-z-{+>BG==v?tL-SL+$ko7cWz*82N79`~E_v>dBA=E5*N(S5 zfW@5JZhlY7ASrma%WETW>vfCE|H*^}rMacW;Cc1u`)yfuA8B{$qL?MTB^Zxyvsbv+$z(6lteSL_mX2kS5ooD-{|Ct!0)8#rg zC@gS}$M;Tx`12JxU~e!z9cu*~h~xK>pRXV_H!&nIA%9)aC{l!9Z~-4zFE!e~Aw*Ch zR+U6Vw8`Zh_xn%lJfW(x0kDy!=afYkjaZ`d`Ssa-ojZfW*8`q9WU9RfC`_-|mau7z zfmzeCc_CTH&AuM;@t&h=1 z=>CYS!)Q0csKh7+^PLv#Shz~Bb)OQi{%B26sVWFiaEYKLbEg%bN~Y#tI>H)piu%RA zi4!xiSGn_nJZ;$e9jvaO|G+nbW1;u=%YkT7RTW^tsgRu2Z@n#fh=Iz#Y|8g#UmG_m zRW4CFqq6fj~h) z!K&xb{#t`ci_-zaUb`7(4~o6r6A?^fd4*GBH@-kHHtq zsD@9!o$ybeJ+7YuuNzBsPRj!Oc@=pN1Mq?jsf-8^UO;yE^?xSoKT{?xpp0^X%A-mu zB#~he-K?ripI&TWgN#j>eiULJS!tRdk%nva0q4!2tSplE(>6`TXlDF39>`HmTu6N& z6}!F8u!$RX+q40L(dVBIfkC6F>Ipl4}F9HWFKSQ$$ zaKD$R-aum!-7al-tS{j}iZQjV7_c&`Eot8~mfL5`Qjdlop*y;~QAebloFu9A1#K<) zJ_$81b>7<8V$EpyN5?i6NgH|lSnrC?wFsa)mBjMiH=+jhdNqAcN$1#2l%a?yVq58T z{bpw3XYgj4OOu#Cujuih=oVp})^QnPD1q(xJ|@0Q1Nn$2^m(lGd9ZO?o_~|UGMg$p zLY~O0>Ob(i$$20olE@zf$-LL)c6z(_4<8H4+aljKZSQdGJ~Ojkqxbd0`FdBEU#Hjk z#&r8mfjSH$^`tQBs8+t)7v35`Z_Q)NsFE zVt%YzUo-O3@>*Lw9>t(2BMWIr7d(nMO4?%)8lpvqaxho>wS3JWw4}Nh(BSmx@!ec@ z^`LXuGc1!rL7`TgBp3I$bJIA)W)o=Duz7KbdyBjWN>^*g5Rw^526$M}w7p*kR#D2f zmkaLsEy9l@l%cfprQ6CWG*zFJVNF)uMEEY6Gm@W-iui~nWy2e;Nvcwrk{#+MZ(joi zQp6AN5N#)B-V3cr;6~JT9oAG}EYm7tx_*1%ENp!{8=u-lUU^u5q=_djo{DG6RbnO*X{65w!C<#Fry4{V8YD+;6h6^8zM zm4eC(VR%#m+`j90kd*WQ@cCl5RAoahkI5I~Js=H3_Z}r+>g?!CLQAj@^@TM3iPz>{ z-xvkNa$d|O;~{L=JMKtQ*kW>0zZ3nbD{x4IWYK$GxlIgfzYP9Zc$PX<>?Z?lF*q0PmEm2J+rvvx_9Q63yC%O!WzMPKyb_X2;#{YM5=i zo|R@U+1ba{N7}N>a5cR%6$h&&W_KygnhRWOgLIv8O-kVCi%ZT#K4>>g>Nq7bY~n<- z<{@08w)-poZmDV$&vC&hgp}%_?`FDXlTte4{h!dlPL&_5UT5-1GV5|5ZymPKTpYeD3wIj1&L4UUe|#fW zpzvmFNt{)?i6_(FGYEBfoVoq;8A11Ht#Z#LFJ&|7+fV z*|`lu3TsC(9)YZNgVyPHG)UNYj!W;gizjNTwB&}_WVxyld?f@Bl6qD&#!AMD_3;ad z{*WG((84ay7TW90Rvs4UFtPHm=Ne>HGD4FpY}`Gva5h<()b)C?P})UBV-}v1{jZLL z=&QZM5|E)v1XVo{pB(eNdDb|`v5=c}GTZZU7wn^z*g3xH3vQvIt1Z~)bgndMG~UAx zM_=Tg+=6WF8gntddKBuwSk_EgP~0vc?+r%?chWVqyQ->udm4-WeAZ(aaFgcq#-FU9 znQ8a6+`YNpZlXK_E)^6$4&J$?AENfBevDc=}=#W%X6)(G(p}H^s3Bm#)ef zTcm3ly>T^9D$HKcb+@_vAYmGd+GzbYz)zhJ?OBzmww5e;XuDYWLMwL%Hb(UO5b)(Q z{+8Iyk&wf7Z#(zVklVTa^H8Yo^!0tt#;-aT`W3jF&F4i`L%=<~(c7HX?|zfTi8Q13 zgX;GyAt=POCjkc&_&VOy{23ergmCbUQ@9lo}(DjT2;1eIA=G;B{FMxf}_WY)~Kvqbk))-tPHp>PYleerUtPryE%BhdbaxJ2@T|C_R~Jf4;6;J=qRShRuM1E%OB=h z-2XU=A$c4u!QL&nU|V5&{6sXjZeT4TEsC$QL9z9Q(h!8Kw;skGV>6|Z6!&vruBk2N z-9h}=Til|R){#U@{3-fYF8srS3Mq;#+QSMHm*(UCq{c8)h!_ba@NpsKGjJ$lr|=K9 z(CccC*Cz}T3a8`7Q7-?;ww01rwo?qp{blNPZ3dzIZX9>X@O_93Bj*%rvDym`eSh?^=_q#fK{$G zmaL;RJbjBWE?g>i{dv!2Y?(p53foy?cT*y+>L4uqDIOIE;rx}omTf?W??P&pC|QPq zydmFFetMW9I`38TqkWBfq4YE3=i-fqRn&@5>G=DBkfTN1I??AuKnY34p%%6j;=UTy zAPTx}P9F1TpM%7~gF0NTmbREp%Kj5hh2{P(e#|#3+q&z%mZX$r1QJRs!5O9(lwAQ}E_%G-b zDkNC|0HAfsNdsgDr>c@l7;KVIjE~PzDZB`Whu7P2Lux_ABzI+h`-TT)qw^E({d|1L zWD`7sR*cs7QH0ufYoZxLD=qLXfR!BBy+99g-rBNQBE%GaKgr|oilcV&HW;b}KY3ZM z4Ys)ilL;3`H;al(U0bWIy3jb}SpfMV=b31NTaQv-Mpp1zSKr83uBA!95sk{g<9#O) z`CKvHUtoQ?`nh6A=hN5WGnvvCx*p`Ey~JS|e)~4ipN5TgMYrCH{c-!4evUc(qyA!U zRn1o~F6KO|q!uyb%~^dW%>TiUu(<58r|IxPsY^51z`gv~b%(o34|LtdAR`7<7qX#k zS@6U8PlwiEep7K64}~D#31oSqN<)T~cwkE#ZH<(J*I1!yl{(YwVbI$_9Z-;d^*|!pE+pfO8KV5pi9Vzhs6VCQmBCH$1FIET_nsH?B2_S6Ca5*BIeKbc}4On~f&wbNH&J59fInc1Y0v-<|VlPwi0@VyVw;3|doDK)H z;nELRBnlK+@o>xkc3O_<72_-jhJyxzlmP7Q2-?fT!^7ZlggH%CV&!M%XWysad7ojE z$LadNNY1B-2u`xat8kO4eqLwzbC30qOtgu)M}`^qg1<&{?3{Ue!l5RC!W}+gUz5Ei zcQazrh*%c&GU1&T4TKienl}l&0f{K)Oy<#)9;wpi#(ZvTXtnjRQ$*s?NjX)k^=QMn zBBl_}3-uwxIXJ+B-ptBH%UgKz_h_7jG1`@I1PrcKKESQC%btfb#2&S z$#Jj0Ke;4=B>ABr`9&F{*QqdkJH|(hGmAp>qO_VtJzTG z?oQWzuEV#{cJdzsfkThIZjn!5|BSbu*cf3P(vrY;Kt~`dIWXl*fXNb}jd@HXgz5sDx%bi?NU_`JRbE>p0_(6;gL&p4 zGTVi+q<#i0UwZ&_mak9Mt~P*dQt^Hg2?{)RpDF6UTENpZSDuN3VuPCRPmQ{q&TQ}Q zlY>(yF6hYm4e21S)!9x5-uw>J0Fm5pE~hh=WKCtKUI2sr1j`5cy^nX{k8+6B7}1*u0mWSF_;@Y#&5|L8 z_>Ro&B6o4j3Dd6Y>wYk|0oMr*q1W>qe&(|E8CL4O;qAon*2?wde!+-Nb)OYVA4AYH zLZ84x6*x?XrB|?P^|~JHjCw>?WVzK*b?;7o8a}U-B>L~XFeeZR;5|V0^z0?7TsEWT85KU$SKH!(pNIml2mu9T&n=CHft&( zl0;0#QVrj9ureu0AW_%=Q9=FKLUTwH%-b+v9BbLRZGkPfbLF=tVgWJeJZo#}nh{-8!S*_oW6d?AY86(_Q|oLH1G- zb5mvx8diQ1k@KZDDM#cTq9%i1eTSn$&~dq_4;|wCUl!P>Ku-V*^z7AyXflwXP3}(V zYy|yXU+*%E1m3P*fAT#76QZnd;@_}WTkB3|E(0FVzWn4)JvSbn@AEsE@+$Z+68iW_ zxc+Dx=X$!-7IYWdHIpp>e@^lbVF3XnlVl+UO9)gvgHXQjH?9P&Ob=Rye%nG8=O9dH zcDi^*+oThqBF4m$lBDldcs&o(`z77(0@-wYS6gxZQla< z?dD_q?L;eWqMyGF8gaY(x%_hD;T+lMsg3w$Ed0-CV&s51FtrE%4jWnEiDc`a`6aHl zGRo5L&i#w#QEMY}5qAp-^eVx6z4*k1n$Ik9NYXSm^%mlBn#rthq0#Ud+gaYm-si)D zS$O9C<_Uo40Ial`agx+8%VxXs>t&ZbBY7^TFX?1X#}raQ-f>GkHq4x?HplVNrxOKN zchNdc10Iwzq2Y@;99;H;Gq;kk5d~`fv@XY>3s;&P(b$*>U|`-=6H?! zybJ+u6$Vrqvk!zn4#OPQe*zCfG}`)%{S5f<`IqmZ2m?Vk9paZ~UHzm|qKyUGI1{TZ zTY4&aXKCRHst7fFOERt99xQ2gkf|v^ECRr0S2hL8)EniH!2r+ToC&?)h6_j@kW(;> z9hnZX!Rl{lmYJJTI^{;!%3f+))BE8OF2Beat(TQAW=#?TyNWtjnUqXhE@nwr_C_9z zQ_ATCwgkOlG4-uO$K?)w^Ll{JV*H&5fkYh&7P_zB*_jklj2}M#D7OPV$l%#6?f%Y| zn3M?lgUXy|HPnOHEA)$EflRnAR1rWx*u9%2sP_qt+4AH5c>eRGq5Nat2*}YfRv3MK zeLtGrk7I-M*)Jdaj5_ZqapWzhy|u1(f4BF!6@279;qf_cKr=th8s)m*{6eRS!{dPS z%xGI`@Ut0DE|bgnxTvb&>~10YCyU2rS4N32hnz$UfqN-ID9X2;h(0Y#1kIxjJA){4 zWdsPqpQP>9cFEaq+gziyb+snd@x@Z6B;wc-v!*u2Tq!yNa7x0Wg5)P?aRPyd68a5% zV^>8{El)X>ZB5=_&>~BPT~+CG4~>3J_Q>>gzN1^g9^tg$P=O*r$O5>AWkhJTI9fn@O0)fDw11x5+RoB zOry@6_<;)!!x`Sc=GJ%}*7y>*Z^nK_5;L(cjwO+=M<6)ZINCZo67t$tavQ(4o(l+U zib7v(bePRcjz=Im_4Ha!fA(B~k@)p@w%9p+J_JEDj5HVUTb#Iz5(`~tno-H;atFTs zT2q_YpNK#_UGC|4nC99>0rninb1(zN+4VLjXb2HNX!vLQ4*FO1=d+B#7%dY-$HNMq{~Y!;Zdr>&#}-9ZJd95xroPHKX0l! z*DWg#Ic-#HuvYRaB24u4;F)1VD?ZKX#Xya2&Brr-vW%?W*hY9y~iBn$(L?#q2UohkE`b zDHX~&u}iL|s?LX5^<=rmg)k2)tLS>|uqpsGFKG0+JpgJ+qW+YvW@K3ZAQ=zzKm()T z*GU(W>!O^Mm`9s-H7HUkh9573G&jZ3bscojsjqPqjz5+4wZt7hF{J52c|*hTXO%?k z%PuP*Dw!%GfP;&q%sI_k-E^+Y zH-`pw5L>K$3pcF;m=P8Ej}-3M)9SjLg*9(*gp61cI1AV=5o@EOeDb*6qMPtjv@3p6 ziq{Fhp?V7i8+-rj5twMEq8vx8e>w?pA`3wvO%Q-BeRrU`%#ggW8Q!2(1j$R>2bD0z zdStA>)OYFTq{`P9TGOLc_xZYw!8xp}qB|03Ph+t_V$^*_^_B=QC94-EHsMh5am-R- z^bTweDQZR;$})sVb#qpt&PZ2Xez3bri*}6zzz!(hncn5LYK zP&I8_^_yjvTYxmhU`;;y4;X_b(iic>~mNhYmrZ#uM zxc2SfdU}4q9(tX0E@5=4m)J*}AVV!Qf7PVfPscq~4~VHD&)D92y7o;;`7~7T-nS~F zpJB=%)NLM)Iu98j#5U};6MI!TyLyaMOysgw_U9@?!}Og{bU@P^G!Ok-YOy(-u^s^FF>H z29F@jpnP`sEZVl_n*Cs_0of>AFbfD^!m)Hqx?cg39%YwM%RwwK(8?({y`;I2OoAcX zmJ-1S#^{`SD2EoQi}0?{f2_O#=hzPsBaGDJ-AGj2VjqC!L!PY1z<&D8|2T~tc#sRP z2&55=81xmNXg-YFbERmA4zxo8KjwG5o-GFHMgMz8FOuw%ROi3ekreW)Y(K9$c8 z(P#iFT$sHPe8{={2AAe!pKv z(@Ovz?n~;50L(*^N8aj2K^hB!Ls%q5X{%b9Q7hn=Sk!v5Ptef(+={)cHSPIp7NYLR zI#W$V6(~=bBu5X$sYf1B9eTo&L&jkthqiS*%}tc%0fA%bH5W;3U2@0skSR#>fq>TN&=iXfd-aR9MSh zuoK{^_*qHg!yp&cLg0-ar^+v;i`9P8ebvCoL$kak+`N&EWJkBXx>204zw9g6S^g{? z(}}(<`oMG!o!}HW7`<`NqiQ|Fq}49LL)n{WavNgK^E9xZI3>yyiGy)U3LG3Q4s z+?0`-AE!~<$lU5c&k4eOqSW10X#w;<6Ry8d4;3SfHOkSZ(;Y)8r$-KYghT^e?KB3B zgIdN@7{H|*s;)uE15JGihqeCBTG0fQI+{R}1(b zCw#>MzN+R$j`_8?qx4?#>*S(m559Q;Dqy5Z5|-7r#BFF^R&Ohf%u^IAhpy_S)fuG0 z6z=5Tff*=WV1RXztcpX@UHFDGZciG7_O?8``6p|Hl`iBW_EXify+HjG?LqwEvr#|FV4O8 zL+q?h^Q%a(Cb>jRi2Y2?Qhvy0h()(VqV)~n6zBZK<$vSg)o|>c!~$Pi&@QQY zPAeM}aTyZ0_YRi;9U5}jDIptTZ$c&uFEaG2Rb(19c>|KIWqtlJiA%ZopQ!*4R3Czr zQGU!W;mD7bhQWlLl^qBlV$6rjbU4Nf*9E8>RlE}qliTSE$gssE+49v5tOAl5*%N8~ zb7x&G4B0k>MO8#ANjYDjS)K0J1Aa2mjnS2ytn&6H!)R7Bo!bf75UmeG=~_W_@;2b!8aI9T#_D zOeqH^O!i?iyEOPSNDEiLWRx{7#9K6MAbd-ZsXWSWF9|#6tLq()HFj`Z2<>$ z)=@SpNyv_it8Yd76^`z_5xnyJEV=M{)G7WXVJQPqC|<(h1bh1Joj(KHp36AI&`QXv z7zj(N6RX#QzBCZNgj&;Xu5A~zEY>M6gJ*G>Urjy}#&3{(Kh01=42|H8=ScCeLRXU8 zWkLo^!dBn?3UceXZoz`T7atn=(eNBA)S7R|Hmr0tUIszjQFq#i?4{7<9)_bcP{aO3 z78a8$6{}-)Q1Bg8Z^L0Z1u<(Rg!m}+on;yHXA7ZX^tvV-S{Q-6D%9xk3m9u6{{s3w5?}%+9T>wj*^{JxJX2dr zvQSf>MfoNz2|MdfhUnhH-qfj>sB=ml+C~)|0!ejmjv!w@E~S4#Pxf1#jylC9aYEz8 zdUffLYJOm#Ia-D|S1Dl`qsy@uueDd?Yg(3+B!OfQkC3{;TGcEPviqV)%Lg)BL`MFK zxSgMTab9J{Nu~JmhvkGpEL;k7D>sR*%eU+=-52X4pY2mU_sJK0L=3)hEE%9C^6eTX z5FBciKB64+m23;`a2$3mFc0}k-bP{3bM#)6`ixJMB* zZHI(>BgLyy$$ALU*GgeA$#3pzrj;Di#Nbi=^2=DWZ!L5rHe?4T#chteOZqhoBsoNx zNTyQct+P0{V`kg`d)e{i=2QOK{kTN|sy`46lUq%7js)lwNHJiX>>in3Z1G{@Cyno+ns^U> z%L743v$pW^+0JPxmGBU_OCTM~WGX!(UcUnP-dgf*1p#$2$4oo9I7@{nOpMf5WkZ@V z#c);jLW=I~7bSn~ z#(a%`nF|DuvxrEhxZS;UwY23LmbHY|k_o?tGDc%;_`z$Rd#W;h85%ZB6`Hv1^y2xdELOH|!Y%(UTEimtt0;a|07$eIZ1uia z33$k5w;7Glh4nLrhW?ys7GFu9G%7zWzda_%0>25z%UDeV^zwMwX5G?nq>y=ue=#3G z02JIHg)R@o?2&IEj}Ny47UOAV?uH3DZ1){2e@VK{k_Plzl^IPYg846LQ5+Pwew+$O zTR(+CYSy0?-DvC}t7@rBDdJlG9MnY;s<;a0po>F$v8>ocOw{pBm_i%Uh!3IkZFL({ z2XAlRYAxOP9sm3k)8v`BdxDncPCEKIJDJqqk%T(Umn}EdI#9NIuxgBtqUK`civ_cUOEIg(gp5zNr95wWK7A39r;MH{ zjJAxT5Xs9L_y_zOf(c%7^#DGsB}l*p`u{#RKrF|_!O(OPfC`BNL6a6D(Ba#7{J>iJz$#DbrMjg z68S~b-zL*DoCLb-yIUOm8rlM-g3**TV=-PL&?b1`;{b;rA4&m~De824@zd?W~_uE&~{5}Y$pFObZQ%+JBF zVOmm+ub``-ApfX=*bZJ5I0+yegl)e6KFO;f>Wjx2NfG)*u#dWEBW*1TGZY_P4vmnn zGQzcnF97)JiSiGQ52R5?e@So_5zTn8jVVDqOr(=GWsnrV>hNHcgX+4(b9k)w?jxVF;PZ_OdY+M1K8evxe3Xlg zImcq>0ByrH)2Pdsb%xc+ocbt}K8tzTpVg<~(vz$j53OV+5((jK`){I4eydEf#H7z>E?Hpp^M-3&>E_3;_$@~|@L=(^Q4?|1|t<3`VkehT5e zs=;bO_~77~&S8Q6%2>iIl`99x(1k^+HOHXVe=>DYrz9sTZK0Jh`5|q+e_yAY8jeVB z34^c)g^9#ZFradO^Hlah3PEgEzOs+Z6b_DhwfN$QrBFlXHPjnwqJ+Ebkp0mZw+odb zx@sZcEY)5e%s~TBK$RS*j17hv$?cf3pGy3624#!kOBndef!xe((2jOc847AM>4Y++ z{dB`j`iE|)I4xIpLRc}a_+Q3u{`ha6_8R$A? zs{;b<3IMn)%nZU_CNhsJ0#s{;ZGHWGDZmb{8_An-8DFkj7x56X1<7U$u>TS3gu6q$*ti>spEWr_R3i{f5%?`(AXB#vOr4lNUoQgV z-?NOQ4;0;{U#o}h9Ke+9M&>Fi8QnHdz9Lc`;$f?>X-eLcF_P|tET0DTrPqaD7&f}W zvu6mGPw-2Ilc$~{%SMaq9sMHaW}xP-oKczyw^mZuo9%_#jVPx7sVUtf5(ba#~m0{;q(un{7ND!({O_67H`)l>${+&reLjz^X zn|Y@bR#=WSOa?h&wq&Bi^Fbx$oVb79xV@r!Synd2gz2WlIR|^;GXqPwp2X=7I0lk z3ZaxxP3MSBbtB+S;ix~^_?HWZ_k#s%h_W1I96#5+9O$vat|=GlE~@?9-dg2zJlT0t z@oSgCY?Ylih|1Xq((VfCM@~q)CQ7w2+y?7;yhg8fTu~mU85#7}vjzEaJX2NU=n|lo zp+iA_F@j}#2lxJO=B&A3wF>e9CwVVQ(h-3035W%U zakVxp4|L!!H|qm%Y(Foo45upvIT>s=*$X2{0|P^;fKjttO~M>6i$OB`uSQN&7~b2` zmbmvui)Rn!7TTU<2}WGm6uRh_0Vb+j10v<#rZ&VvE<5ukuy9YY&>aq%C*R| z-7;8x+;gvL`Ia27Pr@Q4q*8|oQU zagv0&RED*$i&S)aE}KOXOIog-FW9yWe3n_DZhFJ}Dl}@Ae8%G7^2j`5=*b*CrFpoJ zDTclx#2j$_tLQ<}7AP{d;K+m~?&wfMs5d9F#cKwYP=9GC|IY;og~AAvL33fRV;pM? zi~)Dr?lH9N&Tby^-7s?>rF_c}J&wNU+t+Qv*jvWw{X9v-PJ z>(9EFhqCT|Rzaw|y?o_T?RDtP3{^yte0w>zL*MfQ1|E^WFaK_r(bnQU_PLvgCClq+ zbM>g34nk_!y^2M-qCP2Q?b)p31qOVp*?fx=ewSxV{KhRMX{7ekEHS%P*YLZArP@4d zQ-p?}aVP5WsL$u6%%L)RoS+MblNbJGKwZMn8~x(i0DEAW+RV0>LWTYaU~q!?ytxwR z%MQCwVJoGoQ`mcmId;?js)W?P9v@}zH;_0ayFW1m$m=p!y}&h2rj+MQ^UCM+%qLJY ze$MqAQa^`a@59mKhOu5Phr^48=WAS=qXw zEH?v>FCoWvr1wiPHT2%zj=N14NiPYR)B0WEa~zM37%93;$uh(O1bBtn*a-MRr_qS( z+rNp(6(GOGq&vq?Rh=DihW*IKVKM;iPNNQT=fMLweAi@-*iy* zk|;XLnY+`QTU+3(d2Lkm-?3qiM2e7O2Ot0ivLVIBc?G z2F6(xVzxxC;(1F&me0S2V{?g0ez;*6uR&3+QB)Pz@{@083B`FgxKc{V4&SLvL`&em zUyBwy4Jc7$s9g`Gpr^RfNszd_C+D`J%aaFJHqxkM|6U{@g_uLB1xqCa`B}FnWys-A zvBlHiLD;Igj=MIX>&5@9mO|fU%XsVk=ca5ji#E1IRkX^Kg6BxGG-~8G)OcS}~?o28F!+JLdv+V$R( z!PjoxazH#U#g8ZpxJ9~ylrOaK_`dcby(L=3q&v29l$xe*RTfy6UvREv(|;M>biRbx zKu`u{+?4m~p%dysIz{TaXLb3SX;f3%0z_Xp%qRQqdbJxSi21*_bjJ7HLE-)yjBL@! zmW`MVTNLx2#-K{4p!g+6FKl?|EzcOUMvpv2f-F*h#LFEt*3UprtC1kuZ6$14#6?YhlwM~1S1?j z>;mnJcU9eE!9iT5j{urJH#x;n;&ZrUpXYV`YRV+#{5b)*8Pm}nUx`TphzHp`g{y0> zRNj@khzeR?s&3Qul1#TcO}|v{#^Af~o=?SvzWaIv*?nt9&c{o(iLxBR55AZsb9G=j zI?Nd>g@%;+^pmT8#NXiXfJz_eX}M{Z@Oh;6m4KUoqZs5BOm;sA;fo|FFl}lxPRLui zQQ~5hx7XR;A0$$H_Zcx}@p)_2iDtAidbZzeGG1}{dKY1cCsP6?tFvHn(z@5NI>kV&1J4!tpj7tz|# zxl?`g`wsG8fp$9CKN@Sn&=jA?@5auF3mX^5U7^kESB8|>j}bi z&WbWq&B=kY=!fTTSZv*CvCqsI7LY2jcUiwzFiXxj($-%5zhLPTB2UCbh|Ol53RlDv zO+<3U>Ww?JX&dk3@uvyc7m;k3+j39No|4KrFWe+sE-i@0p1l@F6b&+kR$O8l48kwC)%4Us+h z=j5D|jkLc*cV{*VUR1lqeSZCIl>`5i;N`1;^7ZF|eZ$HwWKeXZ>aOp%nq?MPF2=3j zD+?+Xgfgll>Md$XiUg9aR9k^_;1Vghoc?B#v#rTi>km}xVZNOiN6Tz?Bf6fAUp5oG zMPRM!Pe038z!umP^iJy+wC&3;08##Z* zdw&jm`FXaKfjiCuoQYlq&6I&Mbe@UQ-~0npr(QLe%#6&Vhhy*ndhh*|=+UI|z^v69gR5{<5k_PUzjf z`>Smu;5ZbKRrXtun!+%FD6r@_MX{CAABVjh;nu=%?O`8!LAqzpl&mh3k`FQ+x7V2A zMqm(QE}bfhK~6H4i8J$U`8dfAp8~f`S&#OZBfb%yq&IWf+-D>N#i{vB*L20R{u-Vy zc}eYU2UbO!?tAAbbjlB$%Ts#y=FZfR^IzwB9KVK!zW|U5qG#k&Sbh?Nx^2qZi*#Li zUn>P8IB@v+>5p>gYjsY28z{_JlRNIW6_gi{mMYK#aZDGt8wQX{D(cGhww9)SGr8Dy z87w8T)L4`%<&WF-F(op$I$c1QUnTM#WAuY|~q*)!!XK;GVIPwoHnz*im`7&B|F%SpOK+@9%Gx!sx! z8_JZ2x+S4~uWZ9k<^Y3V^{OdWk0^Z~lpL@YACd>I=3hD!Av?v*Jpd@ z!WM_cif}QOZH5i}%n=fHnb4+i?7Kd6BQDuVdb`{S)EDR|A%sxWKDe5z(ELA%BUj8C zcI9SkfQ80TKMaC64}i%qtN#C&Fvu5@AjrUU=bGk6Gg7okQ<;ZB&JQwJWwcJ;%|+;V zqF~tFKPzS~?K=?csnPQ znB0L>tK?RzK|3>2Qi=22_R?%`8c^HHw-gG_DIln`+T-RjN2kwjX(ipMyF4 zh1q{2FCg^msG|Vm_x;?OL+aKI=WEz;#>aXv^Q*tm6<9wr^&MT$dO+5m=XJtr$lxQW z_lu;__AW>W3Ao4$dRy2rFaGV=ex(HMS)32K{x<73UIMp_dH`C2Hg(2+U{)s zN%a0JgAx>C*uz{lT%&t;;oR!LN$cB4wB@WifB0?2#qkFAgjJ@=IkP6w_sq6b;StG}$4Anc7l&Eb7J zG#prJLEukBvcGtnFHwC=A8C*8J=v+&_36fwzn}`C;VfCtRYz9N7rW=H#AQ|b!7N;W zHE>e#-uTtUr@w}L_vNJyy!duP*-;;PGHJ^*XIr4(p`IH4zZu~F42pqp(?bNP(LralQ_0cr(GCG9KZ{B1xei8h^`Zur%M7N^zluA?2IPxbk^kZ;4@l z=e$rbk*2pxm!O_>b>KBsodJiZm{dnQ@TRcCTJ&7Fo?HZZx7X_gsq*Z6YkE@b8sE|^ z&+W*!!PNGBI}upSHjuz_rDGZ}iUvqfil&iReyE{(ViVx@kgh_D=%*(=)ju6qf5t|j zJEe+KkGhro$}a_|=|Ti0c-Jwj^5M6i@XFMoC-bW3AjSC&jp5P{Z7HC6;hMIg{e$ZQ zffWe~r~{E2;D0fJZzQugmIOITMV>yPJTIG*d$j0i6MtoN2*x*`8$lHf2TjS3Pyw%h z3LK{sVQwPtja)?ipw^xBC z=Sc25%KH{Nf>!6pv)Fdm(BKGWJ%_|`BKNAh7`Q`QPyHt`xQL>(qra%2l~&t#HVD(B zWUOzk9OI-l`xv~BsPf0-vmVb5#S*>@2g)ihT2+T`B|yAL$Yp#niWt6>81`VJK_8F3 zh*JX};g+ZJX6|oH0l5Q62IAu9&QTJv&TmEdyOf6giuw6yln> zAbSvuuzV5frfg(1>mdOOVR_HW@IHdmNehE@W2VJ-*8=%>j<(gG-RdigS}R+6#T#}u zuFFmlM5ABh#4}WnI0A(|)XTF7WY`yV3$jrPo-+Vr`ah_)F)e3HB1q+OT}xV48qjX} z^DE@cVbS%PNKNPmSW2dY`&0hxmRjzMifOAk%iv16n<~*hR&f&TeTJY_i@5yCw4gY_ z;n}X+Gip&VG>B$l9Tp&yTO|LNfsX|PE1&@WhyZl8*gMtq#rfnT8tS)HBfC^9h9n#( z9$f%3f?5nZDFp6qM(ttaOPRbE=6iBtY0*m=N<$5gfX=k*Wz&!s4wRF%T7A&V(ORlb3Sba`D{2$i@61~ z8dSpj@khKuPd;TgVhUV@&m5AhgM}&;Hlh2#fel}#QUZk>@%ViW_>ys4R>Zj+HFz8v z3YRuLD?LMI%7dJz$Xl$%Z(o_BOb^)jsZdQBwDXCObONATX(IjbYK|AS#g+EZ^T+tb z6@VT_tp_1kI&NLQVKLmOpH*GT_rGL9V=y7w_Oj^CaZ!^aTm-B$fR;qefEyi{{M+&2;z%ZkqM_ zKhj4trs91?5Cl>=hBNp*qa=iMBOznl`J}hLE3Y6QsQ)1*(0F!Ul%EY!%UIA4;4@t& z3U|CupHgv)%+X#?NnZ(~3Q6Mr4_$8=)z-gt3)k@C#oe9YP_#gCcemnR+@TbAcXxM! zOL2FCOL2FH5N`hG-1Cm}Ja@bw^I>OiSs)>t76lfm)4 zzcY@_DbX(1o+o`}bHqL11b#q+vc+U_W~Y{Y0(B~jrGTj>=CjI!<5ZwVIf;H%o0cn5 zvSm2jA6LsXELr+7p@5IcTd2BWu*INU>HYiT>DObREZf zuWxsCT{oHlKKTkAwJ)?h^&4epvHM((x0^A_1eJUUD`Y{7#4F^?HEBbgdgnnF03k~V zTbsHBb6mV^vZKp@8=2*p%bT`m3$MZWTl`>T-Z@L%*>0TxH&~`-X>rA_W2YWvFas#RX%nNqdhzwzLJUWP(kX<(KIpkyM+2#u6r+H^+*Pj+xAe%6C0ZD0;N7~ zB983+Qf5`P^$J%v1a6_+MQ$``K{qckRa3!<(juH3KXS>3VmDb)*weY6aLs#Q zV7wR1XTU^kU5V^JDGp!U3s(#fW2JW9o+U*qz6cv)>{TeF{{933x#MI)2-JH6A^`md5@;+P--MA#_53cO;bgV zXQBWjC3ix&yl?;R*xSsMc1Jr-hq`2qD{PB_o_<`R>|M1EA7nwn_Gxt)%M+L6=A2?) zYC3(z(B2VQggCh{@yF!eQ$rzFykgVWl3&#erM!55f=ItSCyc~r1Y#rI2a|NeP{B(4 z*(!+w;Z?E-wK>Z;DPD{j*9AN8byvz+QWfT35d={r2Hk0muQ)L9K zH|MB!zc{9R8jQ{KR%*zRWmN3NEil@xX=5Zet)v|kjgT9d{{D<*{u(VZgqui8HDW!* z5fJu7?qvaoiA~rL@V_3GVd0O6z2YO~b!qT9Alr~b>G=IdgjYhf=XY08zk5Oze@2hb zvdWhMO!y005lEZm%eX`|n%k(QKsa)x6A!O@_eK*xA?>=X_|#&tB({`+Qc0$xVjo{Y z2wq-?gA>l7efbOS_a9cRC)A-kkA;b0TdN9g0e@8B0g1>l*tK1vvu9- zilj`Bi@>(~yb&y|-Y1Pbhd`r;y0DR)C`i6f;SBo!(~5-ZQgzhSRV*_i zZJcLuE8p^X=f8hawDEv=orI)SE@Ng7em>3R8Fj&o-ou(9gcFn5$hrR}1kYZJrZ!DKX{x=4*)5^CU4b z-?O5QJI?ZkBkW4KRwDj=IGrLu8;ynr-^#!z7P63Lp8t$u4d? z4jJl3hAAPkX%7|F$6t?MO2ZJe9AG%kw``}z5MxKtypnG*2EnLa;2anw$3r+S^=$*VmXG&4$UDyd4s&@2qA)IJOgCpi zVA1O4tX#r7EmrMfk)1Vm+KT8?*+oVije(tT!1(f%sNB$&wb+&{G4oS~@>E$_g-bbi zoGpTjz0{s8Ei!Z=_{6=RZ2%Xk=m%>knFui7()p{d)RzTjq4x-5$>lq zN%6SOR_xc@Cx_?nIUfwJp1hJzGYlHYK7JdTuQ-RyYV}zYwBc+*%B-`OP5OktjU_5< zxW0G|fw-1x)}C6eeVd4L7C)j|_os0~hCp*VRys|BZ%Gl|cOp3d7byJuJ%=NO!YGrp z+C$q(HutY<B%-GFIS8%QW~nh>m1p7TDyZb;(M>56XuuezaXo#sp3Hv3WwEnPOnt zO2k5J5g^%VVR^wE+r5l*gQq9oS-4IG&-@mMk`Rs7|C8?L)1sFgkLULoUB1|#b7-r1 zKgRwRSdPkbozZ%($?~cCakjfZ%5up^V5`7oUbcr>ORWv{2bl}NBJkl7{#-B*ZsZ`C z56m$D>K*gGp&6*hyZ=_~%!s8d!{Cfn)l=M8`(s!wLB2_?A6g?MG<|blJZog^CFCYk z;m)DUY%gkvTrk;diG+g^Sl${DQ}mpDxx!pjZH-F{UQv)%kJEn=_L}e!5K-@XYb(Yv zu#e8nF=at}(EjWXG7~!@JKWrqd{>W6NeQCEr4>m1jjBHvHS2JiRuVmbhU>R7e)Ry* z0)6~nXQmGPq90ZJzRX7k5ac@j`T2iZM>dP=QA zsO>Da10^V5~Jw)`X$jz5mp5Diqytf7as9{Us=%pwc#_%z@1It2`0 zGyqE$xuu|WcGQSeq)?&S(m7CnOv*&S8c$fnCsb(&$lTR$(DvZ4WPd=LT_P_CkbFtR zqd{K0z;`>2M5wArcKvtm(+k7@4-0g?Na^mU?RgL7MB+1p1CBOno=C_&NF(yEda7WOVC`_Z0>s)66)!(&1k6 z{2`nE;C~Q-A3bllK)b!_k$WjQ-$;&Lk=OSPGnp5D)iO+H^I$l<#>hSpNto6* zRC(K9+nF=jZ2rDd*Kttxl;Ym8I7pR~7H$4mR43jD99 znD7xQAjXpf1e~Vl=V}JtR)0s*3YNmc$i2~|^a6=i>oCu7^r~iW(hkGC@t0Yr9-yaf z%t@d+iK94>A@aJ#Gmp|k7_oV7Iw>VzX{@uJ3w}5X3wC1^A^DESjk2;|{&MGYOa6To z9uX_1EYMVeDl45X{g=($+6|p8QQ!96>}N!(qZ+S+U#^XmeA4iWH1Eor-tj7w**SbQ zR$Y+BB906flEpWnst6KS+e$Z}S81qGH)zDZ_=wl{#fi>*sWMu^Q$+oW)@k4un5m1i zI{4Oo9zF(0>_R{|GAGsbjTtts*XlPQoPx~&4yl!a%S8h-FqaUGfR}O;CZVwNbXTX^ zuYut5QMn-33-_KlXmX^XxY7KAgzqeJM;n`YkiNh$ivKJgpvS6)9;kDm?{`HMaX-Lj z$&@hJeqY?cXH9_8xc9&--+DNvF`!E#;aR(R~2YA@fpb^d*dqzM@^@`EKHLNyDCPV zq2DE8^kc?E4n^97cUC7RzOU62r+8nz*yd4F*8MSP)HcffTF_Rw>efOvgmr;v=EULt z^sU8F=mu5RdU7Qw*f+jpKZr)2)X0W4oZ{R!Tk-DqOR~riv`zrabWYFp)GO+XoJJ!t zd9g1rk%RxoVFvXQTPS{G8+P~@hdIg$XK9~EG8UmPF+rU#6LzaCud>yX`QpzcR7rD| z*!`I4hk?w29+D?l=>u;`C!B|dY4cSBIf|+@|F5ANcP(Ow;8T_~-|)vFOlDr%vGCsp zu9)6@hYXc}Sz<)#7fYj=SlPp(W?r^-Y%12-Gc;?gEke?VLrjT2(s2&IXgSq;6q+}+ zt!FRmz^*VKWlr}M;wyiG3dCaKc+{0ZrIQHSNdSI9 z_f?0R`+>s20hsEo-Hp!|oOr?qLY{W|;}UjqX;}2f@Kt2SM$A*Ed)Fcvbz;V8Imwjl zwzAz44me40kGC*vX->FUZnxa_2pQ5Cbkr@N$q_Wg{a?QmsiY5-RZ!>wzK{ZplHqTP zR+VJFV5~d(9Jlf}r<=B+-#vcsu!lx%iR8hL8|%<6Sp$_TjfJHSiL&&!gYd!cnE znCgm(()MSo74o^ESF9qC|Dybit-e}X^q9{{_R(?}*(x+xmb}Z$S6X4>%mJnDlc6Fn z2Rsk{C&(RN5uYH)non6P#B4@r=nniY&zqPQYGq45`K@Ue8=(COtNdwTspHHpw05Dt zs}kVZKR-tUEY)e#n96v&9iH28TSFMI$cPOcYMRn%K^_zO-*mwRYDlFp>b|yC+AppD z-k{|u!}so2hjgLU&hctsF1siSV)bkhU33B&MKz}>Mb+Y zQBS@nKIhae01;)^9Ow|}0xMpnkZC$DadGRTdKKT|;FFOl+pxhvga9oZg$M?jWaYHe z!fbE57EeQ6`au^xQnDeEb50WZ^s!7QAj5XpmJ)PVW$W~#bYS?1EMa+9sbJGD#_uSlrqyr5*Pp6w%?yW#$D;+-S37#_JEhhq~#Q{p%Ut`o5Qu=y^ z>xSh131iuv-fuAI!uh9PTu2NB1`s)L^Ge94e_Dfkn1v?FBBXm?-gcKy?oEMdcJvYf zMVqhzbXcWh{G{V|;Ky7=XU8%jW*N6<7t`1L0ycT-J@hNtpSU%tfh#C1A@-|A<@pTe zO9=T6kRu6utcXHjkiUd(`?uFn(w`WWJclK?csE1jDP)NVY%?x(9E}f&-F*BGbzf5| zvuMxWYl&8Qxg32pculzTS@js8f4LEelA0-DjikB)TivJUKdNQuTZM zG~u605o394Hah-Ph|ld@C_~)o0*4A`-fsz zY;>#+?C?Rj@WrYKKPr|&GL1KYCak)E3?}FExxpQ34x#7bj&OSq*;dfkY}vM8PT8H2 zKk2Z0Gk5c%Bbw!?A5NJI{Sg}jB00^Vcs9AsbaC_`*~YESYagKNw8gq(y&96kW<45u zK)m_ke*@ikI4Eb+vZI48DY%qcQE7bQdS}6}4v!}!P_c(!RgE(>4TH@=!)BMe1;*5Fz`7!Se?f`un?_iw^TgBrktkOXRDC5S~#&h=jcE zq2DKv=cFk!v^3ddXsxgqY`faF&|Pn`q0+5i`@^_0trz_Qa{W-_E|JmIxp1TbUc9I- z?Hco=e3moX_Ns2*o1F_D;&KIay*!_gXwB&1zRdW&L5hYz=MJWO>l%<%K+2ee%W33; z{v!mBMh@c_2^unF(7qeiev#E&-JX{%A(sZ3Oj9Y5<8|%ruOk9A+C2nwZok)W zG~5EWMkJgy62FXG`0eykBrzqKHXGc7Bs8q43rt5h*Ii#=cB}}RCc2NDxcR&E*kdPE zYF;70hIzat;NRl0F34I&t~mgef+oub`6Z(J>)S_jg|C*gOMef-I$ds7qO6kz?;~D_ z{W>L{T7XlKwTCW@mtPiw@h(FRF6SD|?K?2CGXE_AouWe>?z@$jaClvc1V>{rWYoM9 zar$BJO9iaj)!m37ZzBfXO7tcuEc<164bB^tO#^Q`d+l7GnSPHysHKj#a-P6QC7U5J zt)Qtd8)*gi{d#f?|3!C;5sJ<_c<$SH>Bk^PRuV3z?;z8Q?(BT*F-oQniqO=8%B=U1sX`-W`Xxp=Jm6_8nUYpS)s{WSYTbjp@AGzJJ z^Cy~LSR@Y+=ni0l^vDkVFOTaS0aPPxxpg#o=@eg91>_;eOI>X2{w!LiUs^yW*=#+; zv%6T~m$xJtCd6?=3#7Y1hk3Txp3g8ypVT`|G>_Fg<7;n9Pc6TYP%bCLwmtHAKZpc| zg?H5g90P+8*fHKps`h7kHCX%T$a>w-87)|U~|CsJ^Kaf)6;W(MON}{oHq~C0@)rJ&9SmDg; zyV9R5p;6+aV>DklQt(4(B_><2>-B12RXL=IkoE4d|1%>FEDqbUQ1A@fk${Ho%ZQf5 zpzjCGJ_&g=0~&du2V%`>Pr;Al^0^6k7(wikUGVQgjSR_hK5H+9%C1ak(y{=Qe6e17R+J3!dg!} zAx)0}w0+{-iEI&~Q5~&e0^0f=^?G&7ZcWx=`gx^4)4gi{_+|8^^vkrbmP{E;kkf7I zNh{WUL+ilM8_#XnfT-VV?=%fIP@t~X)+^0^32|LdUrtFa#apF>iq+*PO(9g^tk%Oy zsjq*dUn|V3vna)8%?KK58=b{j1s><#@I9@t7wv;*l90o9XKXDe!8HS`;ARZpiUep2 zB{c>}hs{_m%ZqIHazcK`9-sWVesYa{Y*lFGrQF->t_L?%G$%9WVVQnajEMIVT;)Ds za~(^<>ifIp@yW^nO}mSy2kz6u9RqQ>Y>bdNIr1)?N zw&o-3I<79{kADgD;@?kJbh)m&@bcl7?4+iptUpJcY;YI*&!B!;XlVd+UolAWmBw=; zETpn`gQIk+r&oG-2I6SC-@*yH!P#|HwW#Q>m&XJ(l1subpv?{MK80jg(`?WR>+7se zSD}j+*}#>#-s{COcQwoX>6m>xUvp4t)=Af00l^es7uRZM4U=XQm#gB~)m#jS!vx_% zQ1I&NFw^;%XClfvo5jb`+(u@KVG3|EI(Piyi(B;%=^m89bY)q%IXp^PcRz!pR@u6@W+g2IAg^SZz~-SX5Ftl^9la3z8m4@ZM9}#;IvdnLR2B$)qg@uo>pLsF~||F z`kSpTEY*;gvy?c&?s8XV5DYSx$b3H9CrVP6jwF-&si`BRY`RT;ifCT1OH)$++uCvo z4L(U7t=77bHT#NJUHeO68ge>?*+f^_Gb0akyqhqUf?COw=Tu=w5&VRsk&Fi|OskDy zgK4wYp1O*D9OE}cRzEx-HrKWgY5zkaC9`nVhsP?BR13J%3b}Q=TJ(+u=(E zoKR}vfZ`1%;EjUWl#7=C^a4z`Bj=(cZR<1u`q~u;%P%W8W81>%$(WO=y!0n-cU2Nu zUS}!H+4>40zjP-rx5vz5#)QNXn*e%llaH_<o7M<61WgI zq!6|V98JebUL2`9-!W>87J4g>*i>2+ZIidtMZK{P(y9ihOOq#pmh@!!*hAqJ{v1Mou)#_uJ8pBs_H5Zt>A`55gq7bj8(`X@2j7pdSMr}h@0cR#8a1^ecjv7$osdTS zz~hY{U1IebRq!&sd2r6!k*hjYI(4ms?mKgI!J-AFKoiCT=QYxeZ&a$lU& zx>s!HWoz$uSXlhK2a8^Q&3WIRmO03nci)2}tdqhcR+Fc6+KgOF$Tcd%*o%p-`gtb+ z(@4fsU5^QKI&wt+nLmpFy1Uhu7Nh^pr2$*B2(Ap6_i zgn;WGilMAj~9D;dEVjWDL@8Gw_8hgM{zH z&<^gz^zgEcZyzUp6+|S*3LAu;x(Xi~xjPX;do`14>Zv-8mCvaOmGK;^0{+;dS>r zWuub2=fxFwpbj&?(Eec`sMqv(v^J}yGsN1<>v+F8;Y+SrH>=h8w``Au-^l6=!6u92 zWtU>#($6X?ooy#@Y1m8O)23&|M-LrG*fqsCCgamTw9@hK&2z<=VQyIR@cVyAz2O#4 z3>Y1SBTDGCPG;?H`$z0?9n+%E63P^kjYa z4&8`_DrAb36Jmznov`=|1nIGwMlhw(Xu}#Y83MHG;fvPxVGE^wsPW+j?k&|A3hhFg zfS;Zk@@&=lDw=2*wWjv)fULV^%rS$ytROJ4Yxv% zQuHxmTGST!J%-NQ1R->Yh14yi7Aj){CP(|T5^+7jLqH%Eixqq34l2YAt z9SnET{!-HGr=F3{_L7jI>%0)=kl;Bfb|BPrP5czM3|YBhXrII@(;RHHhXRsUo~+=; zLAf0M6x{Fi}7ZFF`%mA|Noi9sd)fjQt4xEsWosYsL5>ds|>EDsEvoVJh@(eg$}17No->^xpuQ4b3oYMs~KR zCy;!0rR-NFnod5^@V&yy0*2^s&)rA)JKJZGM5a$Gk}q5R_?GFSk+)hS%shORMf}wT zF-?kNY`gY<5M1z#m^a_wF2%&)Kg6L+_&KY(<$!^={gc^r60Y4hOIdzC+bGrF$}NV< zK#wcD)yEs9f|MhYk7YxYum!H@S*zwaCAwVKiGFwjd)G4w%tU2GVNEz&ed!tg_<^Iz zX|+Y|&-wl#V!Q~nCs&;CQlQ1#w?Z`)335QmAPR*o?%~(gb3@I%PkK~ROKk3D)46f+Cb z^U#os9FaT8$+#IHAl6K{{IXL_xZ1@R#ENt#5gqvwKQRSywqwIzdVMZ%I)CY7c9~1v zp^0AOV?aOOr)-SQ!mdGGe!ceLUS|c+xt}rtz_&P4)Y2;QgqvMB6_MDyMMSa-@auQm z4>jGdGvY{+)n3;OId9u~aYSEuRa@h>yF%$xrGa-8 zA@I8|I@{Og)R^pz>(9P8bv}ee8_o$A`p$UnX#Q*LGO8WhivQV%`N>N(UC8x=tMpIfXLolxb z7h8bv62S|*!4lR3Z>G=se2K)1D&)6|juSmflD9O=T6o=_;U@TUXZu%qE15Qlj6E+N zH?J2xxI}~CaV@0nqNU>iPU9ZD%^+|bNEw_b<|Wane;G-)I`uqu)*!#)F#DLr-<)sx zv$MROs_b{2w#PEhHowsfF5q^hs?&}g({-Zi^fWBoXJLHl-7xJr4ldPEGC5HRt{A5~ z+}d5_f!&GcmW~dNpI3z%`~#avYR-u<8lsqo!k%jEzS(ohg*|j8kSN0M*7Z)>V|@lD z6%toJN-Om;@tAjXGJM`YB?Jq&=in>#A-@067o8(us%jJQy3&g?2fS;l%EnpzXh_6v zywKk=ST8QmM}dyG2Hw>}WtxSQuWvwl^&a@IVNGUPA>+$CAY=lP_LnXXyu5FD%jcew zdXD2$TYH*KE(%3@S%q##A5odYk9cc&2c5ZEKK&qEeG9jr{%fOqzt)>n(^+R{`v^2! zeHV0aT(_DqeKTs@?X$ZWqtWf_wb^-ddxzV;*xkhH%Bp@c9NkF;Uo}~VBg2P#QB!{n zA=n37dDsU;IRo4eY1&FAJ)AaPV`{?e3AoA|1ibevjpjCl1nf4vAU`j%Vys?(i$ZRT z>r;b521F%YG`ejy)_dT+axgZE&GS{bJ*MwTVfuUEBD20$JC^N`d`~hTxh-z&&L{S0Je%dSI@`t6qm+i>{oPhggH1NuB7zxrpVn8UI zz#@~U&ECfI=l|AF2ydaK5RG#S4i9>C+hO6#7EaF+9Tm7Q2gr8IN3tw=&9d?-2>#BX zULt1D2sIjB$?oUwg^E*LJN2+dOBRYh@Rjd*ur@#+V*nN!y!n<_*sYD5?AQVJ|W+D%)!qTY!*@e5ZVTM6VE zd&GZ#OINo!KSn!sbHdpjcAp-b|C{ z^@=3aegIRduoXHoM?H982Ur#!!xy+q;MRBEV1?${?{5)w`A+qUpXJ`}A*s8VhOPqv zHOufjSV$JY=<3mug)8V~LRvZo-p(7(vItEM%Y9CZ6VJ8Er_Hq5xc7(0Od>@!?!KlE z<0@7p30!p*PpwfsYG1_V(km;;+I(kfa_~5>TIPDO?p74i1&d8L?*9hN&89ud_N-8i z;pDAy62cHLzngLHKs)xL2Zn zf_vq}c%RtO2F1ToLS+A=LPF=2Ie)H`yOwi|^76z9?Uen_x15MgqLV%BgAFN|$oIaNX(0?AN$*Fq0J7qIAxLznFvj0ZYRH=? zqh)cLWxKN8IEl!z+4C&hsLFYp_B!q6RIll7XXte!kN)gAhrBX6AmmE8blp_BGa5GA zLGeGm0LeudX_hvIZAGEKxaZ9Hkh70RApz_DwuO#mJwGi)g%oL=z_&+w6Z%Cr)M67gY0Va zb~9JeW+tzJn)CDDrXY2{27^OfKIX1n{l()HC%=me8|_X<%tIKX6ZD;(nZdY*>gU0c z*1GIu`MU@3LMf(1lXi`D!Z+8&=cu%gC(8?G{bK4pD@;{D+ov@#g5sQ_7POQ82)p*T zb-;v9(>6%0hS8wO`6)Tu2S*kEuT*WigdCwzb%T%Ufzpn6bE9))7K`O|;8tx->LnzC zhO)WJ>v})qBDIv@nX(uUvvX6gkNelv;|x_GuWy#n}aWZS@RCAPquk7-x=UFeA2m}!KGn-vU; z4lxc2r2=EW^u{qT2a>Q}3e}W#4KLOCxK>&HAC!(|ga`2w>coUhsN0Jb}3{=kB~#f=5kErd>(04)YRuA4G5b7TVE5ztb;{?| zP+Dr;_>+#l=idXQVjpHRwLG5tUH1OPWk^Q0T!jXcUdv@`>rHak!s_32cZ6AE|DPyi z_ZJ24b3O^@d7LIf_piKGyJhow=mtE9v!@m2jf_*h+RGs~HFP#lvC~diQZGlZ&oR)b z_%*JktWm`JCwN6 zr9?x_t$UvDd^iI|sz2KGYC0OB(fOSxTOrLU?8_|*~*{QIJ&SmE^c|%Fm zYY_P&ZD~YSh09zy&yOd`OcF8YTcV;tmtt>kl<-F6b9X1jPfZy&owAy$GJxK9RX*72 z$~*~ECY3Nt#f0D!^((jkS|M=)fo>oaAgCZ>qu?ix1s7c7ua~!*L+}|P}cDme;_x9CB6MN zqUT_WyGC?rAE-f)5vvt!ifywRq$l900p6#^Oj~JBW-q^GeTPg1-^WuJo1zNoWiP!R zO+B%%68XW}rkV2Hc4Y2s7lEK2d>Wfb|+{3FP<(5LkDXEpz7<+VVa3g&YhM=9_$o`ht zL$p$(78v;&pik6FHNM>I+1ip^uk1#bw2?eV>q8fHi+C>Al{>)@%ec3f2glZU+$J>^ zxfnIaN$w0C3yV)#* z*ZGKx*6r&Ec|NuatIEj{b6@A{vS5BuM}CALr3OIf6+^?2 zFl;+l&3cQ+*)eBLM@QFArb2ej7x#JRm2Q7wfB5&P!M!Hd~;qlrXgA{pl45 z09w<|JRbyKT!T>$Ul<@d?f%A^+h=5Vk~>os0U4d%o$Xg)0q=<@|5g2tJp_BY2Y!c4PHA@bCeZavz?OpJvm=5A zHrR0z)j#gf-lxIIH&or$l(F)hdQTHHm>XoBSOoT{{oksB6}~N?qMu+I3H2NEXW!NI zbvQ`p{EssmGN0m2w^YT#U~o`wqjbI75os%U;%o~ivZ`rt&Z#KN-y(ia9Uy=J_E}V?Le1)b7x<1QP_EN5ztZ<>elI_3im!lc*faHQU~`tY@d~57gD0Bai75hqTFyl-=e{Y@~|O3knXIKzn^25 zq7ia6G0T>`EClwtnTya7&e^KKrZc59_E ziJlK>uvexn5=rY;KFtb^oWN`5a)W#pzmc;{F}+QO_g0j_Vb^{|x9%*`3scq{pp)Mc zuH7KZ>+K_wx{dBldRuKi=k^*q^9uV^JB^L+(UUEv8F7i=0nwb0$KB9^uHRyL@Z!cQ z(P=f)x(F;ikYP0jcRBqu*G>IkzPEDd%Ql;+ zG0Tl>MpH!2=FIf`7$@lt`%H)Rg6rdjvluO8E zMvAG5nxd?Eq;L1ktS~(Z+;^FT&bMpR!<9H-ce39QP-t07T$`FgMwjM7n( z$7A~QiS~2I6^r6<`PMh1qJe;BF~N{ML=xrQS-|zB(%(^cu|AlD7yS&lqwdSE6LkUn z0$)J^YIk3MVgp`Jk(q;_lv~j9TjRt(F5cCypLw+&@{4Ui9i!jmU|#BMo@$qkrg6lK zAP06D^P=fC?&{@FCG+$04!c)bK_6AnV0l)P0pKL)o}Q|GB~0kHn4^}2;%v{A@tt@E zKI|C2x4shoX77z+Pb#_x^dO~@u9w2p1)#yx5p?l2Luq?vCYIUmunZK3k)H>UVvQ=>fp-C?vx2A5gZ48NV{cEfbT zy(!h9MU6q*PXR)9!4(!e=xV>LPQR~p_jLO0CRZzeKY6_{AT2D-(S*G4#t1+{<5r8K zm4q(!WUwozhiIKb5ycCits;T790&jsxD;OnB`Bng2)Z zKkOIi!!;|$=Op4~34q6AX6dBbLO&_S0_|XKlI^kSrD!I;n6j8SDCLSgpa~TZ{-)LB z)ZR;Zm(*@TDciN@D=_ls_OXa;z%9WsrDqo*a~woFTk7V~aqOX_rT4I5dtknQ6xg zEddTo$lCgm$LI+O%m0MvClDzN}cP%fB+^fuRIRP$Cv z)3M7RYd&VOkm%L@i8Z;UGndD2;u!09mKAinchE|%KbS}{SpL})nEnHu+R@RFyn)$j zKGB%(X;J(?y?`O0_}TKheYJL-A2W^FgPk^{{?YTiC*=H)=lE7Z*K^=yhpt)biOq6T z8P{!lN%P)$aXZBCwI0&d7|q}2wAl}5;PrDd%c(hZwfiud?_6+u6QDkp*}HU8-R3+n z_*DTp+x}de{oh)cj5LbS-Wr}Uwm^&irs=`1lX<@hmg)kdcC~uC%L23e6)+|$N72V# zF4MC&aRUL!SNBu|V_jY+2?c^MK<(Uty18P)cH8{6uDvEGlU`}!gVpAX%r02Hzh-%s zdU$?{scO4S9htFl3D411IZeO{KuSuQ66`cnbSnDqPEH6Vw3Aa9r7Ea(&SI_C7ARM_ z8t2)ufL>lxgq$ZFG?n|IaE;o9sEGNd$gq|+H;3+X{BK{=VZ#Vjo9c0Y^gLT{mEpi8 zK70DrduRfSEA+tjNbUMiP8fh$$z<}JEYGn=gOjFLcmYj`Lvoyyy2kR9N;NsFo-!6XV^KCo)asdr{-Tnykc-qAqR_XRGdwB{ctw{!FFmM@_bHIeS;S4@WX2G6&n z=SR|~sN&f!BXBu7zEkw)(eF@kr9ydCpEW8P|cJ^ z>g|TLeXC?$_UiuTZ7adXnl;Qvq>)_Rp|G5e`|Ua2f{DpN54VlXYMbRdsARY{9$!s}L18k=$a5OVIjl412TiX`yZ*o&Jukix)ac_F`+G_NZtUy)N zyA9wXKZnH?@Jxn8r}jRwAxY72j8E`t#3^O!Nv$ov_>oc9@k;9|>{$D{AIYI}y}|Qp zmYX^CrwP>UU1y%XGp`O=ZL^7+t#5Du{maYVTdlxj?Ap8nsaCtuxY(Bv<%^u`6pZxU zw$bKsJE|Tj5hHiHy+sl-tB9L8-el~H5pnY`rVM!r4Z+HdGK$NeIz2(M=8Fv+jwENK|RFn zhzt?3^k7}An2aHN8`F&Dl%`W! z0Z9C^POx3b@Vj)oV9+4&rr=!Kzx=_5${ zzLaFG3IUxayKfh;S23Mr!KYb%i|(LswG0`3m9CrZ^hIBun10uIRX7}7q2tS#NFFda z?*EYWRbg?oTe=AeZV3?F65K67aCdiihv4qPgS)%Cdt3>s#`Q5%28M44&j(4uS=X|Dd;>CvV`fRtdQ@Xx(UJu>-JSL3-GiEDn zXq;qUcj-1-&LU!C#Lj8De0EXWX6cN8ZFf>?89YT~&gP>9_uBV+;fAS0$cThGKitl` zo6zs78awhys>Xi~9^vzy4b{$7U!JGDo}qKzj2D`&pILCLns8mtRNj$#b*?~hZGY7{Sf?l(NFVKyNEQC6OE_E@MkLpz5gMu{bgT# zz?FueF$Q-M7I7Z3h|BBDWv40arF|A2Ihrb5{Hua?;=hFijC!+>cG5%zJ^Imsx$e<@ zbeD?8K9tMXgu!4?CZyVy_iTQRA99gK5DFtLdrZYf%2lMD&Y>?HLw4Z%MY*1)&(ZyZ z{rqQ^gN6P#L5gG+J`SY>maoh6gs;nY3^@tuhj-mjg*x;(ZD0dqf2 z>1eX_=6M_e)IORN5H^eBx>868Jdy5r-hMm_ua>PiWBC_9d7r6~t=?)rT&AY0+t@T}!((`5!BQ;8WpCWxoyEs} zF(ehxoRMbyt!`$3q=z#tpc3#wP#pdJI zZs;MJb(>fnnfA7#$IEzM$euY*62;>)Jv%dz>OCKNirl!cHj~-%%ZS(QHhHkUj%jhZ zej5~$r8$l0Ind;OzvgkUh{upw!P~sE?)zBC;QKZ|*o-7;H>L|d>4?te0Qsjh(?@0A z)-sGgO*SngRco&3V6)m^!RNCQ1w}@`Zf`kFe)7=s^jR9Xk3OAsny=q}WWChP?6AV3 z)q38l1+emZ>bm=AES!zjyc%P__Q{%Yerf7_*o~u0A^ImD6X)Y!S*_6(0T=j@mv4a_ zP|O}$&9V6DWWDMcjD-&c@(kpOQlAA|(D32W^f4Cab%Nq~=Smwu9Z$Uk<8Rf{g*Z1K zeiwbFr4&z-Quh3!h|N3|uAl=osaGwlNyUMYc|Hx)+IEhr*n8FZRMlwQz}BWb#xGat zou?)633swtqmGe)Qo8+%sLR9ex|AZmYF0~@9MS_Q*8w*}<1K?)>a+sP$nxQuhmvw= z&T{(L>lFJP6&w)zP`+Z8Avd(5WgJwDJIu9E!4@E~6T{fxabolRwHp7fBHJ^2ChW*$ z!;ePlUP%+dG$?`;;w?CxuDnF)Z{&nuvRbk*gqwp<+X(_;K7!v(ar(@cUd~Mzdrz4} z7MlT*7}Nn)9+m3ZIj1c%as+oL>D%ll6WP9w_iHmU8#=OhYa42Mzv&56;p$mAr>m zmB`_d`t9uMEd*{Knmp0@VBC-DJkh@WjBduq<+`4i%zn?GtFt_gQ%ok3_PX5eZiPqh z4f?75I0BQBqiRDNoFGi#xACkTk98UWHMoOD&Cs zj&3%NcTa`o&6&mY3$S$PMZhY~pPs9DK<>5z*Jy?u*Zu(4b@%NqJszdg67Tsvwf3uh zHQz=rdBUVDvil8>!9}gAtSqmd@Kf|6ax!n>`_cRHR;jLeQ{YjM6nn;6YSl8}?;?C< z2DYni>%<-uKm!5Pb$ZLuyYbVRze$A1Y}|EoiG$O#}5 zH8SSlqvKHFcSMoxlJOi2)H+fV+cf)FXmhFN9d84yCLbway@^bymiH&y;8uOt81)TS ztBQ0;=d1NgSX>b;=TUN0K@lxKE|HzG#S9{53_9!+?6V&W(3xOAsXJ0f4WskrI(nhY zY4GXGh93Cpe!lDC)EN}6;;EqZ+lBRqNJf(Ms4>6GfNyzr3v_5+KB#JrewMj7<};0S zM4!pP)SM*-wEQT`Nkb;(?oa@Xh9h9sWx_aL}aq0d1fo$Fd zkxr^!&Aq4j5K?cmE(EkGah*JBbzN_xHGqU|Jn(6;aAEDH5YHBHcODY_RU# z<S;s)qGI@|$yFYcTz>=JA?hnFtWG}fI%M1_B9jD^nkq7{BU*dZhNM0uKfmhH7 z{mv7SRtuq%+1|CT zy8NkfU=d7&iRrB^S|GG$ApBxCgUe)<|G;F94SfRll z_XFTj%BlfpurGh8tG5h&p8$I+A)#E7)c;YpCd*_KuK%m41}|x!ed)2nfaRqC8c~$t zdk$9rdtu4rlj8J~2}}Prd&$PO)dCRllvfW9uzef_E!!@fb z5U5a)$SSw~^mu%(x%Ar=q;WNQihA~CaH;VjEB)M)>P=m9u>HPl)GPwGq8~2PIeNo> zuV1gj_i~>zx_dq`diADtw9405BnxSybNkVx^!3gPu>pSb)K|Ag((&Rt{NAm$uv0Pj>QCP9p?m2UZ#gcugj=zCi_M)E68)m(z&CXz9L8R81JH)lE{ycJqT? zsByzJ2!5GQnP!P0d>>x!4H#1^cfq-0qr!9JgfD^0rkq*fw|;UAoC%p`evw)U}uWj!=18;ZjT~SvtY{D z(S(4%D~D3SGG5dyrYsNx)dsOr!BSO`EKHh^9${+ewAeAUELYcJljjhwt0CPIl_^5ZA_iw<9pc ztA~lC*D|Ax4MOedH}id+%)j0Z)1wnBz*|7+UuxP7<)Tl2j;ERdX1H&cME%`^Wy@R7 ztONE7$ryO=7tw+AtL5dtt+UAAdBS}k*!Fba8u2ug5?u5!ZQzQZ(KIOn?~hYGa~x zXVF#KAA{GIh>vy)SJ2g`Y3sR!+8QifeD+(fUXVy#df0Xx^;~Dtd2P6B zzq;{n^knt>C+o&RTwoxI061&DD1dz1E9qR#ewv5IhN2jTvMnj+tP>L#{#d?RsHMa% z7(5iATitq3Cs{~b!K#y}Jtp@_utqdz45O}cO-AX>#0?Nr~oqG<~ zd|NjR_G)o)2rKG%h|28Mq>+}yk7qjm9gV6&r!#rdv(nsNAW`0D=kVEK$3p%M%Da;B**o zh00x?LffwPpycE3W8eq*fj>)*liFIarA5>o$7VJeaRqX`1Qk{;b3#^WY3E`y#!E{T z!(&qbQ1LgZLqWF74gm$3)&Zi#vn1$SpH?<3brR`w5r(g`-aa=`6nV~bb$N}Ox~xNk zU$Rx{$d7zcQ4@WYg?>fR%+*oBNR;R`tFD?*!_ugcC6q0>{Bd5~Cm;(4_)J7QMovfc z_~I*jp30@`4JBC>e4C}O!F3y>VI7HgY;KIdeqFhd_jJR;Fj>oF}WG_kVhM z$3CPX!ug+|Ym#?x)k0Y+7{fI-qYQI2xdxCpYFy0XN5 zCtR*y4`Q!g7j+BT1-f?5cT$Amn>mzBTsJu1yIB$hNKWlxRas_eg}RcfQJ|Ieq7 zI3FuM{{^ykLLKiv$}y}oH75)HvkBxV;1^IQDR(g!UYpLbXfBQ{EyNqU{4EcZB!|&Y z6QCd0`z1ew2jiomVBqxQIx-5$N1{#0;Uv_2kkQynK4b29dI#dwbbqJ^=XuP2JnEf* zi+O4yfBd`a_83{;JrA)B%fk~X{%Rg&=9D=VJVf9)ymj=mpwiNV+TqHJV?X#fDMq@5 z$eLA6Ttn9yeyQ*M)URV0gx-#-Q|50x6<_gnFAR_pHCV3>h>%*FXw0zS&v*kCov0Ae z1m&;e(G^FD=Al9ap%rb95=nxSG79e#sO(=tLZ&w1z6JQI#=GB8h=tu_a1Tg$nCBeI z7H%!WxwEgIxb(7q%Lep|0y+tME#e* zuFjJv{5l*sJW7q9c1AYUIL{%#GaXLiPYXpgh72p3GWfck9cT|17A5H1%0bQI%O7E^ zv<$P@2RLaO8eg_jDhXnf6C-z7z{*{%AlnHk1sK#e8{VruX2c4{t#L~3V$4#22+oONlIw)YGN-XWCVgG8i%sO z>{CJ~6D+rmf7!a{n7xnkd6W0VmuruJrYL7sMz`pH@8UMa!y|v1)3@G!Slh3oRO~v- z7MbCE+e|)|@jPS%Y_GAKBgb~RDHx_0&#<3A0iiFWQBh?iq zU29x4;mQ#3L$efoSn(I1GHSC$Mw>yba&3UOncb!$_4} zjyH0cqi3N!B+!bKHye*+?x+w(wGdzfv?EZ=nzy3E3%`jT)NKQ+Bl@&9vPMRcRt);2_O`v7ESoEF4VdZa_4o35# z09#NKKTa*VQAr=H0LYGg3Pu#KrSh3;qR1VU=jCcPT&^J@a=ry*rbpiC0Mve7KfL+=9o83POEn?L-@P}b0CEmZB9tGfr9jRpj@sf!frdL1Xig(K`JsZ&=-P;Rf zN6|I6Irl;J*B=&_YwT;a*jOKgJ{jA}YdC^4^l1D22mbjU1O6&_MkWM}$vwwLOy_fm zS#IoqM6U0#>rN7Tvo*iC%LX>ljmDaalRtWSsySPC<2qDDpw7GMOr% z=%IsBj57IaU>VPh@VO8Guj^YF*BM&|efTbF+O4-t-{?;S)UPcEOGPp9c*ZTY@d%0Po)h8o?Pnt}*GVlnGA#fg^kp!`THiH& zj4lASh$;g4(j>PFH*YeiC)AzwOX(9!oYspH*Oz7KTtH#fkVOsm&k0aWthuzCKqdJN zjX6TKXsTJuHbMT_?e^;tzCqNr;`|mOXNmLPJ-L}~6$%x_ifwlM#T%><44hTGD71hT zGEF`LG>g$#!k3?w7psT(XvQ$FY&%Q?`iPfm!WEeCWCm1IIWl@i&&T*^RUNxWn{hLF zhl7Cz8`feaDa*U@E`L_KW6l<`0gsMSMCtZt94tkdobEk#k(3S1RqhsBt9q|Dp(3`1 znS79+Z??|y8{>U$_UiK(!q~r5o&OtGazX}ShME^-EAF0@3EQ8A(s8nM?!5PdL} z!JA}_hCW7FE-Z`Ub&Q2xh6%ZWxO%DEP+&l|w7Wc4a593TU4L>+;wP4bj2L*xqXE~q z1O+2QMk=dS%4sc6!{Jm@uV(E6SJ&KUT5-Pzv?<6%Ct=s1NyNL;GEf_1quzov2}aA4$- z#h&ngW&sVRGWy$+=mGEhw`b`-q~Gx@8x}u4G4iw@68d3IBzu_&#kLSmW+617nuPe8 z|3E#ianK1ckaG4?OMz8BQQw_|OMx+wm)X3$*xc}SHPee;@)0%}+lYvgt!VAo{G!Gd z9FByxV4|vYakFU1Wh6``k;XJ&`E1Kw*XpWN?)Hkx?vAKu6?7DYRT7a3bsv|;0s!n5 zEeAi0#oTbTMc&1`)sVvN{J_)G16(@)A1Dc=pho#B|A~C6Xs<3%KIv>4;<+>@xD1?= zDTrO=Ol^!spVoqj$E9aADT{u#5WGyoZnCzhrUX00ozlQ^cR4LyOR);cxV~?VpRCr2 z0mu~CXv`wH-AzYN{<%0xz$TlV6{Hg#WxH3e(Kj+0MV{A;*i~~X{G-BJaR z9Vo7%O?NH<=cv6!)y94bNx5QUojGuC$R22fg_^_V$0qMUb>T^o9gU*N-riZn?-S zWGEA1MXZD(k_=iYz6CBu4X^DCO`4dlDHn|WVKkAE60LU35i=DpB>(jbwUXItEcYi?AC)O6~|wvZJ86)1Pt@+&V`xusVVNryKg)7NN*P z5`_3-k#LD@d2*fXdz~i;FlfqD@T8E{m}H6fH|#^-@f@BOF}jS!c%QR~&`4c6d}7I3 zA&-KU^b8t3Cwc7gv?kz2+rE(Sr_b8ZO`w%0DbD3&MT3Sj!(tZn>#yxVl4&pH1N6ZxZxH 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 literal 0 HcmV?d00001 diff --git a/rac/assets/images/lore-header-light.png b/rac/assets/images/lore-header-light.png new file mode 100644 index 0000000000000000000000000000000000000000..8791db960109d8257dd1bdf1fe3a10a9ad70ad3a GIT binary patch literal 121298 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%0A)QM?WF7|Np2utFX4ZEnK$+ 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 k_+) Date: Sat, 20 Jun 2026 22:23:25 +0000 Subject: [PATCH 09/30] test(supermemory): lock the live SDK adapter to the real add() shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified SdkSupermemoryClient against the real supermemory SDK (v3.48.0): client.add(content, container_tag, metadata, custom_id) with metadata typed str|float|bool|list[str] (so the export's aliases/tags lists are accepted) and an AddResponse carrying id/status. Adds a stubbed-SDK test of the adapter so the shape can't silently drift, and pins the extra to the verified v3 major. Note: no live network round-trip — exercised against a stub, not a real key. --- pyproject.toml | 5 +- tests/test_sdk_client.py | 101 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tests/test_sdk_client.py diff --git a/pyproject.toml b/pyproject.toml index c069aac..05b0c2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,9 @@ 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. -supermemory = ["supermemory>=3.0"] +# 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"] dev = ["pytest>=7.0", "ruff", "mypy"] [project.scripts] diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py new file mode 100644 index 0000000..08a4096 --- /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 lore_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() From f4bc2f5c394075042ae3825131f041d21f580ff4 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:44:07 +0000 Subject: [PATCH 10/30] chore(release): add PyPI publish workflow and tag-driven versioning Adopt setuptools-scm so the version comes from the git tag (vX.Y.Z), matching rac-core. Add python-publish.yml: on a published GitHub Release, gate on the reusable CI workflow, build sdist+wheel, and publish to PyPI via trusted publishing (OIDC, no token). CI checkouts fetch full history for scm. --- .github/workflows/ci.yml | 9 ++++ .github/workflows/python-publish.yml | 67 ++++++++++++++++++++++++++++ pyproject.toml | 9 +++- 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/python-publish.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d0d415..c330456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ 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 @@ -17,6 +20,10 @@ jobs: 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: @@ -45,6 +52,8 @@ jobs: python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v5 + with: + fetch-depth: 0 - uses: actions/setup-python@v6 with: diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 0000000..1cff9d6 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,67 @@ +name: Upload Python Package + +# Publish lore-connectors to PyPI when a GitHub Release is published. +# Versioning is tag-driven (setuptools-scm): tag vX.Y.Z, cut the release, and +# the build is version X.Y.Z. 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/lore-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/pyproject.toml b/pyproject.toml index 05b0c2a..ddeab53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,12 @@ [build-system] -requires = ["setuptools>=77"] +requires = ["setuptools>=77", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] name = "lore-connectors" -version = "0.1.0" +# Version is derived from the git tag by setuptools-scm (mirrors rac-core). +# Release flow: tag vX.Y.Z -> the build is version X.Y.Z. 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" @@ -43,6 +45,9 @@ lore-connect = "lore_connectors.cli:main" [tool.setuptools.packages.find] where = ["src"] +[tool.setuptools_scm] +# Version comes from git tags (vX.Y.Z). Untagged builds get a dev version. + [tool.ruff] line-length = 88 src = ["src", "tests"] From de2f6eac2230cdd25f4e7eb0d5aa1a33a5329991 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sat, 20 Jun 2026 22:44:07 +0000 Subject: [PATCH 11/30] docs(readme): document install-from-source, CI usage, and the one-package model Add a PyPI/from-source Install section (the name is reserved, pre-release installs come from the repo), a 'Run it in CI' GitHub Actions example, and a 'One package, many backends' section: backends are CLI subcommands with optional per-provider extras, not separate packages (ADR-073, ADR-002). --- README.md | 102 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 827b017..e7aea4e 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,11 @@ exact, current decision. Recall fuzzily, verify in Lore. ## Quickstart -1. **Install** the connector (with the Supermemory backend extra): +1. **Install** the connector with the Supermemory backend extra (see + [Install](#install) for the from-source command until it's on PyPI): ```bash - pip install -e '.[supermemory]' + pip install 'lore-connectors[supermemory]' ``` 2. **Authenticate** the backend via the environment (never hard-coded): @@ -69,15 +70,37 @@ re-push updates rather than duplicates. ## Install -| Command | Gets you | +There is nothing to build — it's pure Python. Installing puts a `lore-connect` +command on your PATH. + +**From PyPI** (once published — the name is reserved): + +```bash +pip install 'lore-connectors[supermemory]' +``` + +**From source today** (pre-release — install straight from the repo): + +```bash +# one-liner, no clone: +pip install 'lore-connectors[supermemory] @ git+https://github.com/itsthelore/lore-connectors.git' + +# or from a clone (editable, for hacking on it): +git clone https://github.com/itsthelore/lore-connectors.git +cd lore-connectors +pip install -e '.[supermemory]' +``` + +| Extra | Gets you | |---|---| -| `pip install -e .` | the `lore-connect` CLI + the connector library | -| `pip install -e '.[supermemory]'` | + the Supermemory backend SDK (live push) | -| `pip install -e '.[dev]'` | + ruff, mypy, and pytest for development | +| *(none)* | the `lore-connect` CLI + the connector library + `--dry-run` | +| `[supermemory]` | + the Supermemory SDK, needed for a live push | +| `[dev]` | + ruff, mypy, and pytest for development | -Requires Python 3.11+. The core install and the whole test-suite are -dependency-free — provider SDKs are optional extras, so CI never needs a live -backend. +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 @@ -104,6 +127,35 @@ get_artifact / rac resolve # …verifies the authoritative text in Lore - **No embeddings here.** The backend embeds; the connector only ships text and metadata (rac-core ADR-002, ADR-066). +## Run it in CI + +`lore-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 'lore-connectors[supermemory]' + - run: rac export rac/ --documents | lore-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: @@ -135,10 +187,28 @@ print(summary.summary_line()) # -> "supermemory push: 263 pushed, 0 skippe Pass `dry_run=True` to preview without a client or an API call. +## One package, many backends + +There is **one** `lore-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:** `lore-connect supermemory`, + later `lore-connect mem0`, `lore-connect neo4j`, …; and +- **pull only the SDKs you use, as extras:** + `pip install 'lore-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 -One repo, one module per backend (rac-core ADR-073; this repo's ADR-002). A new -backend is a module under `src/lore_connectors/` implementing one outbound seam: +A new backend is a module under `src/lore_connectors/` implementing one outbound +seam — record parsing, the CLI, dry-run, and the summary shape are shared: ```python class Connector(Protocol): @@ -146,11 +216,11 @@ class Connector(Protocol): def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSummary: ... ``` -Record parsing, the CLI, dry-run, and the summary shape are shared; a module -supplies the upsert mapping behind a thin, mockable client. 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. +The module supplies the upsert mapping behind a thin, mockable client, and adds +its subcommand and optional `[backend]` extra. 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 From 4ce39f7eadc6f204beefc97c6c9b8d7c533d82f2 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 08:01:21 +0000 Subject: [PATCH 12/30] docs(roadmap): open the graph connector track [roadmap:graph-connector] The what/why of consuming rac export --graph and loading Lore's validated relationship graph into a graph backend (Neo4j first). Companion to the documents target already shipped. --- rac/roadmaps/future/graph-connector.md | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 rac/roadmaps/future/graph-connector.md diff --git a/rac/roadmaps/future/graph-connector.md b/rac/roadmaps/future/graph-connector.md new file mode 100644 index 0000000..fcf34eb --- /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 | lore-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 From 7bdc4bdca4037752231949fa4a2f7c95ab4b9073 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 08:01:21 +0000 Subject: [PATCH 13/30] docs(design): specify the graph reader, push seam, and Neo4j mapping [roadmap:graph-connector] The how: typed node/edge reader, a sibling push_graph seam beside the documents push, MERGE-on-id idempotency, and the undirected/unresolved edge handling. Implements rac/designs/graph-connector-shape.md. --- rac/designs/graph-connector-shape.md | 158 +++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 rac/designs/graph-connector-shape.md diff --git a/rac/designs/graph-connector-shape.md b/rac/designs/graph-connector-shape.md new file mode 100644 index 0000000..aaf5de3 --- /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 | lore-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 From b9c89fc2773b14986a5c71ff3b60cd7e1d7ec0a4 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 08:01:21 +0000 Subject: [PATCH 14/30] docs(decisions): record ADR-003 (graph push seam, Neo4j-first) [roadmap:graph-connector] Locks the node/edge push seam, Neo4j as the first graph backend, MERGE-on-id idempotency, undirected-as-single-edge, and skip-unresolved. Validated under rac validate rac/. --- .../adr-003-graph-connector-seam-neo4j.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 rac/decisions/adr-003-graph-connector-seam-neo4j.md 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..e3eeb88 --- /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 `lore-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. From 5e8fb0ee65463f13a3632ce8ff7688b5f7fd0a60 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 08:07:09 +0000 Subject: [PATCH 15/30] feat(graph): add the --graph reader and GraphConnector seam [roadmap:graph-connector] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse rac export --graph into typed Graph/GraphNode/GraphEdge dataclasses, and add a sibling push_graph(graph) seam beside the documents push. Additive — the documents Connector, reader, and CLI are unchanged. Implements rac/designs/graph-connector-shape.md (ADR-003). --- src/lore_connectors/__init__.py | 15 +++- src/lore_connectors/base.py | 40 +++++++++-- src/lore_connectors/graph.py | 124 ++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 src/lore_connectors/graph.py diff --git a/src/lore_connectors/__init__.py b/src/lore_connectors/__init__.py index 5f0fe30..0ab38dc 100644 --- a/src/lore_connectors/__init__.py +++ b/src/lore_connectors/__init__.py @@ -11,13 +11,26 @@ from __future__ import annotations -from .base import Connector, PushSummary +from .base import Connector, GraphConnector, PushSummary +from .graph import ( + Graph, + GraphEdge, + GraphNode, + MalformedGraphError, + parse_graph, +) from .records import MalformedRecordError, Record, parse_documents __all__ = [ "Connector", + "Graph", + "GraphConnector", + "GraphEdge", + "GraphNode", + "MalformedGraphError", "MalformedRecordError", "PushSummary", "Record", "parse_documents", + "parse_graph", ] diff --git a/src/lore_connectors/base.py b/src/lore_connectors/base.py index a4e3d7a..f7e0b50 100644 --- a/src/lore_connectors/base.py +++ b/src/lore_connectors/base.py @@ -13,6 +13,7 @@ from dataclasses import dataclass, field from typing import Protocol, runtime_checkable +from .graph import Graph from .records import Record @@ -20,9 +21,10 @@ class PushSummary: """The outcome of a push, the same shape for every backend. - ``actions`` records one line per record describing what was (or, under a dry + ``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 idempotent update it is. The same summary serves the documents push (one + record per line) and the graph push (nodes and edges). """ backend: str @@ -31,13 +33,16 @@ class PushSummary: dry_run: bool = False actions: list[str] = field(default_factory=list) - def record_push(self, record_id: str, detail: str) -> None: + def record_push(self, item_id: str, detail: str) -> None: self.pushed += 1 - self.actions.append(f"push {record_id}: {detail}") + self.actions.append(f"push {item_id}: {detail}") - def record_skip(self, line_number: int, reason: str) -> None: + def record_skip_item(self, label: str, reason: str) -> None: self.skipped += 1 - self.actions.append(f"skip line {line_number}: {reason}") + 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" @@ -63,3 +68,26 @@ def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSumma 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:`~lore_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/lore_connectors/graph.py b/src/lore_connectors/graph.py new file mode 100644 index 0000000..8b29e9f --- /dev/null +++ b/src/lore_connectors/graph.py @@ -0,0 +1,124 @@ +"""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 + + +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) + + 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, + ) From 28be38801873500d4796ea3164e936c20c853b86 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 08:07:09 +0000 Subject: [PATCH 16/30] feat(neo4j): add the Neo4j graph connector and CLI subcommand [roadmap:graph-connector] MERGE nodes by id and edges by type for idempotency; undirected edges written once, unresolved edges skipped (no phantom nodes); Cypher fully parameterised. Driver behind a thin client (env auth: NEO4J_URI/USERNAME/PASSWORD), optional [neo4j] extra. Exposes 'lore-connect neo4j' with --dry-run/--input. --- pyproject.toml | 9 ++- src/lore_connectors/cli.py | 67 +++++++++++++++++- src/lore_connectors/neo4j/__init__.py | 20 ++++++ src/lore_connectors/neo4j/client.py | 96 ++++++++++++++++++++++++++ src/lore_connectors/neo4j/connector.py | 90 ++++++++++++++++++++++++ 5 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 src/lore_connectors/neo4j/__init__.py create mode 100644 src/lore_connectors/neo4j/client.py create mode 100644 src/lore_connectors/neo4j/connector.py diff --git a/pyproject.toml b/pyproject.toml index ddeab53..19debf4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ Engine = "https://github.com/itsthelore/rac-core" # 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 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"] dev = ["pytest>=7.0", "ruff", "mypy"] [project.scripts] @@ -62,8 +65,8 @@ testpaths = ["tests"] python_version = "3.11" packages = ["lore_connectors"] -# The Supermemory SDK is an optional extra, imported lazily; CI type-checks -# without it installed, so its missing stubs are not an error. +# The Supermemory SDK and Neo4j driver are optional extras, imported lazily; CI +# type-checks without them installed, so their missing stubs are not an error. [[tool.mypy.overrides]] -module = ["supermemory.*"] +module = ["supermemory.*", "neo4j.*"] ignore_missing_imports = true diff --git a/src/lore_connectors/cli.py b/src/lore_connectors/cli.py index 1779025..f518f02 100644 --- a/src/lore_connectors/cli.py +++ b/src/lore_connectors/cli.py @@ -1,11 +1,13 @@ """``lore-connect`` — the CLI entrypoint for the lore-connectors companion. -Reads a ``rac export --documents`` JSON Lines stream (stdin or ``--input``) and -pushes it into a backend. One subcommand per backend; Supermemory is the first:: +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 | lore-connect supermemory rac export rac/ --documents | lore-connect supermemory --dry-run - lore-connect supermemory --input corpus.jsonl + rac export rac/ --graph | lore-connect neo4j + rac export rac/ --graph | lore-connect neo4j --dry-run """ from __future__ import annotations @@ -16,6 +18,10 @@ from typing import TextIO from .base import PushSummary +from .graph import MalformedGraphError, parse_graph +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 @@ -99,6 +105,38 @@ def _push_with_skips( 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="lore-connect", @@ -137,6 +175,29 @@ def build_parser() -> argparse.ArgumentParser: help="Print per-record actions on a live push too.", ) sm.set_defaults(func=_run_supermemory) + + 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 diff --git a/src/lore_connectors/neo4j/__init__.py b/src/lore_connectors/neo4j/__init__.py new file mode 100644 index 0000000..8cc4d1f --- /dev/null +++ b/src/lore_connectors/neo4j/__init__.py @@ -0,0 +1,20 @@ +"""Neo4j graph backend module for lore-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/lore_connectors/neo4j/client.py b/src/lore_connectors/neo4j/client.py new file mode 100644 index 0000000..4a03ae9 --- /dev/null +++ b/src/lore_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/lore_connectors/neo4j/connector.py b/src/lore_connectors/neo4j/connector.py new file mode 100644 index 0000000..0a25fcc --- /dev/null +++ b/src/lore_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 From 0de71f960b81cf929ceb93999dbfbcd696f52331 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 08:07:09 +0000 Subject: [PATCH 17/30] test(neo4j): cover graph parse, MERGE mapping, idempotency, dry-run [roadmap:graph-connector] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives a fake driver — no live Neo4j in CI. Covers node/edge->MERGE mapping, idempotent re-push, skip-unresolved, dry-run, parameterisation, the stubbed-SDK driver adapter (env creds), and the CLI subcommand. --- tests/test_cli_neo4j.py | 55 +++++++++++++++++ tests/test_graph.py | 104 +++++++++++++++++++++++++++++++++ tests/test_neo4j.py | 117 +++++++++++++++++++++++++++++++++++++ tests/test_neo4j_client.py | 115 ++++++++++++++++++++++++++++++++++++ 4 files changed, 391 insertions(+) create mode 100644 tests/test_cli_neo4j.py create mode 100644 tests/test_graph.py create mode 100644 tests/test_neo4j.py create mode 100644 tests/test_neo4j_client.py diff --git a/tests/test_cli_neo4j.py b/tests/test_cli_neo4j.py new file mode 100644 index 0000000..3e8fcf4 --- /dev/null +++ b/tests/test_cli_neo4j.py @@ -0,0 +1,55 @@ +"""End-to-end CLI behaviour for the `lore-connect neo4j` subcommand.""" + +from __future__ import annotations + +import io +import json + +import pytest + +from lore_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_graph.py b/tests/test_graph.py new file mode 100644 index 0000000..c63aac5 --- /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 lore_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_neo4j.py b/tests/test_neo4j.py new file mode 100644 index 0000000..dc92978 --- /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 lore_connectors.graph import Graph, GraphEdge, GraphNode +from lore_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..5702118 --- /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 lore_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() From d144e15c3d731928a44c8332515c66ab1c0f588d Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 08:07:09 +0000 Subject: [PATCH 18/30] docs(readme): document the graph connector and Neo4j usage [roadmap:graph-connector] Add the Neo4j section, the [neo4j] extra, the --graph contract shape, a graph Python API example, and frame both shipped targets (documents + graph). --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e7aea4e..8580c5f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ > **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.** -lore-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**. +lore-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. Two targets ship today: **Supermemory** for the documents projection, and **Neo4j** for the typed relationship graph. ## How it compares @@ -94,7 +94,8 @@ pip install -e '.[supermemory]' | Extra | Gets you | |---|---| | *(none)* | the `lore-connect` CLI + the connector library + `--dry-run` | -| `[supermemory]` | + the Supermemory SDK, needed for a live push | +| `[supermemory]` | + the Supermemory SDK, needed for a live documents push | +| `[neo4j]` | + the Neo4j driver, needed for a live graph push | | `[dev]` | + ruff, mypy, and pytest for development | Requires Python 3.11+, and the [`rac`](https://github.com/itsthelore/rac-core) @@ -156,6 +157,36 @@ 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. +## Graph connector — Neo4j + +The other export projection, `rac export --graph`, is Lore's *real, validated* +relationship graph — typed nodes and edges (`supersedes`, `related_decisions`, +…). The Neo4j connector loads it so an agent can traverse the actual decision +graph instead of one an LLM inferred from prose: + +```bash +pip install 'lore-connectors[neo4j]' +export NEO4J_URI=bolt://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=... + +rac export rac/ --graph | lore-connect neo4j # upsert nodes + edges +rac export rac/ --graph | lore-connect neo4j --dry-run # preview, no connection +``` + +- **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. + +The design and decision behind this live in the corpus: `rac/designs/` +(graph-connector-shape) and `rac/decisions/` (ADR-003). + ## The export contract `rac export --documents` emits JSON Lines, one record per artifact: @@ -168,8 +199,21 @@ updates — it never duplicates — so you don't need to diff or prune first. `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. +retired or superseded items. + +`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. +Both contracts are additive and stable (rac-core ADR-007) — connectors depend +only on them. ## Python API @@ -185,7 +229,18 @@ 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. +…or parse a `--graph` object and push it through a graph backend: + +```python +from lore_connectors import parse_graph +from lore_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 either push without a client or a connection. ## One package, many backends From fc1a45ba424d2f36fc8841660f0be2516442375e Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 09:19:01 +0000 Subject: [PATCH 19/30] docs(readme): generate per-connector sections from docs/connectors pages Each connector documents itself once in docs/connectors/.md; a small stdlib generator (scripts/sync_readme.py) stitches those into collapsible
sections in the README's Connectors region, rewriting page-relative links to the repo root. So a reader gets every connector on one page while each connector owns its own file (no cross-PR README conflicts). A CI drift-guard (sync_readme.py --check) fails if a page is edited without re-syncing. Migrates the Supermemory usage into its page as the first one. --- .github/workflows/ci.yml | 9 +- README.md | 80 ++++++++++++-- docs/connectors/supermemory.md | 45 ++++++++ scripts/sync_readme.py | 184 +++++++++++++++++++++++++++++++++ 4 files changed, 306 insertions(+), 12 deletions(-) create mode 100644 docs/connectors/supermemory.md create mode 100644 scripts/sync_readme.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c330456..39e0acf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,14 +35,19 @@ jobs: python -m pip install -e .[dev] - name: ruff check - run: python -m ruff check src/ tests/ + run: python -m ruff check src/ tests/ scripts/ - name: ruff format --check - run: python -m ruff format --check src/ tests/ + 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 diff --git a/README.md b/README.md index e7aea4e..76c62b6 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@

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

@@ -40,8 +40,9 @@ exact, current decision. Recall fuzzily, verify in Lore. ## Quickstart -1. **Install** the connector with the Supermemory backend extra (see - [Install](#install) for the from-source command until it's on PyPI): +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 'lore-connectors[supermemory]' @@ -65,8 +66,8 @@ exact, current decision. Recall fuzzily, verify in Lore. rac export rac/ --documents | lore-connect supermemory --dry-run ``` -Re-running is idempotent: each record upserts on its canonical Lore `id`, so a -re-push updates rather than duplicates. +Re-running is idempotent: a re-push updates rather than duplicates. Each +backend's exact commands, auth, and flags live under [Connectors](#connectors). ## Install @@ -94,7 +95,7 @@ pip install -e '.[supermemory]' | Extra | Gets you | |---|---| | *(none)* | the `lore-connect` CLI + the connector library + `--dry-run` | -| `[supermemory]` | + the Supermemory SDK, needed for a live push | +| `[]` | + 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) @@ -127,6 +128,62 @@ get_artifact / rac resolve # …verifies the authoritative text in Lore - **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 (`lore-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 'lore-connectors[supermemory]' +export SUPERMEMORY_API_KEY=sk-... + +rac export rac/ --documents | lore-connect supermemory # upsert every record +rac export rac/ --documents | lore-connect supermemory --dry-run # preview, no API call +lore-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={lore 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) + +
+ + + ## Run it in CI `lore-connect` is a one-shot command — it pushes and exits — so keeping a @@ -217,10 +274,13 @@ class Connector(Protocol): ``` The module supplies the upsert mapping behind a thin, mockable client, and adds -its subcommand and optional `[backend]` extra. 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. +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 diff --git a/docs/connectors/supermemory.md b/docs/connectors/supermemory.md new file mode 100644 index 0000000..af047b7 --- /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 'lore-connectors[supermemory]' +export SUPERMEMORY_API_KEY=sk-... + +rac export rac/ --documents | lore-connect supermemory # upsert every record +rac export rac/ --documents | lore-connect supermemory --dry-run # preview, no API call +lore-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={lore 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/scripts/sync_readme.py b/scripts/sync_readme.py new file mode 100644 index 0000000..9759015 --- /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:])) From ccb32b8a443dff3237c4df5627cce32be4026dbf Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 09:50:46 +0000 Subject: [PATCH 20/30] docs(neo4j): move usage into docs/connectors/neo4j.md (align with the pages pattern) Adopt the generated-pages structure from main: drop the inline README edits (including the --graph contract and graph Python API, which now live on the page) and document Neo4j in docs/connectors/neo4j.md, then regenerate the README Connectors region. --- README.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/README.md b/README.md index 76c62b6..b7aa51d 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,71 @@ Decision: [`rac/decisions/`](rac/decisions) — the connector seam (ADR-002).
+
+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 'lore-connectors[neo4j]' +export NEO4J_URI=bolt://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=... + +rac export rac/ --graph | lore-connect neo4j # upsert nodes + edges +rac export rac/ --graph | lore-connect neo4j --dry-run # preview, no connection +lore-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 lore_connectors import parse_graph +from lore_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 From 3141fdb4101c08ed25a2a3a083b5076912050bad Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 12:42:29 +0100 Subject: [PATCH 21/30] feat(mem0): add the Mem0 documents connector [roadmap:documents-backends] (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(decisions): record ADR-004 (Mem0 documents backend, resync idempotency) Mem0 reuses the documents push(records) seam; it has no per-record upsert key (verified against mem0ai 2.0.7), so idempotency is a container resync — clear the source partition, then add. infer=False stores text as-is. Validated under rac validate rac/. * feat(mem0): add the Mem0 documents connector and CLI subcommand Reuse the documents seam: map source->Mem0 user_id partition, ship text + metadata (lore_id/type/status/title) with infer=False. Idempotent by container resync (clear partition, then add). Driver behind a thin client (env auth MEM0_API_KEY), optional [mem0] extra. Exposes 'lore-connect mem0'. * test(mem0): cover record mapping, resync idempotency, dry-run, auth guard Drives a fake client — no live API in CI. Covers record->add mapping with lore_id metadata, clear-once-then-add, idempotent re-push (partition holds the same set), dry-run, the stubbed-SDK adapter (infer=False, env key), and the CLI. * docs(readme): document the Mem0 documents backend Add the Mem0 section, the [mem0] extra, and frame both documents backends (Supermemory + Mem0). * docs(mem0): move usage into docs/connectors/mem0.md (align with the pages pattern) Adopt the generated-pages structure from main: drop the inline README edits and document Mem0 in its own docs/connectors/mem0.md, then regenerate the README Connectors region. No conflict surface beyond the generated block. --- README.md | 32 ++++++ docs/connectors/mem0.md | 33 ++++++ pyproject.toml | 9 +- .../adr-004-mem0-documents-connector.md | 99 +++++++++++++++++ src/lore_connectors/cli.py | 66 ++++++++++- src/lore_connectors/mem0/__init__.py | 21 ++++ src/lore_connectors/mem0/client.py | 81 ++++++++++++++ src/lore_connectors/mem0/connector.py | 84 ++++++++++++++ tests/test_cli_mem0.py | 49 +++++++++ tests/test_mem0.py | 104 ++++++++++++++++++ tests/test_mem0_client.py | 80 ++++++++++++++ 11 files changed, 651 insertions(+), 7 deletions(-) create mode 100644 docs/connectors/mem0.md create mode 100644 rac/decisions/adr-004-mem0-documents-connector.md create mode 100644 src/lore_connectors/mem0/__init__.py create mode 100644 src/lore_connectors/mem0/client.py create mode 100644 src/lore_connectors/mem0/connector.py create mode 100644 tests/test_cli_mem0.py create mode 100644 tests/test_mem0.py create mode 100644 tests/test_mem0_client.py diff --git a/README.md b/README.md index b7aa51d..2311f41 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,38 @@ Decision: [`rac/decisions/`](rac/decisions) — the connector seam (ADR-002).
+
+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 'lore-connectors[mem0]' +export MEM0_API_KEY=m0-... + +rac export rac/ --documents | lore-connect mem0 # upsert every record +rac export rac/ --documents | lore-connect mem0 --dry-run # preview, no API call +lore-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 `lore_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) + +
+
Neo4j — graph → typed nodes & edges via Cypher MERGE; idempotent on the canonical id diff --git a/docs/connectors/mem0.md b/docs/connectors/mem0.md new file mode 100644 index 0000000..1c50a9e --- /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 'lore-connectors[mem0]' +export MEM0_API_KEY=m0-... + +rac export rac/ --documents | lore-connect mem0 # upsert every record +rac export rac/ --documents | lore-connect mem0 --dry-run # preview, no API call +lore-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 `lore_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/pyproject.toml b/pyproject.toml index 19debf4..9b9efcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ Engine = "https://github.com/itsthelore/rac-core" # 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"] @@ -65,8 +68,8 @@ testpaths = ["tests"] python_version = "3.11" packages = ["lore_connectors"] -# The Supermemory SDK and Neo4j driver are optional extras, imported lazily; CI -# type-checks without them installed, so their missing stubs are not an error. +# 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.*", "neo4j.*"] +module = ["supermemory.*", "mem0.*", "neo4j.*"] ignore_missing_imports = true 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..d6499eb --- /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 + `lore_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 (`lore_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 `lore_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/src/lore_connectors/cli.py b/src/lore_connectors/cli.py index f518f02..c9591f5 100644 --- a/src/lore_connectors/cli.py +++ b/src/lore_connectors/cli.py @@ -5,9 +5,8 @@ object:: rac export rac/ --documents | lore-connect supermemory - rac export rac/ --documents | lore-connect supermemory --dry-run + rac export rac/ --documents | lore-connect mem0 --dry-run rac export rac/ --graph | lore-connect neo4j - rac export rac/ --graph | lore-connect neo4j --dry-run """ from __future__ import annotations @@ -17,8 +16,11 @@ from collections.abc import Iterable, Iterator from typing import TextIO -from .base import PushSummary +from .base import Connector, PushSummary from .graph import MalformedGraphError, parse_graph +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 @@ -88,8 +90,36 @@ def _run_supermemory(args: argparse.Namespace) -> int: 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 _push_with_skips( - connector: SupermemoryConnector, + connector: Connector, stream: Iterable[str], *, dry_run: bool, @@ -176,6 +206,34 @@ def build_parser() -> argparse.ArgumentParser: ) 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) + neo = sub.add_parser( "neo4j", help="Upsert the --graph projection into Neo4j (idempotent via MERGE).", diff --git a/src/lore_connectors/mem0/__init__.py b/src/lore_connectors/mem0/__init__.py new file mode 100644 index 0000000..d8ed5e1 --- /dev/null +++ b/src/lore_connectors/mem0/__init__.py @@ -0,0 +1,21 @@ +"""Mem0 backend module for lore-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/lore_connectors/mem0/client.py b/src/lore_connectors/mem0/client.py new file mode 100644 index 0000000..b91fcb4 --- /dev/null +++ b/src/lore_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/lore_connectors/mem0/connector.py b/src/lore_connectors/mem0/connector.py new file mode 100644 index 0000000..22c6003 --- /dev/null +++ b/src/lore_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. + + ``lore_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( + { + "lore_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/tests/test_cli_mem0.py b/tests/test_cli_mem0.py new file mode 100644 index 0000000..98f46ae --- /dev/null +++ b/tests/test_cli_mem0.py @@ -0,0 +1,49 @@ +"""End-to-end CLI behaviour for the `lore-connect mem0` subcommand.""" + +from __future__ import annotations + +import io + +from lore_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_mem0.py b/tests/test_mem0.py new file mode 100644 index 0000000..eaeb6ff --- /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 lore_connectors.mem0 import DEFAULT_CONTAINER, Mem0Connector +from lore_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"]["lore_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..d2b458d --- /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 lore_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={"lore_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"] == {"lore_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() From d6cdcd18de54e70f390b5194bdd014c1d2c54d45 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 12:45:13 +0100 Subject: [PATCH 22/30] feat(zep): add the Zep documents connector [roadmap:documents-backends] (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(decisions): record ADR-005 (Zep documents backend, graph-resync idempotency) Zep reuses the documents push(records) seam; it has no per-record upsert key (verified against zep-cloud 3.23.0), so idempotency is a graph resync — delete and recreate the source graph, then add text episodes. Validated under rac validate rac/. * feat(zep): add the Zep documents connector and CLI subcommand Reuse the documents seam: map source->Zep graph_id, add each record as a type=text episode with metadata (lore_id/type/status/title). Idempotent by graph resync (delete+create the graph, then add). SDK behind a thin client (env auth ZEP_API_KEY), optional [zep] extra. Exposes 'lore-connect zep'. * test(zep): cover record mapping, graph-resync idempotency, dry-run, auth guard Drives a fake client — no live API in CI. Covers record->episode mapping with lore_id metadata, clear-once-then-add, idempotent re-push, dry-run, the stubbed-SDK adapter (delete+create, tolerates a missing graph), and the CLI. * docs(readme): document the Zep documents backend Add the Zep section, the [zep] extra, and frame both documents backends (Supermemory + Zep). * docs(zep): move usage into docs/connectors/zep.md (align with the pages pattern) Adopt the generated-pages structure from main: drop the inline README edits and document Zep in its own docs/connectors/zep.md, then regenerate the README Connectors region. --- README.md | 33 ++++++ docs/connectors/zep.md | 34 ++++++ pyproject.toml | 5 +- .../adr-005-zep-documents-connector.md | 96 ++++++++++++++++ src/lore_connectors/cli.py | 59 ++++++++++ src/lore_connectors/zep/__init__.py | 21 ++++ src/lore_connectors/zep/client.py | 85 +++++++++++++++ src/lore_connectors/zep/connector.py | 83 ++++++++++++++ tests/test_cli_zep.py | 48 ++++++++ tests/test_zep.py | 102 +++++++++++++++++ tests/test_zep_client.py | 103 ++++++++++++++++++ 11 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 docs/connectors/zep.md create mode 100644 rac/decisions/adr-005-zep-documents-connector.md create mode 100644 src/lore_connectors/zep/__init__.py create mode 100644 src/lore_connectors/zep/client.py create mode 100644 src/lore_connectors/zep/connector.py create mode 100644 tests/test_cli_zep.py create mode 100644 tests/test_zep.py create mode 100644 tests/test_zep_client.py diff --git a/README.md b/README.md index 2311f41..592324b 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,39 @@ Decision: [`rac/decisions/`](rac/decisions) — ADR-004 (Mem0 backend, resync id
+
+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 'lore-connectors[zep]' +export ZEP_API_KEY=z_... + +rac export rac/ --documents | lore-connect zep # upsert every record +rac export rac/ --documents | lore-connect zep --dry-run # preview, no API call +lore-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 `lore_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) + +
+
Neo4j — graph → typed nodes & edges via Cypher MERGE; idempotent on the canonical id diff --git a/docs/connectors/zep.md b/docs/connectors/zep.md new file mode 100644 index 0000000..0488881 --- /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 'lore-connectors[zep]' +export ZEP_API_KEY=z_... + +rac export rac/ --documents | lore-connect zep # upsert every record +rac export rac/ --documents | lore-connect zep --dry-run # preview, no API call +lore-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 `lore_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 index 9b9efcc..0f75126 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ 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"] dev = ["pytest>=7.0", "ruff", "mypy"] [project.scripts] @@ -71,5 +74,5 @@ packages = ["lore_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.*"] +module = ["supermemory.*", "mem0.*", "neo4j.*", "zep_cloud.*"] ignore_missing_imports = true 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..7301667 --- /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 `lore_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 (`lore_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 `lore_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/src/lore_connectors/cli.py b/src/lore_connectors/cli.py index c9591f5..e2177c7 100644 --- a/src/lore_connectors/cli.py +++ b/src/lore_connectors/cli.py @@ -27,6 +27,9 @@ 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]: @@ -118,6 +121,34 @@ def _run_mem0(args: argparse.Namespace) -> int: 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 _push_with_skips( connector: Connector, stream: Iterable[str], @@ -234,6 +265,34 @@ def build_parser() -> argparse.ArgumentParser: ) 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) + neo = sub.add_parser( "neo4j", help="Upsert the --graph projection into Neo4j (idempotent via MERGE).", diff --git a/src/lore_connectors/zep/__init__.py b/src/lore_connectors/zep/__init__.py new file mode 100644 index 0000000..dcff9d2 --- /dev/null +++ b/src/lore_connectors/zep/__init__.py @@ -0,0 +1,21 @@ +"""Zep backend module for lore-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/lore_connectors/zep/client.py b/src/lore_connectors/zep/client.py new file mode 100644 index 0000000..f3acad5 --- /dev/null +++ b/src/lore_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/lore_connectors/zep/connector.py b/src/lore_connectors/zep/connector.py new file mode 100644 index 0000000..8f54fcd --- /dev/null +++ b/src/lore_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. + + ``lore_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( + { + "lore_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/test_cli_zep.py b/tests/test_cli_zep.py new file mode 100644 index 0000000..7c83a29 --- /dev/null +++ b/tests/test_cli_zep.py @@ -0,0 +1,48 @@ +"""End-to-end CLI behaviour for the `lore-connect zep` subcommand.""" + +from __future__ import annotations + +import io + +from lore_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_zep.py b/tests/test_zep.py new file mode 100644 index 0000000..dfbfbfa --- /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 lore_connectors.records import Record +from lore_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"]["lore_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..96483ba --- /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 lore_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={"lore_id": "RAC-1"}) + call = _StubZep.last.graph.adds[0] + assert call == { + "data": "body", + "type": "text", + "graph_id": "rac", + "metadata": {"lore_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() From 3fa3cb4c46961772d16b8ab929e7a842e8667413 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 12:46:37 +0100 Subject: [PATCH 23/30] feat(letta): add the Letta documents connector [roadmap:documents-backends] (#5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(decisions): record ADR-006 (Letta documents backend, archive-resync idempotency) Letta reuses the documents push(records) seam; a corpus source maps to a Letta archive, and the connector resolves the opaque archive_id behind the client. No per-record upsert key (verified against letta-client 1.12.1), so idempotency is an archive resync. Validated under rac validate rac/. * feat(letta): add the Letta documents connector and CLI subcommand Map source->Letta archive; add each record as a passage with metadata (lore_id/type/status/title). Idempotent by archive resync (delete archives by name, create, add). Adapter hides the archive_id indirection; env auth (LETTA_API_KEY cloud or LETTA_BASE_URL self-hosted), optional [letta] extra. Exposes 'lore-connect letta'. * test(letta): cover record mapping, archive-resync idempotency, dry-run, auth guard Drives a fake client — no live API in CI. Covers record->passage mapping, clear-once-then-add, idempotent re-push, dry-run, the stubbed-SDK adapter (archive list/create/delete + passage create by resolved id, cloud and self-hosted auth), and the CLI. * docs(readme): document the Letta documents backend Add the Letta section (cloud + self-hosted auth) and the [letta] extra. * docs(letta): move usage into docs/connectors/letta.md (align with the pages pattern) Adopt the generated-pages structure from main: drop the inline README edits and document Letta in its own docs/connectors/letta.md, then regenerate the README Connectors region. --- README.md | 34 ++++++ docs/connectors/letta.md | 35 ++++++ pyproject.toml | 5 +- .../adr-006-letta-documents-connector.md | 97 +++++++++++++++ src/lore_connectors/cli.py | 59 +++++++++ src/lore_connectors/letta/__init__.py | 21 ++++ src/lore_connectors/letta/client.py | 100 +++++++++++++++ src/lore_connectors/letta/connector.py | 85 +++++++++++++ tests/test_cli_letta.py | 49 ++++++++ tests/test_letta.py | 91 ++++++++++++++ tests/test_letta_client.py | 114 ++++++++++++++++++ 11 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 docs/connectors/letta.md create mode 100644 rac/decisions/adr-006-letta-documents-connector.md create mode 100644 src/lore_connectors/letta/__init__.py create mode 100644 src/lore_connectors/letta/client.py create mode 100644 src/lore_connectors/letta/connector.py create mode 100644 tests/test_cli_letta.py create mode 100644 tests/test_letta.py create mode 100644 tests/test_letta_client.py diff --git a/README.md b/README.md index 592324b..3816425 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,40 @@ Decision: [`rac/decisions/`](rac/decisions) — ADR-005 (Zep backend, graph-resy
+
+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 'lore-connectors[letta]' +export LETTA_API_KEY=... # Letta Cloud +# or, self-hosted: export LETTA_BASE_URL=http://localhost:8283 + +rac export rac/ --documents | lore-connect letta # upsert every record +rac export rac/ --documents | lore-connect letta --dry-run # preview, no API call +lore-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 `lore_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) + +
+
Neo4j — graph → typed nodes & edges via Cypher MERGE; idempotent on the canonical id diff --git a/docs/connectors/letta.md b/docs/connectors/letta.md new file mode 100644 index 0000000..4322ec2 --- /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 'lore-connectors[letta]' +export LETTA_API_KEY=... # Letta Cloud +# or, self-hosted: export LETTA_BASE_URL=http://localhost:8283 + +rac export rac/ --documents | lore-connect letta # upsert every record +rac export rac/ --documents | lore-connect letta --dry-run # preview, no API call +lore-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 `lore_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/pyproject.toml b/pyproject.toml index 0f75126..173c918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,9 @@ 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"] dev = ["pytest>=7.0", "ruff", "mypy"] [project.scripts] @@ -74,5 +77,5 @@ packages = ["lore_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.*"] +module = ["supermemory.*", "mem0.*", "neo4j.*", "zep_cloud.*", "letta_client.*"] ignore_missing_imports = true 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..bfda849 --- /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 + `lore_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 `lore_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/src/lore_connectors/cli.py b/src/lore_connectors/cli.py index e2177c7..857956d 100644 --- a/src/lore_connectors/cli.py +++ b/src/lore_connectors/cli.py @@ -18,6 +18,9 @@ from .base import Connector, PushSummary 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 @@ -149,6 +152,34 @@ def _run_zep(args: argparse.Namespace) -> int: 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 _push_with_skips( connector: Connector, stream: Iterable[str], @@ -293,6 +324,34 @@ def build_parser() -> argparse.ArgumentParser: ) 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) + neo = sub.add_parser( "neo4j", help="Upsert the --graph projection into Neo4j (idempotent via MERGE).", diff --git a/src/lore_connectors/letta/__init__.py b/src/lore_connectors/letta/__init__.py new file mode 100644 index 0000000..773e346 --- /dev/null +++ b/src/lore_connectors/letta/__init__.py @@ -0,0 +1,21 @@ +"""Letta backend module for lore-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/lore_connectors/letta/client.py b/src/lore_connectors/letta/client.py new file mode 100644 index 0000000..930f0cc --- /dev/null +++ b/src/lore_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/lore_connectors/letta/connector.py b/src/lore_connectors/letta/connector.py new file mode 100644 index 0000000..dd8ded0 --- /dev/null +++ b/src/lore_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. + + ``lore_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( + { + "lore_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/tests/test_cli_letta.py b/tests/test_cli_letta.py new file mode 100644 index 0000000..4fbcfb4 --- /dev/null +++ b/tests/test_cli_letta.py @@ -0,0 +1,49 @@ +"""End-to-end CLI behaviour for the `lore-connect letta` subcommand.""" + +from __future__ import annotations + +import io + +from lore_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_letta.py b/tests/test_letta.py new file mode 100644 index 0000000..0ec9d21 --- /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 lore_connectors.letta import DEFAULT_CONTAINER, LettaConnector +from lore_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"]["lore_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..521bb33 --- /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 lore_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={"lore_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"] == {"lore_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() From b3395b1955987b7532217ea00899ef3aacc3aece Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 21 Jun 2026 12:47:55 +0100 Subject: [PATCH 24/30] feat(cognee): add the Cognee documents connector [roadmap:documents-backends] (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(decisions): record ADR-007 (Cognee documents backend, two-phase pipeline) Cognee is the odd backend: a module-level async add->cognify pipeline with native content-hash idempotency (incremental_loading), no per-record metadata filter, verified against cognee 1.2.0. The connector stages records then cognifies once per dataset; provenance rides in a Lore-Id header. Validated under rac validate rac/. * feat(cognee): add the Cognee documents connector and CLI subcommand Map source->Cognee dataset; stage each record (with a Lore-Id provenance header) then build the graph once per dataset via add+cognify. Idempotency is Cognee's native content-hash dedup, not a resync. Async pipeline run behind a thin client (env auth LLM_API_KEY, lazy import), optional [cognee] extra. Exposes 'lore-connect cognee'. * test(cognee): cover stage-then-commit, provenance, dedup, dry-run, auth guard Drives a fake client and a stubbed async cognee module — no pipeline in CI. Covers stage-per-record + commit-once, the Lore-Id provenance header, content-hash dedup on re-push, empty-push no commit, dry-run, the batched add+cognify adapter, and the CLI. * docs(readme): document the Cognee documents backend Add the Cognee section (two-phase pipeline, content-hash idempotency, the no-deletion-prune caveat) and the [cognee] extra. * docs(cognee): move usage into docs/connectors/cognee.md (align with the pages pattern) Adopt the generated-pages structure from main: drop the inline README edits and document Cognee in its own docs/connectors/cognee.md, then regenerate the README Connectors region. --- README.md | 32 +++++ docs/connectors/cognee.md | 33 ++++++ pyproject.toml | 12 +- .../adr-007-cognee-documents-connector.md | 109 ++++++++++++++++++ src/lore_connectors/cli.py | 59 ++++++++++ src/lore_connectors/cognee/__init__.py | 23 ++++ src/lore_connectors/cognee/client.py | 99 ++++++++++++++++ src/lore_connectors/cognee/connector.py | 68 +++++++++++ tests/test_cli_cognee.py | 48 ++++++++ tests/test_cognee.py | 102 ++++++++++++++++ tests/test_cognee_client.py | 73 ++++++++++++ 11 files changed, 657 insertions(+), 1 deletion(-) create mode 100644 docs/connectors/cognee.md create mode 100644 rac/decisions/adr-007-cognee-documents-connector.md create mode 100644 src/lore_connectors/cognee/__init__.py create mode 100644 src/lore_connectors/cognee/client.py create mode 100644 src/lore_connectors/cognee/connector.py create mode 100644 tests/test_cli_cognee.py create mode 100644 tests/test_cognee.py create mode 100644 tests/test_cognee_client.py diff --git a/README.md b/README.md index 3816425..a9bd131 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,38 @@ Decision: [`rac/decisions/`](rac/decisions) — ADR-006 (Letta backend, archive-
+
+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 'lore-connectors[cognee]' +export LLM_API_KEY=... # Cognee needs an LLM to cognify + +rac export rac/ --documents | lore-connect cognee # build the graph +rac export rac/ --documents | lore-connect cognee --dry-run # preview, no pipeline run +lore-connect cognee --input corpus.jsonl # read a file, not stdin +``` + +- **A corpus maps to a Cognee dataset.** Each record is staged with a `Lore-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 diff --git a/docs/connectors/cognee.md b/docs/connectors/cognee.md new file mode 100644 index 0000000..426d8ff --- /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 'lore-connectors[cognee]' +export LLM_API_KEY=... # Cognee needs an LLM to cognify + +rac export rac/ --documents | lore-connect cognee # build the graph +rac export rac/ --documents | lore-connect cognee --dry-run # preview, no pipeline run +lore-connect cognee --input corpus.jsonl # read a file, not stdin +``` + +- **A corpus maps to a Cognee dataset.** Each record is staged with a `Lore-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/pyproject.toml b/pyproject.toml index 173c918..04551fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ 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] @@ -77,5 +80,12 @@ packages = ["lore_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.*"] +module = [ + "supermemory.*", + "mem0.*", + "neo4j.*", + "zep_cloud.*", + "letta_client.*", + "cognee.*", +] ignore_missing_imports = true 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..db94c7a --- /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 `Lore-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/src/lore_connectors/cli.py b/src/lore_connectors/cli.py index 857956d..036f4c2 100644 --- a/src/lore_connectors/cli.py +++ b/src/lore_connectors/cli.py @@ -17,6 +17,9 @@ 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 @@ -180,6 +183,34 @@ def _run_letta(args: argparse.Namespace) -> int: 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], @@ -352,6 +383,34 @@ def build_parser() -> argparse.ArgumentParser: ) 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).", diff --git a/src/lore_connectors/cognee/__init__.py b/src/lore_connectors/cognee/__init__.py new file mode 100644 index 0000000..7e51417 --- /dev/null +++ b/src/lore_connectors/cognee/__init__.py @@ -0,0 +1,23 @@ +"""Cognee backend module for lore-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/lore_connectors/cognee/client.py b/src/lore_connectors/cognee/client.py new file mode 100644 index 0000000..0ae82c4 --- /dev/null +++ b/src/lore_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, lore_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 ``lore_id`` + (and lifecycle) is embedded as a header line — keeping the verify-in-Lore + handle recoverable from Cognee's graph (ADR-007). + """ + header = f"Lore-Id: {lore_id}\nType: {type}\nStatus: {status}\nTitle: {title}" + return f"{header}\n\n{text}" diff --git a/src/lore_connectors/cognee/connector.py b/src/lore_connectors/cognee/connector.py new file mode 100644 index 0000000..d39b065 --- /dev/null +++ b/src/lore_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, + lore_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/tests/test_cli_cognee.py b/tests/test_cli_cognee.py new file mode 100644 index 0000000..5b198c8 --- /dev/null +++ b/tests/test_cli_cognee.py @@ -0,0 +1,48 @@ +"""End-to-end CLI behaviour for the `lore-connect cognee` subcommand.""" + +from __future__ import annotations + +import io + +from lore_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_cognee.py b/tests/test_cognee.py new file mode 100644 index 0000000..89f90ce --- /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 lore_connectors.cognee import DEFAULT_CONTAINER, CogneeConnector +from lore_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 "Lore-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..8852fa8 --- /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 lore_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() From b7f5774ae1e0894bf31c241547875431b94937f4 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Mon, 22 Jun 2026 04:55:07 +0000 Subject: [PATCH 25/30] docs(decisions): record ADR-008 (CalVer scheme; contract version is the cross-repo dependency) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt CalVer (YYYY.M., independent cadence — not lock-step with rac-core). Capture the real cross-repo dependency as the export contract schema_version, checked in code, never a rac-core package pin (ADR-007, ADR-063). Validated under rac validate rac/. --- .../adr-008-calver-and-contract-version.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 rac/decisions/adr-008-calver-and-contract-version.md 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..eac31d2 --- /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.`. lore-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: + lore-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.** lore-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. From 67767382602f31ec335eaeaf22eed4f4f0398fae Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Mon, 22 Jun 2026 04:55:07 +0000 Subject: [PATCH 26/30] feat(contract): declare and check the supported export schema_version Add SUPPORTED_CONTRACT_VERSION ("1") and check_contract_version: the documents and graph readers warn (ContractVersionWarning, on stderr) on an unknown contract major and still proceed best-effort. The connector depends on the contract version, not the rac package version (ADR-008). --- src/lore_connectors/__init__.py | 8 ++++ src/lore_connectors/contract.py | 44 ++++++++++++++++++ src/lore_connectors/graph.py | 3 ++ src/lore_connectors/records.py | 3 ++ tests/test_contract.py | 80 +++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 src/lore_connectors/contract.py create mode 100644 tests/test_contract.py diff --git a/src/lore_connectors/__init__.py b/src/lore_connectors/__init__.py index 0ab38dc..bd8e822 100644 --- a/src/lore_connectors/__init__.py +++ b/src/lore_connectors/__init__.py @@ -12,6 +12,11 @@ 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, @@ -22,7 +27,9 @@ from .records import MalformedRecordError, Record, parse_documents __all__ = [ + "SUPPORTED_CONTRACT_VERSION", "Connector", + "ContractVersionWarning", "Graph", "GraphConnector", "GraphEdge", @@ -31,6 +38,7 @@ "MalformedRecordError", "PushSummary", "Record", + "check_contract_version", "parse_documents", "parse_graph", ] diff --git a/src/lore_connectors/contract.py b/src/lore_connectors/contract.py new file mode 100644 index 0000000..44719c2 --- /dev/null +++ b/src/lore_connectors/contract.py @@ -0,0 +1,44 @@ +"""The rac export contract version this connector speaks. + +The only cross-repo dependency lore-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/lore_connectors/graph.py b/src/lore_connectors/graph.py index 8b29e9f..ae7669e 100644 --- a/src/lore_connectors/graph.py +++ b/src/lore_connectors/graph.py @@ -13,6 +13,8 @@ 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.""" @@ -115,6 +117,7 @@ def parse_graph(payload: str) -> Graph: 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, diff --git a/src/lore_connectors/records.py b/src/lore_connectors/records.py index 0d3f807..0946771 100644 --- a/src/lore_connectors/records.py +++ b/src/lore_connectors/records.py @@ -13,6 +13,8 @@ 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. @@ -124,4 +126,5 @@ def parse_documents(lines: Iterable[str], *, strict: bool = False) -> Iterator[R if strict: raise MalformedRecordError(index, str(exc), raw) from exc continue + check_contract_version(record.schema_version) yield record diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..ad35b2a --- /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 lore_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" From f51e2f9f4290dffafbc3192a0bf8981e703573e5 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Mon, 22 Jun 2026 04:55:07 +0000 Subject: [PATCH 27/30] docs(release): adopt CalVer tags and document the supported contract version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the setuptools-scm note for CalVer (tag 2026.6.1, not 2026.06.1 — PEP 440 strips the zero), and note the schema_version-1 target in the README export contract section. --- README.md | 5 +++++ pyproject.toml | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a9bd131..65034fe 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,11 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index 04551fe..773dcc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,10 @@ lore-connect = "lore_connectors.cli:main" where = ["src"] [tool.setuptools_scm] -# Version comes from git tags (vX.Y.Z). Untagged builds get a dev version. +# 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 From cb0cc7f96f068e59a9293c731a05053f5198b41b Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Mon, 22 Jun 2026 06:17:04 +0000 Subject: [PATCH 28/30] docs(release): align tag-format comments with CalVer (ADR-008) The version comments in pyproject.toml and python-publish.yml still carried the SemVer-era 'tag vX.Y.Z' guidance, which wrongly implies a leading 'v' and contradicts the CalVer decision. The PyPI publish triggers on a published GitHub Release (no tag-pattern filter), and setuptools-scm strips a leading 'v', so no 'v' is required; the tag is YYYY.M. (e.g. 2026.6.1). Comment-only change; no behaviour, build, or contract change. Implements rac/decisions/adr-008-calver-and-contract-version.md. --- .github/workflows/python-publish.yml | 8 +++++--- pyproject.toml | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 1cff9d6..3c9e1fb 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,8 +1,10 @@ name: Upload Python Package -# Publish lore-connectors to PyPI when a GitHub Release is published. -# Versioning is tag-driven (setuptools-scm): tag vX.Y.Z, cut the release, and -# the build is version X.Y.Z. Mirrors rac-core's release flow. +# Publish lore-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: diff --git a/pyproject.toml b/pyproject.toml index 773dcc0..05e55ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,8 @@ build-backend = "setuptools.build_meta" [project] name = "lore-connectors" # Version is derived from the git tag by setuptools-scm (mirrors rac-core). -# Release flow: tag vX.Y.Z -> the build is version X.Y.Z. No manual edits. +# 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" From bd51ce13a10ac39d7a7c98e604d29c3550bbed0e Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Mon, 29 Jun 2026 05:58:40 +0000 Subject: [PATCH 29/30] refactor(connectors): rename lore-connectors to rac-connectors [roadmap:rac-connectors] Implements rac/roadmaps/repo-topology/rac-connectors.md (ADR-092). Seeds the rac-connectors repository from the former lore-connectors with history preserved, and applies the rac-* naming: - distribution lore-connectors -> rac-connectors (registered on PyPI) - import package src/lore_connectors -> src/rac_connectors (one dist, one subdir per integration: cognee, letta, mem0, neo4j, supermemory, zep) - CLI entry point lore-connect -> rac-connect The export-contract field names (lore_id) and the "Lore" product brand are unchanged. Connectors remain thin export-contract consumers over the public rac CLI; no engine internals (ADR-073, ADR-063, ADR-002). --- .github/workflows/python-publish.yml | 4 +- NOTICE | 2 +- README.md | 116 +++++++++--------- docs/connectors/cognee.md | 10 +- docs/connectors/letta.md | 10 +- docs/connectors/mem0.md | 10 +- docs/connectors/neo4j.md | 14 +-- docs/connectors/supermemory.md | 10 +- docs/connectors/zep.md | 10 +- pyproject.toml | 12 +- rac/decisions/adr-001-python-stack.md | 10 +- rac/decisions/adr-002-connector-interface.md | 4 +- .../adr-003-graph-connector-seam-neo4j.md | 2 +- .../adr-008-calver-and-contract-version.md | 6 +- rac/designs/graph-connector-shape.md | 2 +- rac/roadmaps/future/graph-connector.md | 2 +- scripts/sync_readme.py | 8 +- .../__init__.py | 2 +- .../base.py | 2 +- .../cli.py | 10 +- .../cognee/__init__.py | 2 +- .../cognee/client.py | 0 .../cognee/connector.py | 0 .../contract.py | 2 +- .../graph.py | 0 .../letta/__init__.py | 2 +- .../letta/client.py | 0 .../letta/connector.py | 0 .../mem0/__init__.py | 2 +- .../mem0/client.py | 0 .../mem0/connector.py | 0 .../neo4j/__init__.py | 2 +- .../neo4j/client.py | 0 .../neo4j/connector.py | 0 .../records.py | 0 .../supermemory/__init__.py | 2 +- .../supermemory/client.py | 0 .../supermemory/connector.py | 2 +- .../zep/__init__.py | 2 +- .../zep/client.py | 0 .../zep/connector.py | 0 tests/fakes.py | 2 +- tests/test_cli.py | 2 +- tests/test_cli_cognee.py | 4 +- tests/test_cli_letta.py | 4 +- tests/test_cli_mem0.py | 4 +- tests/test_cli_neo4j.py | 4 +- tests/test_cli_zep.py | 4 +- tests/test_cognee.py | 4 +- tests/test_cognee_client.py | 2 +- tests/test_contract.py | 2 +- tests/test_graph.py | 2 +- tests/test_letta.py | 4 +- tests/test_letta_client.py | 2 +- tests/test_mem0.py | 4 +- tests/test_mem0_client.py | 2 +- tests/test_neo4j.py | 4 +- tests/test_neo4j_client.py | 2 +- tests/test_records.py | 2 +- tests/test_sdk_client.py | 2 +- tests/test_supermemory.py | 6 +- tests/test_zep.py | 4 +- tests/test_zep_client.py | 2 +- 63 files changed, 165 insertions(+), 165 deletions(-) rename src/{lore_connectors => rac_connectors}/__init__.py (93%) rename src/{lore_connectors => rac_connectors}/base.py (97%) rename src/{lore_connectors => rac_connectors}/cli.py (98%) rename src/{lore_connectors => rac_connectors}/cognee/__init__.py (90%) rename src/{lore_connectors => rac_connectors}/cognee/client.py (100%) rename src/{lore_connectors => rac_connectors}/cognee/connector.py (100%) rename src/{lore_connectors => rac_connectors}/contract.py (95%) rename src/{lore_connectors => rac_connectors}/graph.py (100%) rename src/{lore_connectors => rac_connectors}/letta/__init__.py (88%) rename src/{lore_connectors => rac_connectors}/letta/client.py (100%) rename src/{lore_connectors => rac_connectors}/letta/connector.py (100%) rename src/{lore_connectors => rac_connectors}/mem0/__init__.py (88%) rename src/{lore_connectors => rac_connectors}/mem0/client.py (100%) rename src/{lore_connectors => rac_connectors}/mem0/connector.py (100%) rename src/{lore_connectors => rac_connectors}/neo4j/__init__.py (86%) rename src/{lore_connectors => rac_connectors}/neo4j/client.py (100%) rename src/{lore_connectors => rac_connectors}/neo4j/connector.py (100%) rename src/{lore_connectors => rac_connectors}/records.py (100%) rename src/{lore_connectors => rac_connectors}/supermemory/__init__.py (88%) rename src/{lore_connectors => rac_connectors}/supermemory/client.py (100%) rename src/{lore_connectors => rac_connectors}/supermemory/connector.py (97%) rename src/{lore_connectors => rac_connectors}/zep/__init__.py (88%) rename src/{lore_connectors => rac_connectors}/zep/client.py (100%) rename src/{lore_connectors => rac_connectors}/zep/connector.py (100%) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 3c9e1fb..b7817b1 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,6 +1,6 @@ name: Upload Python Package -# Publish lore-connectors to PyPI when a GitHub Release is published. The +# 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 @@ -54,7 +54,7 @@ jobs: environment: name: pypi - url: https://pypi.org/p/lore-connectors + url: https://pypi.org/p/rac-connectors steps: - name: Retrieve release distributions diff --git a/NOTICE b/NOTICE index 5354881..a663138 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -lore-connectors +rac-connectors Copyright 2026 Tom Ballard This product is licensed under the Apache License, Version 2.0 (see LICENSE). diff --git a/README.md b/README.md index 65034fe..fc51b93 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# lore-connectors +# rac-connectors - - - Lore — agents that know why. Deterministic. Read-only. No RAG, no guessing. + + + Lore — agents that know why. Deterministic. Read-only. No RAG, no guessing.

@@ -15,7 +15,7 @@

-CI +CI Python Typed License: Apache 2.0 @@ -23,7 +23,7 @@ > **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.** -lore-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**. +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 @@ -45,7 +45,7 @@ exact, current decision. Recall fuzzily, verify in Lore. it's on PyPI): ```bash - pip install 'lore-connectors[supermemory]' + pip install 'rac-connectors[supermemory]' ``` 2. **Authenticate** the backend via the environment (never hard-coded): @@ -57,13 +57,13 @@ exact, current decision. Recall fuzzily, verify in Lore. 3. **Push** the corpus — pipe a `rac export --documents` stream straight in: ```bash - rac export rac/ --documents | lore-connect supermemory + rac export rac/ --documents | rac-connect supermemory ``` 4. **Preview** first if you like — `--dry-run` calls no API: ```bash - rac export rac/ --documents | lore-connect supermemory --dry-run + rac export rac/ --documents | rac-connect supermemory --dry-run ``` Re-running is idempotent: a re-push updates rather than duplicates. Each @@ -71,30 +71,30 @@ backend's exact commands, auth, and flags live under [Connectors](#connectors). ## Install -There is nothing to build — it's pure Python. Installing puts a `lore-connect` +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 'lore-connectors[supermemory]' +pip install 'rac-connectors[supermemory]' ``` **From source today** (pre-release — install straight from the repo): ```bash # one-liner, no clone: -pip install 'lore-connectors[supermemory] @ git+https://github.com/itsthelore/lore-connectors.git' +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/lore-connectors.git -cd lore-connectors +git clone https://github.com/itsthelore/rac-connectors.git +cd rac-connectors pip install -e '.[supermemory]' ``` | Extra | Gets you | |---|---| -| *(none)* | the `lore-connect` CLI + the connector library + `--dry-run` | +| *(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 | @@ -109,7 +109,7 @@ optional extras, so CI never needs a live backend. rac export rac/ --documents # Lore emits one JSON line per artifact │ ▼ -lore-connect supermemory # this repo: upsert each record into the backend +rac-connect supermemory # this repo: upsert each record into the backend │ ▼ Supermemory (fuzzy, associative recall) @@ -130,7 +130,7 @@ get_artifact / rac resolve # …verifies the authoritative text in Lore ## Connectors -One package, one CLI: pick a backend with a subcommand (`lore-connect +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. @@ -145,12 +145,12 @@ A one-way, outbound push of the `rac export --documents` stream into [Supermemory](https://supermemory.ai). ```bash -pip install 'lore-connectors[supermemory]' +pip install 'rac-connectors[supermemory]' export SUPERMEMORY_API_KEY=sk-... -rac export rac/ --documents | lore-connect supermemory # upsert every record -rac export rac/ --documents | lore-connect supermemory --dry-run # preview, no API call -lore-connect supermemory --input corpus.jsonl # read a file, not stdin +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: @@ -190,12 +190,12 @@ A one-way, outbound push of the `rac export --documents` stream into a different subcommand: ```bash -pip install 'lore-connectors[mem0]' +pip install 'rac-connectors[mem0]' export MEM0_API_KEY=m0-... -rac export rac/ --documents | lore-connect mem0 # upsert every record -rac export rac/ --documents | lore-connect mem0 --dry-run # preview, no API call -lore-connect mem0 --input corpus.jsonl # read a file, not stdin +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 @@ -222,12 +222,12 @@ A one-way, outbound push of the `rac export --documents` stream into backends, a different subcommand: ```bash -pip install 'lore-connectors[zep]' +pip install 'rac-connectors[zep]' export ZEP_API_KEY=z_... -rac export rac/ --documents | lore-connect zep # upsert every record -rac export rac/ --documents | lore-connect zep --dry-run # preview, no API call -lore-connect zep --input corpus.jsonl # read a file, not stdin +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 @@ -255,13 +255,13 @@ A one-way, outbound push of the `rac export --documents` stream into documents backends, a different subcommand: ```bash -pip install 'lore-connectors[letta]' +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 | lore-connect letta # upsert every record -rac export rac/ --documents | lore-connect letta --dry-run # preview, no API call -lore-connect letta --input corpus.jsonl # read a file, not stdin +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 @@ -289,12 +289,12 @@ 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 'lore-connectors[cognee]' +pip install 'rac-connectors[cognee]' export LLM_API_KEY=... # Cognee needs an LLM to cognify -rac export rac/ --documents | lore-connect cognee # build the graph -rac export rac/ --documents | lore-connect cognee --dry-run # preview, no pipeline run -lore-connect cognee --input corpus.jsonl # read a file, not stdin +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 `Lore-Id:` @@ -322,12 +322,12 @@ relationship graph — typed nodes and edges (`supersedes`, `related_decisions`, the actual decision graph instead of one an LLM inferred from prose: ```bash -pip install 'lore-connectors[neo4j]' +pip install 'rac-connectors[neo4j]' export NEO4J_URI=bolt://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=... -rac export rac/ --graph | lore-connect neo4j # upsert nodes + edges -rac export rac/ --graph | lore-connect neo4j --dry-run # preview, no connection -lore-connect neo4j --input graph.json # read a file, not stdin +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 @@ -361,8 +361,8 @@ The contract is additive and stable (rac-core ADR-007). ### Python API ```python -from lore_connectors import parse_graph -from lore_connectors.neo4j import Neo4jConnector, client_from_env +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) @@ -382,7 +382,7 @@ Design + decision: [`rac/designs/`](rac/designs) (graph-connector-shape) and ## Run it in CI -`lore-connect` is a one-shot command — it pushes and exits — so keeping a +`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`: @@ -399,8 +399,8 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.11" - - run: pip install requirements-as-code 'lore-connectors[supermemory]' - - run: rac export rac/ --documents | lore-connect supermemory + - 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 }} ``` @@ -435,8 +435,8 @@ The connector is a library too. Parse a `--documents` stream into records and push them through any backend module: ```python -from lore_connectors import parse_documents -from lore_connectors.supermemory import SupermemoryConnector, client_from_env +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) @@ -447,13 +447,13 @@ Pass `dry_run=True` to preview without a client or an API call. ## One package, many backends -There is **one** `lore-connectors` package on PyPI, not one per provider. As +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:** `lore-connect supermemory`, - later `lore-connect mem0`, `lore-connect neo4j`, …; and +- **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 'lore-connectors[supermemory,mem0]'`. The base install and the + `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 @@ -465,7 +465,7 @@ product with independent cadence — the documented escape hatch, not the defaul ## Add a backend -A new backend is a module under `src/lore_connectors/` implementing one outbound +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 @@ -476,7 +476,7 @@ class Connector(Protocol): 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) +`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): @@ -502,7 +502,7 @@ documented with Lore. ## Origin -lore-connectors is the **connector companion** to Lore / RAC. rac-core ADR-073 +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 @@ -511,9 +511,9 @@ offline). This repo dogfoods Lore for its own decisions under `rac/`. ## Repository layout ```text -lore-connectors/ - src/lore_connectors/ the connector library: the documents reader, the shared - push seam, the lore-connect CLI, and one module per +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), diff --git a/docs/connectors/cognee.md b/docs/connectors/cognee.md index 426d8ff..9bd9e6d 100644 --- a/docs/connectors/cognee.md +++ b/docs/connectors/cognee.md @@ -1,4 +1,4 @@ - metadata block") - _, _, rest = text.partition(" metadata block") + _, _, rest = text.partition("") meta = _parse_meta(meta_block) body = body.lstrip("\n") diff --git a/src/lore_connectors/__init__.py b/src/rac_connectors/__init__.py similarity index 93% rename from src/lore_connectors/__init__.py rename to src/rac_connectors/__init__.py index bd8e822..ce65cd6 100644 --- a/src/lore_connectors/__init__.py +++ b/src/rac_connectors/__init__.py @@ -1,4 +1,4 @@ -"""lore-connectors — outbound connectors that push Lore's export into backends. +"""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 diff --git a/src/lore_connectors/base.py b/src/rac_connectors/base.py similarity index 97% rename from src/lore_connectors/base.py rename to src/rac_connectors/base.py index f7e0b50..ccb90c1 100644 --- a/src/lore_connectors/base.py +++ b/src/rac_connectors/base.py @@ -75,7 +75,7 @@ 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:`~lore_connectors.graph.Graph`, not a stream of records — + — 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 diff --git a/src/lore_connectors/cli.py b/src/rac_connectors/cli.py similarity index 98% rename from src/lore_connectors/cli.py rename to src/rac_connectors/cli.py index 036f4c2..d923393 100644 --- a/src/lore_connectors/cli.py +++ b/src/rac_connectors/cli.py @@ -1,12 +1,12 @@ -"""``lore-connect`` — the CLI entrypoint for the lore-connectors companion. +"""``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 | lore-connect supermemory - rac export rac/ --documents | lore-connect mem0 --dry-run - rac export rac/ --graph | lore-connect neo4j + 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 @@ -262,7 +262,7 @@ def _run_neo4j(args: argparse.Namespace) -> int: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( - prog="lore-connect", + 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 " diff --git a/src/lore_connectors/cognee/__init__.py b/src/rac_connectors/cognee/__init__.py similarity index 90% rename from src/lore_connectors/cognee/__init__.py rename to src/rac_connectors/cognee/__init__.py index 7e51417..274762d 100644 --- a/src/lore_connectors/cognee/__init__.py +++ b/src/rac_connectors/cognee/__init__.py @@ -1,4 +1,4 @@ -"""Cognee backend module for lore-connectors.""" +"""Cognee backend module for rac-connectors.""" from __future__ import annotations diff --git a/src/lore_connectors/cognee/client.py b/src/rac_connectors/cognee/client.py similarity index 100% rename from src/lore_connectors/cognee/client.py rename to src/rac_connectors/cognee/client.py diff --git a/src/lore_connectors/cognee/connector.py b/src/rac_connectors/cognee/connector.py similarity index 100% rename from src/lore_connectors/cognee/connector.py rename to src/rac_connectors/cognee/connector.py diff --git a/src/lore_connectors/contract.py b/src/rac_connectors/contract.py similarity index 95% rename from src/lore_connectors/contract.py rename to src/rac_connectors/contract.py index 44719c2..f792ae3 100644 --- a/src/lore_connectors/contract.py +++ b/src/rac_connectors/contract.py @@ -1,6 +1,6 @@ """The rac export contract version this connector speaks. -The only cross-repo dependency lore-connectors has on Lore / RAC is the export +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), diff --git a/src/lore_connectors/graph.py b/src/rac_connectors/graph.py similarity index 100% rename from src/lore_connectors/graph.py rename to src/rac_connectors/graph.py diff --git a/src/lore_connectors/letta/__init__.py b/src/rac_connectors/letta/__init__.py similarity index 88% rename from src/lore_connectors/letta/__init__.py rename to src/rac_connectors/letta/__init__.py index 773e346..6b7a436 100644 --- a/src/lore_connectors/letta/__init__.py +++ b/src/rac_connectors/letta/__init__.py @@ -1,4 +1,4 @@ -"""Letta backend module for lore-connectors.""" +"""Letta backend module for rac-connectors.""" from __future__ import annotations diff --git a/src/lore_connectors/letta/client.py b/src/rac_connectors/letta/client.py similarity index 100% rename from src/lore_connectors/letta/client.py rename to src/rac_connectors/letta/client.py diff --git a/src/lore_connectors/letta/connector.py b/src/rac_connectors/letta/connector.py similarity index 100% rename from src/lore_connectors/letta/connector.py rename to src/rac_connectors/letta/connector.py diff --git a/src/lore_connectors/mem0/__init__.py b/src/rac_connectors/mem0/__init__.py similarity index 88% rename from src/lore_connectors/mem0/__init__.py rename to src/rac_connectors/mem0/__init__.py index d8ed5e1..0298dd6 100644 --- a/src/lore_connectors/mem0/__init__.py +++ b/src/rac_connectors/mem0/__init__.py @@ -1,4 +1,4 @@ -"""Mem0 backend module for lore-connectors.""" +"""Mem0 backend module for rac-connectors.""" from __future__ import annotations diff --git a/src/lore_connectors/mem0/client.py b/src/rac_connectors/mem0/client.py similarity index 100% rename from src/lore_connectors/mem0/client.py rename to src/rac_connectors/mem0/client.py diff --git a/src/lore_connectors/mem0/connector.py b/src/rac_connectors/mem0/connector.py similarity index 100% rename from src/lore_connectors/mem0/connector.py rename to src/rac_connectors/mem0/connector.py diff --git a/src/lore_connectors/neo4j/__init__.py b/src/rac_connectors/neo4j/__init__.py similarity index 86% rename from src/lore_connectors/neo4j/__init__.py rename to src/rac_connectors/neo4j/__init__.py index 8cc4d1f..632a636 100644 --- a/src/lore_connectors/neo4j/__init__.py +++ b/src/rac_connectors/neo4j/__init__.py @@ -1,4 +1,4 @@ -"""Neo4j graph backend module for lore-connectors.""" +"""Neo4j graph backend module for rac-connectors.""" from __future__ import annotations diff --git a/src/lore_connectors/neo4j/client.py b/src/rac_connectors/neo4j/client.py similarity index 100% rename from src/lore_connectors/neo4j/client.py rename to src/rac_connectors/neo4j/client.py diff --git a/src/lore_connectors/neo4j/connector.py b/src/rac_connectors/neo4j/connector.py similarity index 100% rename from src/lore_connectors/neo4j/connector.py rename to src/rac_connectors/neo4j/connector.py diff --git a/src/lore_connectors/records.py b/src/rac_connectors/records.py similarity index 100% rename from src/lore_connectors/records.py rename to src/rac_connectors/records.py diff --git a/src/lore_connectors/supermemory/__init__.py b/src/rac_connectors/supermemory/__init__.py similarity index 88% rename from src/lore_connectors/supermemory/__init__.py rename to src/rac_connectors/supermemory/__init__.py index 7c322b2..ac89f49 100644 --- a/src/lore_connectors/supermemory/__init__.py +++ b/src/rac_connectors/supermemory/__init__.py @@ -1,4 +1,4 @@ -"""Supermemory backend module for lore-connectors.""" +"""Supermemory backend module for rac-connectors.""" from __future__ import annotations diff --git a/src/lore_connectors/supermemory/client.py b/src/rac_connectors/supermemory/client.py similarity index 100% rename from src/lore_connectors/supermemory/client.py rename to src/rac_connectors/supermemory/client.py diff --git a/src/lore_connectors/supermemory/connector.py b/src/rac_connectors/supermemory/connector.py similarity index 97% rename from src/lore_connectors/supermemory/connector.py rename to src/rac_connectors/supermemory/connector.py index 00714e1..7d5685d 100644 --- a/src/lore_connectors/supermemory/connector.py +++ b/src/rac_connectors/supermemory/connector.py @@ -1,4 +1,4 @@ -"""The Supermemory connector — module one of lore-connectors (ADR-073). +"""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 diff --git a/src/lore_connectors/zep/__init__.py b/src/rac_connectors/zep/__init__.py similarity index 88% rename from src/lore_connectors/zep/__init__.py rename to src/rac_connectors/zep/__init__.py index dcff9d2..3b8ccea 100644 --- a/src/lore_connectors/zep/__init__.py +++ b/src/rac_connectors/zep/__init__.py @@ -1,4 +1,4 @@ -"""Zep backend module for lore-connectors.""" +"""Zep backend module for rac-connectors.""" from __future__ import annotations diff --git a/src/lore_connectors/zep/client.py b/src/rac_connectors/zep/client.py similarity index 100% rename from src/lore_connectors/zep/client.py rename to src/rac_connectors/zep/client.py diff --git a/src/lore_connectors/zep/connector.py b/src/rac_connectors/zep/connector.py similarity index 100% rename from src/lore_connectors/zep/connector.py rename to src/rac_connectors/zep/connector.py diff --git a/tests/fakes.py b/tests/fakes.py index c772fad..8fec271 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -4,7 +4,7 @@ from typing import Any -from lore_connectors.supermemory.client import AddResult +from rac_connectors.supermemory.client import AddResult class FakeSupermemoryClient: diff --git a/tests/test_cli.py b/tests/test_cli.py index 558e56c..94e07fc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,7 +6,7 @@ import pytest -from lore_connectors import cli +from rac_connectors import cli def _line(record_id: str = "RAC-1") -> str: diff --git a/tests/test_cli_cognee.py b/tests/test_cli_cognee.py index 5b198c8..1c3cd66 100644 --- a/tests/test_cli_cognee.py +++ b/tests/test_cli_cognee.py @@ -1,10 +1,10 @@ -"""End-to-end CLI behaviour for the `lore-connect cognee` subcommand.""" +"""End-to-end CLI behaviour for the `rac-connect cognee` subcommand.""" from __future__ import annotations import io -from lore_connectors import cli +from rac_connectors import cli def _line(record_id: str = "RAC-1") -> str: diff --git a/tests/test_cli_letta.py b/tests/test_cli_letta.py index 4fbcfb4..ccb0a89 100644 --- a/tests/test_cli_letta.py +++ b/tests/test_cli_letta.py @@ -1,10 +1,10 @@ -"""End-to-end CLI behaviour for the `lore-connect letta` subcommand.""" +"""End-to-end CLI behaviour for the `rac-connect letta` subcommand.""" from __future__ import annotations import io -from lore_connectors import cli +from rac_connectors import cli def _line(record_id: str = "RAC-1") -> str: diff --git a/tests/test_cli_mem0.py b/tests/test_cli_mem0.py index 98f46ae..804e1dd 100644 --- a/tests/test_cli_mem0.py +++ b/tests/test_cli_mem0.py @@ -1,10 +1,10 @@ -"""End-to-end CLI behaviour for the `lore-connect mem0` subcommand.""" +"""End-to-end CLI behaviour for the `rac-connect mem0` subcommand.""" from __future__ import annotations import io -from lore_connectors import cli +from rac_connectors import cli def _line(record_id: str = "RAC-1") -> str: diff --git a/tests/test_cli_neo4j.py b/tests/test_cli_neo4j.py index 3e8fcf4..bd56866 100644 --- a/tests/test_cli_neo4j.py +++ b/tests/test_cli_neo4j.py @@ -1,4 +1,4 @@ -"""End-to-end CLI behaviour for the `lore-connect neo4j` subcommand.""" +"""End-to-end CLI behaviour for the `rac-connect neo4j` subcommand.""" from __future__ import annotations @@ -7,7 +7,7 @@ import pytest -from lore_connectors import cli +from rac_connectors import cli _GRAPH = { "schema_version": "1", diff --git a/tests/test_cli_zep.py b/tests/test_cli_zep.py index 7c83a29..8ffce4a 100644 --- a/tests/test_cli_zep.py +++ b/tests/test_cli_zep.py @@ -1,10 +1,10 @@ -"""End-to-end CLI behaviour for the `lore-connect zep` subcommand.""" +"""End-to-end CLI behaviour for the `rac-connect zep` subcommand.""" from __future__ import annotations import io -from lore_connectors import cli +from rac_connectors import cli def _line(record_id: str = "RAC-1") -> str: diff --git a/tests/test_cognee.py b/tests/test_cognee.py index 89f90ce..9a29033 100644 --- a/tests/test_cognee.py +++ b/tests/test_cognee.py @@ -6,8 +6,8 @@ import pytest -from lore_connectors.cognee import DEFAULT_CONTAINER, CogneeConnector -from lore_connectors.records import Record +from rac_connectors.cognee import DEFAULT_CONTAINER, CogneeConnector +from rac_connectors.records import Record class FakeCogneeClient: diff --git a/tests/test_cognee_client.py b/tests/test_cognee_client.py index 8852fa8..d9338a5 100644 --- a/tests/test_cognee_client.py +++ b/tests/test_cognee_client.py @@ -14,7 +14,7 @@ import pytest -from lore_connectors.cognee.client import MissingCredentialsError, SdkCogneeClient +from rac_connectors.cognee.client import MissingCredentialsError, SdkCogneeClient class _StubCognee(types.ModuleType): diff --git a/tests/test_contract.py b/tests/test_contract.py index ad35b2a..03fd9b1 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -6,7 +6,7 @@ import pytest -from lore_connectors import ( +from rac_connectors import ( SUPPORTED_CONTRACT_VERSION, ContractVersionWarning, check_contract_version, diff --git a/tests/test_graph.py b/tests/test_graph.py index c63aac5..de32e13 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -6,7 +6,7 @@ import pytest -from lore_connectors.graph import ( +from rac_connectors.graph import ( Graph, GraphEdge, GraphNode, diff --git a/tests/test_letta.py b/tests/test_letta.py index 0ec9d21..7c1b25a 100644 --- a/tests/test_letta.py +++ b/tests/test_letta.py @@ -6,8 +6,8 @@ import pytest -from lore_connectors.letta import DEFAULT_CONTAINER, LettaConnector -from lore_connectors.records import Record +from rac_connectors.letta import DEFAULT_CONTAINER, LettaConnector +from rac_connectors.records import Record class FakeLettaClient: diff --git a/tests/test_letta_client.py b/tests/test_letta_client.py index 521bb33..133a0c6 100644 --- a/tests/test_letta_client.py +++ b/tests/test_letta_client.py @@ -15,7 +15,7 @@ import pytest -from lore_connectors.letta.client import MissingCredentialsError, SdkLettaClient +from rac_connectors.letta.client import MissingCredentialsError, SdkLettaClient class _Archive: diff --git a/tests/test_mem0.py b/tests/test_mem0.py index eaeb6ff..e4e4486 100644 --- a/tests/test_mem0.py +++ b/tests/test_mem0.py @@ -6,8 +6,8 @@ import pytest -from lore_connectors.mem0 import DEFAULT_CONTAINER, Mem0Connector -from lore_connectors.records import Record +from rac_connectors.mem0 import DEFAULT_CONTAINER, Mem0Connector +from rac_connectors.records import Record class FakeMem0Client: diff --git a/tests/test_mem0_client.py b/tests/test_mem0_client.py index d2b458d..b68616c 100644 --- a/tests/test_mem0_client.py +++ b/tests/test_mem0_client.py @@ -14,7 +14,7 @@ import pytest -from lore_connectors.mem0.client import MissingApiKeyError, SdkMem0Client +from rac_connectors.mem0.client import MissingApiKeyError, SdkMem0Client class _StubMemoryClient: diff --git a/tests/test_neo4j.py b/tests/test_neo4j.py index dc92978..98f00c9 100644 --- a/tests/test_neo4j.py +++ b/tests/test_neo4j.py @@ -6,8 +6,8 @@ import pytest -from lore_connectors.graph import Graph, GraphEdge, GraphNode -from lore_connectors.neo4j import Neo4jConnector +from rac_connectors.graph import Graph, GraphEdge, GraphNode +from rac_connectors.neo4j import Neo4jConnector class FakeNeo4jClient: diff --git a/tests/test_neo4j_client.py b/tests/test_neo4j_client.py index 5702118..dec6072 100644 --- a/tests/test_neo4j_client.py +++ b/tests/test_neo4j_client.py @@ -14,7 +14,7 @@ import pytest -from lore_connectors.neo4j.client import DriverNeo4jClient, MissingCredentialsError +from rac_connectors.neo4j.client import DriverNeo4jClient, MissingCredentialsError class _StubSession: diff --git a/tests/test_records.py b/tests/test_records.py index 95e00ac..9eb12ae 100644 --- a/tests/test_records.py +++ b/tests/test_records.py @@ -6,7 +6,7 @@ import pytest -from lore_connectors.records import ( +from rac_connectors.records import ( MalformedRecordError, Record, parse_documents, diff --git a/tests/test_sdk_client.py b/tests/test_sdk_client.py index 08a4096..2ed46f2 100644 --- a/tests/test_sdk_client.py +++ b/tests/test_sdk_client.py @@ -16,7 +16,7 @@ import pytest -from lore_connectors.supermemory.client import ( +from rac_connectors.supermemory.client import ( MissingApiKeyError, SdkSupermemoryClient, ) diff --git a/tests/test_supermemory.py b/tests/test_supermemory.py index 7126c66..6d1b51c 100644 --- a/tests/test_supermemory.py +++ b/tests/test_supermemory.py @@ -4,9 +4,9 @@ import pytest -from lore_connectors.records import Record -from lore_connectors.supermemory import SupermemoryConnector -from lore_connectors.supermemory.client import MissingApiKeyError, SdkSupermemoryClient +from rac_connectors.records import Record +from rac_connectors.supermemory import SupermemoryConnector +from rac_connectors.supermemory.client import MissingApiKeyError, SdkSupermemoryClient from .fakes import FakeSupermemoryClient diff --git a/tests/test_zep.py b/tests/test_zep.py index dfbfbfa..f88d62c 100644 --- a/tests/test_zep.py +++ b/tests/test_zep.py @@ -6,8 +6,8 @@ import pytest -from lore_connectors.records import Record -from lore_connectors.zep import DEFAULT_CONTAINER, ZepConnector +from rac_connectors.records import Record +from rac_connectors.zep import DEFAULT_CONTAINER, ZepConnector class FakeZepClient: diff --git a/tests/test_zep_client.py b/tests/test_zep_client.py index 96483ba..ecdb42a 100644 --- a/tests/test_zep_client.py +++ b/tests/test_zep_client.py @@ -14,7 +14,7 @@ import pytest -from lore_connectors.zep.client import MissingApiKeyError, SdkZepClient +from rac_connectors.zep.client import MissingApiKeyError, SdkZepClient class _StubGraph: From 0d3e54ccff0245ef8e608f1df41f5857b0f2c7fa Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Mon, 29 Jun 2026 06:06:35 +0000 Subject: [PATCH 30/30] feat(connectors): rename outbound id field lore_id to rac_id [roadmap:rac-connectors] Implements rac/roadmaps/repo-topology/rac-connectors.md (ADR-092). Renames the outbound contract field the connectors write into backends: - metadata key lore_id -> rac_id (mem0, letta, zep, supermemory) - cognee header Lore-Id: -> Rac-Id: The value is unchanged (the canonical RAC-* artifact id), so name and value now agree (rac_id: RAC-...). This is a deliberate outbound data-contract change, recorded in the rac-core ADR for the connectors repackage. The "Lore" product brand and the verify-in-Lore loop wording are unchanged. --- README.md | 10 +++++----- docs/connectors/cognee.md | 2 +- docs/connectors/letta.md | 2 +- docs/connectors/mem0.md | 2 +- docs/connectors/supermemory.md | 2 +- docs/connectors/zep.md | 2 +- rac/decisions/adr-004-mem0-documents-connector.md | 6 +++--- rac/decisions/adr-005-zep-documents-connector.md | 6 +++--- rac/decisions/adr-006-letta-documents-connector.md | 4 ++-- rac/decisions/adr-007-cognee-documents-connector.md | 2 +- src/rac_connectors/cognee/client.py | 6 +++--- src/rac_connectors/cognee/connector.py | 2 +- src/rac_connectors/letta/connector.py | 4 ++-- src/rac_connectors/mem0/connector.py | 4 ++-- src/rac_connectors/zep/connector.py | 4 ++-- tests/test_cognee.py | 2 +- tests/test_letta.py | 2 +- tests/test_letta_client.py | 4 ++-- tests/test_mem0.py | 2 +- tests/test_mem0_client.py | 4 ++-- tests/test_zep.py | 2 +- tests/test_zep_client.py | 4 ++-- 22 files changed, 39 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index fc51b93..05e7fb1 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Each record maps to a Supermemory upsert: ``` record → add(content=text, container_tag=metadata.source, - metadata={lore id, type, status, title, path, …}, + metadata={rac id, type, status, title, path, …}, custom_id=id) ``` @@ -199,7 +199,7 @@ rac-connect mem0 --input corpus.jsonl # read a file, not std ``` - **Stores the text as-is.** `infer=False` skips Mem0's LLM fact-extraction, so it - only embeds the artifact text; the canonical `lore_id`, `type`, `status`, and + 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 — @@ -231,7 +231,7 @@ rac-connect zep --input corpus.jsonl # read a file, not stdi ``` - **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 `lore_id`, + 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 @@ -265,7 +265,7 @@ rac-connect letta --input corpus.jsonl # read a file, not st ``` - **A corpus maps to a Letta archive.** A `source` becomes a named archive; each - record is added as a passage carrying the canonical `lore_id`, `type`, + 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 @@ -297,7 +297,7 @@ rac export rac/ --documents | rac-connect cognee --dry-run # preview, no pipeli rac-connect cognee --input corpus.jsonl # read a file, not stdin ``` -- **A corpus maps to a Cognee dataset.** Each record is staged with a `Lore-Id:` +- **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 diff --git a/docs/connectors/cognee.md b/docs/connectors/cognee.md index 9bd9e6d..730b221 100644 --- a/docs/connectors/cognee.md +++ b/docs/connectors/cognee.md @@ -20,7 +20,7 @@ rac export rac/ --documents | rac-connect cognee --dry-run # preview, no pipeli rac-connect cognee --input corpus.jsonl # read a file, not stdin ``` -- **A corpus maps to a Cognee dataset.** Each record is staged with a `Lore-Id:` +- **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 diff --git a/docs/connectors/letta.md b/docs/connectors/letta.md index 9321f41..c4776bf 100644 --- a/docs/connectors/letta.md +++ b/docs/connectors/letta.md @@ -22,7 +22,7 @@ rac-connect letta --input corpus.jsonl # read a file, not st ``` - **A corpus maps to a Letta archive.** A `source` becomes a named archive; each - record is added as a passage carrying the canonical `lore_id`, `type`, + 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 diff --git a/docs/connectors/mem0.md b/docs/connectors/mem0.md index bc95625..7f51b36 100644 --- a/docs/connectors/mem0.md +++ b/docs/connectors/mem0.md @@ -21,7 +21,7 @@ rac-connect mem0 --input corpus.jsonl # read a file, not std ``` - **Stores the text as-is.** `infer=False` skips Mem0's LLM fact-extraction, so it - only embeds the artifact text; the canonical `lore_id`, `type`, `status`, and + 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 — diff --git a/docs/connectors/supermemory.md b/docs/connectors/supermemory.md index 3dbe6ad..77d841d 100644 --- a/docs/connectors/supermemory.md +++ b/docs/connectors/supermemory.md @@ -24,7 +24,7 @@ Each record maps to a Supermemory upsert: ``` record → add(content=text, container_tag=metadata.source, - metadata={lore id, type, status, title, path, …}, + metadata={rac id, type, status, title, path, …}, custom_id=id) ``` diff --git a/docs/connectors/zep.md b/docs/connectors/zep.md index c4eee8f..c8c52fe 100644 --- a/docs/connectors/zep.md +++ b/docs/connectors/zep.md @@ -21,7 +21,7 @@ rac-connect zep --input corpus.jsonl # read a file, not stdi ``` - **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 `lore_id`, + 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 diff --git a/rac/decisions/adr-004-mem0-documents-connector.md b/rac/decisions/adr-004-mem0-documents-connector.md index d6499eb..bf8e52b 100644 --- a/rac/decisions/adr-004-mem0-documents-connector.md +++ b/rac/decisions/adr-004-mem0-documents-connector.md @@ -31,7 +31,7 @@ strategy, or re-running would duplicate every memory. 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 - `lore_id`, `type`, `status`, and `title` ride in metadata for the + `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 @@ -45,7 +45,7 @@ strategy, or re-running would duplicate every memory. path. - Re-sync is unambiguously idempotent and uses only well-supported Mem0 operations (`delete_all` by partition, `add`). -- Provenance (`lore_id`/`status`) is preserved for the verify-in-Lore loop. +- Provenance (`rac_id`/`status`) is preserved for the verify-in-Lore loop. ### Negative / trade-offs @@ -69,7 +69,7 @@ Architecture ## Alternatives Considered -### Per-record delete-then-add keyed on a `lore_id` metadata filter +### 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 diff --git a/rac/decisions/adr-005-zep-documents-connector.md b/rac/decisions/adr-005-zep-documents-connector.md index 7301667..4fd29cb 100644 --- a/rac/decisions/adr-005-zep-documents-connector.md +++ b/rac/decisions/adr-005-zep-documents-connector.md @@ -31,7 +31,7 @@ to resync a namespace. 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 `lore_id`, `type`, `status`, and `title` ride in episode metadata for + 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 @@ -44,7 +44,7 @@ to resync a namespace. - 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 (`lore_id`/`status`) is preserved for the verify-in-Lore loop. +- Provenance (`rac_id`/`status`) is preserved for the verify-in-Lore loop. ### Negative / trade-offs @@ -68,7 +68,7 @@ Architecture ## Alternatives Considered -### Per-episode delete keyed on a `lore_id` metadata filter +### 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 diff --git a/rac/decisions/adr-006-letta-documents-connector.md b/rac/decisions/adr-006-letta-documents-connector.md index bfda849..78c35e2 100644 --- a/rac/decisions/adr-006-letta-documents-connector.md +++ b/rac/decisions/adr-006-letta-documents-connector.md @@ -32,7 +32,7 @@ Two specifics shape the connector (verified against letta-client 1.12.1): - **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 - `lore_id`/`type`/`status`/`title` in metadata. Re-running yields exactly the + `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). @@ -70,7 +70,7 @@ Architecture ## Alternatives Considered -### Per-passage delete keyed on a `lore_id` metadata filter +### 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 diff --git a/rac/decisions/adr-007-cognee-documents-connector.md b/rac/decisions/adr-007-cognee-documents-connector.md index db94c7a..4783371 100644 --- a/rac/decisions/adr-007-cognee-documents-connector.md +++ b/rac/decisions/adr-007-cognee-documents-connector.md @@ -38,7 +38,7 @@ does not transfer directly. This ADR records how Cognee fits the seam anyway. 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 `Lore-Id`, type, + 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 diff --git a/src/rac_connectors/cognee/client.py b/src/rac_connectors/cognee/client.py index 0ae82c4..122a2c3 100644 --- a/src/rac_connectors/cognee/client.py +++ b/src/rac_connectors/cognee/client.py @@ -87,13 +87,13 @@ def client_from_env() -> SdkCogneeClient: def provenance_payload( - *, text: str, lore_id: str, type: str, status: str, title: str + *, 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 ``lore_id`` + 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"Lore-Id: {lore_id}\nType: {type}\nStatus: {status}\nTitle: {title}" + 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 index d39b065..fe0359d 100644 --- a/src/rac_connectors/cognee/connector.py +++ b/src/rac_connectors/cognee/connector.py @@ -47,7 +47,7 @@ def push(self, records: Iterable[Record], *, dry_run: bool = False) -> PushSumma continue payload = provenance_payload( text=record.text, - lore_id=record.id, + rac_id=record.id, type=record.type, status=record.status, title=record.title, diff --git a/src/rac_connectors/letta/connector.py b/src/rac_connectors/letta/connector.py index dd8ded0..c88e540 100644 --- a/src/rac_connectors/letta/connector.py +++ b/src/rac_connectors/letta/connector.py @@ -38,14 +38,14 @@ def _container_for(self, record: Record) -> str: def _metadata_for(self, record: Record) -> dict[str, Any]: """Metadata shipped with each passage. - ``lore_id`` carries the canonical handle for the verify-in-Lore loop; + ``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( { - "lore_id": record.id, + "rac_id": record.id, "type": record.type, "status": record.status, "title": record.title, diff --git a/src/rac_connectors/mem0/connector.py b/src/rac_connectors/mem0/connector.py index 22c6003..68ee1c2 100644 --- a/src/rac_connectors/mem0/connector.py +++ b/src/rac_connectors/mem0/connector.py @@ -38,14 +38,14 @@ def _container_for(self, record: Record) -> str: def _metadata_for(self, record: Record) -> dict[str, Any]: """Metadata shipped with each memory. - ``lore_id`` carries the canonical handle for the verify-in-Lore loop; + ``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( { - "lore_id": record.id, + "rac_id": record.id, "type": record.type, "status": record.status, "title": record.title, diff --git a/src/rac_connectors/zep/connector.py b/src/rac_connectors/zep/connector.py index 8f54fcd..0408fc3 100644 --- a/src/rac_connectors/zep/connector.py +++ b/src/rac_connectors/zep/connector.py @@ -37,14 +37,14 @@ def _container_for(self, record: Record) -> str: def _metadata_for(self, record: Record) -> dict[str, Any]: """Metadata shipped with each episode. - ``lore_id`` carries the canonical handle for the verify-in-Lore loop; + ``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( { - "lore_id": record.id, + "rac_id": record.id, "type": record.type, "status": record.status, "title": record.title, diff --git a/tests/test_cognee.py b/tests/test_cognee.py index 9a29033..a946ce1 100644 --- a/tests/test_cognee.py +++ b/tests/test_cognee.py @@ -58,7 +58,7 @@ def test_payload_carries_provenance() -> None: client = FakeCogneeClient() CogneeConnector(client).push([_record()]) # push commits internally payload = next(iter(client.built["rac"])) - assert "Lore-Id: RAC-ABC" in payload # the verify-in-Lore handle + 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 diff --git a/tests/test_letta.py b/tests/test_letta.py index 7c1b25a..d4ebe5d 100644 --- a/tests/test_letta.py +++ b/tests/test_letta.py @@ -49,7 +49,7 @@ def test_record_maps_to_add_with_metadata() -> None: call = client.adds[0] assert call["text"] == "## Context\n\nbody" assert call["container"] == "rac" # source maps to the Letta archive - assert call["metadata"]["lore_id"] == "RAC-ABC" + assert call["metadata"]["rac_id"] == "RAC-ABC" assert call["metadata"]["status"] == "Accepted" diff --git a/tests/test_letta_client.py b/tests/test_letta_client.py index 133a0c6..93b6980 100644 --- a/tests/test_letta_client.py +++ b/tests/test_letta_client.py @@ -73,14 +73,14 @@ def stub_letta(monkeypatch: pytest.MonkeyPatch) -> 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={"lore_id": "RAC-1"}) + 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"] == {"lore_id": "RAC-1"} + assert passage["metadata"] == {"rac_id": "RAC-1"} def test_clear_deletes_existing_archive_of_same_name(stub_letta: None) -> None: diff --git a/tests/test_mem0.py b/tests/test_mem0.py index e4e4486..716ca1a 100644 --- a/tests/test_mem0.py +++ b/tests/test_mem0.py @@ -54,7 +54,7 @@ def test_record_maps_to_add_with_metadata() -> None: call = client.adds[0] assert call["text"] == "## Context\n\nbody" assert call["container"] == "rac" # source maps to the Mem0 partition - assert call["metadata"]["lore_id"] == "RAC-ABC" # the verify-in-Lore handle + 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" diff --git a/tests/test_mem0_client.py b/tests/test_mem0_client.py index b68616c..28b4dcb 100644 --- a/tests/test_mem0_client.py +++ b/tests/test_mem0_client.py @@ -45,11 +45,11 @@ def stub_mem0(monkeypatch: pytest.MonkeyPatch) -> 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={"lore_id": "RAC-1"}) + 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"] == {"lore_id": "RAC-1"} + assert call["metadata"] == {"rac_id": "RAC-1"} assert call["infer"] is False # store as-is; no LLM rewrite diff --git a/tests/test_zep.py b/tests/test_zep.py index f88d62c..becca6f 100644 --- a/tests/test_zep.py +++ b/tests/test_zep.py @@ -54,7 +54,7 @@ def test_record_maps_to_add_with_metadata() -> None: call = client.adds[0] assert call["text"] == "## Context\n\nbody" assert call["container"] == "rac" # source maps to the Zep graph_id - assert call["metadata"]["lore_id"] == "RAC-ABC" + assert call["metadata"]["rac_id"] == "RAC-ABC" assert call["metadata"]["status"] == "Accepted" assert call["metadata"]["path"] == "rac/decisions/adr-001.md" diff --git a/tests/test_zep_client.py b/tests/test_zep_client.py index ecdb42a..1ceee7f 100644 --- a/tests/test_zep_client.py +++ b/tests/test_zep_client.py @@ -58,13 +58,13 @@ def stub_zep(monkeypatch: pytest.MonkeyPatch) -> None: def test_add_maps_to_graph_add(stub_zep: None) -> None: client = SdkZepClient(api_key="k") - client.add(text="body", container="rac", metadata={"lore_id": "RAC-1"}) + 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": {"lore_id": "RAC-1"}, + "metadata": {"rac_id": "RAC-1"}, }