xyberos-redis 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: xyberos-redis
3
+ Version: 0.1.0
4
+ Summary: Redis plugin (RFC-0019, M4): RedisVectorStore + RedisMemory + cache backing for CacheResponder
5
+ License: Apache-2.0
6
+ Keywords: xyberos,plugin,redis,vector,memory,cache,state
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: xyberos>=1.0
10
+ Provides-Extra: state
11
+ Requires-Dist: redis; extra == "state"
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: fakeredis; extra == "test"
15
+
16
+ # xyberos-redis
17
+
18
+ **Redis plugin — RFC-0019, M4.** `RedisVectorStore` + `RedisMemory` + cache
19
+ backing for `CacheResponder`, all lazy-importing `redis`. Covers the
20
+ `[state]` extra scope (cache + state + vector).
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install -e ./redis
26
+ pip install redis # or: pip install xyberos[state]
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from xyberos import create_app
33
+ from xyberos_redis import RedisPlugin
34
+
35
+ app = create_app()
36
+ app.load_plugin(RedisPlugin(url="redis://localhost:6379")) # or set REDIS_URL
37
+
38
+ vector = app.resolve("redis_vector") # a VectorStore
39
+ memory = app.resolve("redis_memory") # a Memory
40
+ cache = app.resolve("redis_cache") # exact-match string cache
41
+ ```
42
+
43
+ `RedisPlugin(replace_defaults=True)` also swaps the app's `vector_store` /
44
+ `memory` providers.
45
+
46
+ ### CacheResponder backing
47
+
48
+ `RedisVectorStore` is a full `VectorStore`, so it plugs straight into the core
49
+ `CacheResponder` for near-exact `prompt -> answer` caching:
50
+
51
+ ```python
52
+ from xyberos.llm import HashEmbedder
53
+ from xyberos.router import CacheResponder
54
+ from xyberos_redis import RedisVectorStore
55
+
56
+ responder = CacheResponder(store=RedisVectorStore(url="redis://localhost:6379"),
57
+ embedder=HashEmbedder())
58
+ responder.teach("what is xyberos?", "a cognitive platform")
59
+ ```
60
+
61
+ ## Design
62
+
63
+ - **`RedisVectorStore`** — one Redis hash per namespace; vectors JSON-encoded;
64
+ queries scored with exact cosine (the same scan-and-score approach as the
65
+ stdlib `SqliteVectorStore`, so behavior — and parity — holds without needing
66
+ a RediSearch vector module).
67
+ - **`RedisMemory`** — one entry per `store()` appended to a Redis list;
68
+ `retrieve()` returns `MemoryEntry`s oldest-first, mirroring `SqliteMemory`.
69
+ - **`RedisStringCache`** — tiny exact-match `get/set/delete` with optional TTL.
70
+ - `redis` is imported **lazily**; a clear `ProviderError` is raised when missing.
71
+
72
+ ## Tests
73
+
74
+ ```bash
75
+ pip install pytest fakeredis
76
+ pytest tests/
77
+ ```
78
+
79
+ Tests run against **fakeredis** (an in-memory redis-py client), so they need no
80
+ Redis server; they skip cleanly when `redis`/`fakeredis` are absent. The vector
81
+ parity tests run the exact scenarios the `SqliteVectorStore` reference passes.
82
+
83
+ ## Ship location
84
+
85
+ `[state]` extra (`redis` already added to `pyproject.toml`).
@@ -0,0 +1,70 @@
1
+ # xyberos-redis
2
+
3
+ **Redis plugin — RFC-0019, M4.** `RedisVectorStore` + `RedisMemory` + cache
4
+ backing for `CacheResponder`, all lazy-importing `redis`. Covers the
5
+ `[state]` extra scope (cache + state + vector).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install -e ./redis
11
+ pip install redis # or: pip install xyberos[state]
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```python
17
+ from xyberos import create_app
18
+ from xyberos_redis import RedisPlugin
19
+
20
+ app = create_app()
21
+ app.load_plugin(RedisPlugin(url="redis://localhost:6379")) # or set REDIS_URL
22
+
23
+ vector = app.resolve("redis_vector") # a VectorStore
24
+ memory = app.resolve("redis_memory") # a Memory
25
+ cache = app.resolve("redis_cache") # exact-match string cache
26
+ ```
27
+
28
+ `RedisPlugin(replace_defaults=True)` also swaps the app's `vector_store` /
29
+ `memory` providers.
30
+
31
+ ### CacheResponder backing
32
+
33
+ `RedisVectorStore` is a full `VectorStore`, so it plugs straight into the core
34
+ `CacheResponder` for near-exact `prompt -> answer` caching:
35
+
36
+ ```python
37
+ from xyberos.llm import HashEmbedder
38
+ from xyberos.router import CacheResponder
39
+ from xyberos_redis import RedisVectorStore
40
+
41
+ responder = CacheResponder(store=RedisVectorStore(url="redis://localhost:6379"),
42
+ embedder=HashEmbedder())
43
+ responder.teach("what is xyberos?", "a cognitive platform")
44
+ ```
45
+
46
+ ## Design
47
+
48
+ - **`RedisVectorStore`** — one Redis hash per namespace; vectors JSON-encoded;
49
+ queries scored with exact cosine (the same scan-and-score approach as the
50
+ stdlib `SqliteVectorStore`, so behavior — and parity — holds without needing
51
+ a RediSearch vector module).
52
+ - **`RedisMemory`** — one entry per `store()` appended to a Redis list;
53
+ `retrieve()` returns `MemoryEntry`s oldest-first, mirroring `SqliteMemory`.
54
+ - **`RedisStringCache`** — tiny exact-match `get/set/delete` with optional TTL.
55
+ - `redis` is imported **lazily**; a clear `ProviderError` is raised when missing.
56
+
57
+ ## Tests
58
+
59
+ ```bash
60
+ pip install pytest fakeredis
61
+ pytest tests/
62
+ ```
63
+
64
+ Tests run against **fakeredis** (an in-memory redis-py client), so they need no
65
+ Redis server; they skip cleanly when `redis`/`fakeredis` are absent. The vector
66
+ parity tests run the exact scenarios the `SqliteVectorStore` reference passes.
67
+
68
+ ## Ship location
69
+
70
+ `[state]` extra (`redis` already added to `pyproject.toml`).
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xyberos-redis"
7
+ version = "0.1.0"
8
+ description = "Redis plugin (RFC-0019, M4): RedisVectorStore + RedisMemory + cache backing for CacheResponder"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Apache-2.0"}
12
+ dependencies = ["xyberos>=1.0"]
13
+ keywords = ["xyberos", "plugin", "redis", "vector", "memory", "cache", "state"]
14
+
15
+ [project.optional-dependencies]
16
+ state = ["redis"]
17
+ test = ["pytest", "fakeredis"]
18
+
19
+ [project.entry-points."xyberos.plugins"]
20
+ redis = "xyberos_redis.plugin:plugin"
21
+
22
+ [tool.setuptools]
23
+ packages = ["xyberos_redis"]
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,50 @@
1
+ """Tests for the Redis cache story: CacheResponder backing + string cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+ from xyberos.llm import HashEmbedder
9
+ from xyberos.router import CacheResponder
10
+
11
+ from xyberos_redis import RedisStringCache, RedisVectorStore
12
+
13
+
14
+ @pytest.fixture()
15
+ def client():
16
+ return pytest.importorskip("fakeredis").FakeStrictRedis()
17
+
18
+
19
+ def test_redis_vector_store_backs_cache_responder(client):
20
+ """RedisVectorStore is a VectorStore, so it can back CacheResponder."""
21
+ responder = CacheResponder(
22
+ store=RedisVectorStore(client=client),
23
+ embedder=HashEmbedder(),
24
+ threshold=0.9,
25
+ )
26
+ responder.teach("what is xyberos?", "a cognitive platform")
27
+ assert responder.size == 1
28
+
29
+ answer = responder.respond(SimpleNamespace(prompt="what is xyberos?"))
30
+ assert answer == "a cognitive platform"
31
+ # A near-identical prompt also hits (exact-match embedding -> score 1.0).
32
+ assert responder.respond(SimpleNamespace(prompt="what is xyberos?")) is not None
33
+
34
+
35
+ def test_redis_string_cache(client):
36
+ cache = RedisStringCache(client=client)
37
+ assert cache.get("k") is None
38
+ cache.set("k", "v")
39
+ assert cache.get("k") == "v"
40
+ cache.delete("k")
41
+ assert cache.get("k") is None
42
+
43
+
44
+ def test_redis_string_cache_clear(client):
45
+ cache = RedisStringCache(client=client)
46
+ cache.set("a", "1")
47
+ cache.set("b", "2")
48
+ cache.clear()
49
+ assert cache.get("a") is None
50
+ assert cache.get("b") is None
@@ -0,0 +1,68 @@
1
+ """Tests for RedisMemory — unit + parity with SqliteMemory semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+
9
+ from xyberos.memory import MemoryEntry, SqliteMemory
10
+
11
+ from xyberos_redis import RedisMemory
12
+
13
+
14
+ @pytest.fixture()
15
+ def client():
16
+ return pytest.importorskip("fakeredis").FakeStrictRedis()
17
+
18
+
19
+ @pytest.fixture()
20
+ def memory(client):
21
+ return RedisMemory(client=client)
22
+
23
+
24
+ def _ctx(prompt="hello", response="hi", metadata=None, plan=None, error=None):
25
+ return SimpleNamespace(
26
+ prompt=prompt, response=response, metadata=metadata, plan=plan, error=error
27
+ )
28
+
29
+
30
+ def test_store_and_retrieve_in_order(memory):
31
+ memory.store(_ctx(prompt="first", response="one"))
32
+ memory.store(_ctx(prompt="second", response="two"))
33
+ entries = memory.retrieve(_ctx())
34
+ assert [e.prompt for e in entries] == ["first", "second"]
35
+ assert entries[0].response == "one"
36
+ assert entries[1].response == "two"
37
+
38
+
39
+ def test_metadata_round_trip(memory):
40
+ memory.store(_ctx(prompt="q", response="a", metadata={"user": "baltz"}))
41
+ entry = memory.retrieve(_ctx())[0]
42
+ assert entry.metadata == {"user": "baltz"}
43
+
44
+
45
+ def test_clear(memory):
46
+ memory.store(_ctx())
47
+ memory.clear()
48
+ assert memory.retrieve(_ctx()) == []
49
+
50
+
51
+ def test_parity_with_sqlite_memory(client):
52
+ """RedisMemory and SqliteMemory must expose the same observable behavior."""
53
+ redis_mem = RedisMemory(client=client)
54
+ sqlite_mem = SqliteMemory(":memory:")
55
+ ctx = _ctx(prompt="q", response="a", metadata={"k": "v"}, plan={"step": 1})
56
+
57
+ redis_mem.store(ctx)
58
+ sqlite_mem.store(ctx)
59
+
60
+ redis_entries = redis_mem.retrieve(_ctx())
61
+ sqlite_entries = sqlite_mem.retrieve(_ctx())
62
+
63
+ assert len(redis_entries) == len(sqlite_entries) == 1
64
+ assert redis_entries[0].prompt == sqlite_entries[0].prompt == "q"
65
+ assert redis_entries[0].response == sqlite_entries[0].response == "a"
66
+ assert redis_entries[0].metadata == sqlite_entries[0].metadata == {"k": "v"}
67
+ assert redis_entries[0].plan == sqlite_entries[0].plan == {"step": 1}
68
+ assert redis_entries[0].created_at # both record a timestamp
@@ -0,0 +1,50 @@
1
+ """M4 parity: RedisVectorStore must behave like SqliteVectorStore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from xyberos.vector import SqliteVectorStore
7
+
8
+ from xyberos_redis import RedisVectorStore
9
+
10
+ QUERY = [1.0, 0.0, 0.0, 0.0]
11
+
12
+
13
+ def run_parity_scenarios(store) -> None:
14
+ """The full VectorStore contract; every adapter must pass these."""
15
+ store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
16
+ store.upsert("ns", "b", [0.8, 0.2, 0.0, 0.0], {"text": "beta"})
17
+ store.upsert("ns", "c", [0.5, 0.5, 0.0, 0.0], {"text": "gamma"})
18
+ store.upsert("other", "x", [1.0, 1.0, 1.0, 1.0], {"text": "other ns"})
19
+
20
+ hits = store.query("ns", QUERY, top_k=3)
21
+ assert [h.id for h in hits] == ["a", "b", "c"]
22
+ assert hits[0].payload["text"] == "alpha"
23
+ assert hits[0].score > hits[1].score > hits[2].score
24
+ assert len(store.query("ns", QUERY, top_k=1)) == 1
25
+
26
+ strict = store.query("ns", QUERY, top_k=3, threshold=0.99)
27
+ assert [h.id for h in strict] == ["a"]
28
+
29
+ store.delete("ns", "b")
30
+ assert [h.id for h in store.query("ns", QUERY, top_k=3)] == ["a", "c"]
31
+ assert [h.id for h in store.query("other", [1.0, 1.0, 1.0, 1.0], top_k=2)] == ["x"]
32
+ assert all(abs(h.score) < 1e-6 for h in store.query("ns", [0.0, 0.0, 0.0, 1.0], top_k=2))
33
+
34
+ store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha-v2"})
35
+ assert store.query("ns", QUERY, top_k=1)[0].payload["text"] == "alpha-v2"
36
+
37
+ store.clear("ns")
38
+ assert store.query("ns", QUERY, top_k=3) == []
39
+ assert [h.id for h in store.query("other", [1.0, 1.0, 1.0, 1.0], top_k=1)] == ["x"]
40
+
41
+
42
+ def test_sqlite_reference() -> None:
43
+ """The stdlib reference passes the same scenarios the adapters must match."""
44
+ run_parity_scenarios(SqliteVectorStore(":memory:"))
45
+
46
+
47
+ def test_redis_parity():
48
+ pytest.importorskip("fakeredis")
49
+ client = pytest.importorskip("fakeredis").FakeStrictRedis()
50
+ run_parity_scenarios(RedisVectorStore(client=client))
@@ -0,0 +1,46 @@
1
+ """Tests for loading the redis plugin into a Xyberos app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from xyberos import create_app
7
+
8
+ from xyberos_redis import RedisPlugin
9
+
10
+
11
+ @pytest.fixture()
12
+ def client():
13
+ return pytest.importorskip("fakeredis").FakeStrictRedis()
14
+
15
+
16
+ def test_plugin_conforms_to_contract():
17
+ plugin = RedisPlugin()
18
+ assert plugin.name == "redis"
19
+ assert callable(plugin.register) and callable(plugin.unregister)
20
+
21
+
22
+ def test_plugin_registers_services(client):
23
+ app = create_app()
24
+ app.load_plugin(RedisPlugin(client=client, key_prefix="test"))
25
+ assert "redis_client" in app.registry.names
26
+ assert "redis_vector" in app.registry.names
27
+ assert "redis_memory" in app.registry.names
28
+ assert "redis_cache" in app.registry.names
29
+
30
+ vector = app.resolve("redis_vector")
31
+ vector.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0], {"text": "alpha"})
32
+ assert vector.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=1)[0].payload["text"] == "alpha"
33
+
34
+ memory = app.resolve("redis_memory")
35
+ memory.store(type("Ctx", (), {"prompt": "q", "response": "a", "metadata": {}})())
36
+ assert memory.retrieve(type("Ctx", (), {})())[0].prompt == "q"
37
+
38
+ app.unload_plugin("redis")
39
+
40
+
41
+ def test_replace_defaults(client):
42
+ app = create_app()
43
+ app.load_plugin(RedisPlugin(client=client, key_prefix="test", replace_defaults=True))
44
+ assert app.resolve("memory").__class__.__name__ == "RedisMemory"
45
+ assert app.resolve("vector_store").__class__.__name__ == "RedisVectorStore"
46
+ app.unload_plugin("redis")
@@ -0,0 +1,45 @@
1
+ """Unit tests for RedisVectorStore (fakeredis, no server needed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from xyberos_redis import RedisVectorStore
8
+
9
+
10
+ @pytest.fixture()
11
+ def client():
12
+ return pytest.importorskip("fakeredis").FakeStrictRedis()
13
+
14
+
15
+ @pytest.fixture()
16
+ def store(client):
17
+ return RedisVectorStore(client=client)
18
+
19
+
20
+ def test_upsert_and_query(store):
21
+ store.upsert("ns", "a", [1.0, 0.0, 0.0], {"text": "alpha"})
22
+ store.upsert("ns", "b", [0.0, 1.0, 0.0], {"text": "beta"})
23
+ hits = store.query("ns", [1.0, 0.0, 0.0], top_k=2)
24
+ assert hits[0].id == "a"
25
+ assert hits[0].payload["text"] == "alpha"
26
+
27
+
28
+ def test_namespaces_isolated(store):
29
+ store.upsert("one", "a", [1.0, 0.0, 0.0], {"text": "one"})
30
+ store.upsert("two", "b", [0.0, 1.0, 0.0], {"text": "two"})
31
+ assert [h.id for h in store.query("one", [1.0, 0.0, 0.0])] == ["a"]
32
+ assert [h.id for h in store.query("two", [1.0, 0.0, 0.0])] == ["b"]
33
+
34
+
35
+ def test_delete(store):
36
+ store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0])
37
+ store.upsert("ns", "b", [0.0, 1.0, 0.0, 0.0])
38
+ store.delete("ns", "a")
39
+ assert [h.id for h in store.query("ns", [1.0, 0.0, 0.0, 0.0], top_k=2)] == ["b"]
40
+
41
+
42
+ def test_clear(store):
43
+ store.upsert("ns", "a", [1.0, 0.0, 0.0, 0.0])
44
+ store.clear("ns")
45
+ assert store.query("ns", [1.0, 0.0, 0.0, 0.0]) == []
@@ -0,0 +1,19 @@
1
+ """Redis plugin (RFC-0019, M4): vector store + memory + cache backing.
2
+
3
+ * :class:`RedisVectorStore` — a :class:`~xyberos.contracts.VectorStore` backed
4
+ by Redis hashes (can also back :class:`~xyberos.router.CacheResponder`).
5
+ * :class:`RedisMemory` — a :class:`~xyberos.contracts.Memory` backed by a Redis
6
+ list.
7
+ * :class:`RedisStringCache` — a small exact-match string cache for ephemeral
8
+ state.
9
+
10
+ ``redis`` is imported lazily on first use and a clear ``ProviderError`` is
11
+ raised when it is missing (``pip install xyberos[state]``).
12
+ """
13
+
14
+ from .cache import RedisStringCache
15
+ from .memory import RedisMemory
16
+ from .plugin import RedisPlugin
17
+ from .vector import RedisVectorStore
18
+
19
+ __all__ = ["RedisMemory", "RedisPlugin", "RedisStringCache", "RedisVectorStore"]
@@ -0,0 +1,80 @@
1
+ """A tiny exact-match string cache backed by Redis (lazy ``redis``).
2
+
3
+ This covers the "ephemeral state" side of the RFC-0019 M4 Redis scope; the
4
+ *vector* cache backing for :class:`~xyberos.router.CacheResponder` is provided
5
+ by :class:`~xyberos_redis.RedisVectorStore` (a full ``VectorStore``), which can
6
+ be passed as ``CacheResponder(store=..., embedder=...)`` for near-exact
7
+ ``prompt -> answer`` caching.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ from typing import Any
14
+
15
+ from xyberos.exceptions.provider import ProviderError
16
+
17
+
18
+ def _require_redis() -> Any:
19
+ try:
20
+ redis = importlib.import_module("redis")
21
+ except ImportError as exc:
22
+ raise ProviderError(
23
+ "the 'redis' package is required; install it with "
24
+ "'pip install xyberos[state]'"
25
+ ) from exc
26
+ return redis
27
+
28
+
29
+ class RedisStringCache:
30
+ """An exact-match ``key -> value`` string cache with optional TTL."""
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ url: str | None = None,
36
+ client: Any | None = None,
37
+ key_prefix: str = "xyberos:cache",
38
+ ) -> None:
39
+ self._url = url
40
+ self._client = client
41
+ self._prefix = key_prefix
42
+
43
+ def start(self) -> None:
44
+ self._get_client()
45
+
46
+ def stop(self) -> None:
47
+ self._client = None
48
+
49
+ def get(self, key: str) -> str | None:
50
+ value = self._get_client().get(f"{self._prefix}:{key}")
51
+ if value is None:
52
+ return None
53
+ return value.decode("utf-8") if isinstance(value, bytes) else str(value)
54
+
55
+ def set(self, key: str, value: str, *, ttl: int | None = None) -> None:
56
+ client = self._get_client()
57
+ full_key = f"{self._prefix}:{key}"
58
+ client.set(full_key, value, ex=ttl)
59
+
60
+ def delete(self, key: str) -> None:
61
+ self._get_client().delete(f"{self._prefix}:{key}")
62
+
63
+ def clear(self) -> None:
64
+ keys = self._keys()
65
+ if keys:
66
+ self._get_client().delete(*keys)
67
+
68
+ def _keys(self) -> list[str]:
69
+ client = self._get_client()
70
+ pattern = f"{self._prefix}:*"
71
+ if hasattr(client, "keys"):
72
+ return [k.decode("utf-8") if isinstance(k, bytes) else str(k) for k in client.keys(pattern)]
73
+ return []
74
+
75
+ def _get_client(self) -> Any:
76
+ if self._client is not None:
77
+ return self._client
78
+ redis = _require_redis()
79
+ self._client = redis.from_url(self._url) if self._url else redis.Redis()
80
+ return self._client
@@ -0,0 +1,104 @@
1
+ """Redis-backed :class:`~xyberos.contracts.Memory` (lazy ``redis``).
2
+
3
+ Mirrors the stdlib ``SqliteMemory`` semantics: ``store`` appends one
4
+ reconstructable snapshot (a ``MemoryEntry``) and ``retrieve`` returns all
5
+ entries, oldest first, in insertion order. The list lives under
6
+ ``<prefix>:memory``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from typing import Any
15
+
16
+ from xyberos.contracts.memory import Memory
17
+ from xyberos.memory import MemoryEntry
18
+ from xyberos.exceptions.provider import ProviderError
19
+
20
+
21
+ def _require_redis() -> Any:
22
+ try:
23
+ redis = importlib.import_module("redis")
24
+ except ImportError as exc:
25
+ raise ProviderError(
26
+ "the 'redis' package is required; install it with "
27
+ "'pip install xyberos[state]'"
28
+ ) from exc
29
+ return redis
30
+
31
+
32
+ def _decode(value: Any) -> str:
33
+ if isinstance(value, bytes):
34
+ return value.decode("utf-8")
35
+ return str(value)
36
+
37
+
38
+ def _utc_now() -> str:
39
+ return datetime.now(timezone.utc).isoformat()
40
+
41
+
42
+ class RedisMemory(Memory):
43
+ """A :class:`Memory` backed by a Redis list (one row per ``store`` call)."""
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ url: str | None = None,
49
+ client: Any | None = None,
50
+ key: str = "xyberos:memory",
51
+ ) -> None:
52
+ self._url = url
53
+ self._client = client
54
+ self._key = key
55
+
56
+ def start(self) -> None:
57
+ self._get_client()
58
+
59
+ def stop(self) -> None:
60
+ self._client = None
61
+
62
+ def retrieve(self, context: object) -> list[MemoryEntry]:
63
+ client = self._get_client()
64
+ raw_entries = client.lrange(self._key, 0, -1)
65
+ entries: list[MemoryEntry] = []
66
+ for raw in raw_entries:
67
+ record = json.loads(_decode(raw))
68
+ entries.append(
69
+ MemoryEntry(
70
+ prompt=record.get("prompt"),
71
+ response=record.get("response"),
72
+ metadata=record.get("metadata") or {},
73
+ plan=record.get("plan"),
74
+ error=record.get("error"),
75
+ created_at=record.get("created_at", ""),
76
+ )
77
+ )
78
+ return entries
79
+
80
+ def store(self, context: object) -> None:
81
+ client = self._get_client()
82
+ record = json.dumps(
83
+ {
84
+ "prompt": getattr(context, "prompt", None),
85
+ "response": getattr(context, "response", None),
86
+ "metadata": dict(getattr(context, "metadata", None) or {}),
87
+ "plan": getattr(context, "plan", None),
88
+ "error": getattr(context, "error", None),
89
+ "created_at": _utc_now(),
90
+ },
91
+ default=str,
92
+ )
93
+ client.rpush(self._key, record)
94
+
95
+ def clear(self) -> None:
96
+ client = self._get_client()
97
+ client.delete(self._key)
98
+
99
+ def _get_client(self) -> Any:
100
+ if self._client is not None:
101
+ return self._client
102
+ redis = _require_redis()
103
+ self._client = redis.from_url(self._url) if self._url else redis.Redis()
104
+ return self._client
@@ -0,0 +1,96 @@
1
+ """Redis plugin entry point (RFC-0019, M4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ from typing import Any
8
+
9
+ from xyberos.contracts import Plugin
10
+ from xyberos.exceptions.provider import ProviderError
11
+
12
+ from .cache import RedisStringCache
13
+ from .memory import RedisMemory
14
+ from .vector import RedisVectorStore
15
+
16
+
17
+ def _require_redis() -> Any:
18
+ try:
19
+ redis = importlib.import_module("redis")
20
+ except ImportError as exc:
21
+ raise ProviderError(
22
+ "the 'redis' package is required; install it with "
23
+ "'pip install xyberos[state]'"
24
+ ) from exc
25
+ return redis
26
+
27
+
28
+ class RedisPlugin(Plugin):
29
+ """Registers Redis-backed services.
30
+
31
+ Registers named services ``redis_client``, ``redis_vector``,
32
+ ``redis_memory`` and ``redis_cache`` (all sharing one Redis client). When
33
+ ``replace_defaults=True`` it also replaces the app's ``vector_store`` and
34
+ ``memory`` providers. Configuration comes from explicit args, then
35
+ ``REDIS_URL`` / ``REDIS_PREFIX`` env vars.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ url: str | None = None,
42
+ client: Any | None = None,
43
+ key_prefix: str | None = None,
44
+ replace_defaults: bool = False,
45
+ ) -> None:
46
+ self._url = url or os.getenv("REDIS_URL")
47
+ self._client = client
48
+ self._prefix = key_prefix or os.getenv("REDIS_PREFIX") or "xyberos"
49
+ self._replace_defaults = replace_defaults
50
+
51
+ @property
52
+ def name(self) -> str:
53
+ return "redis"
54
+
55
+ def _shared_client(self) -> Any:
56
+ if self._client is None:
57
+ redis = _require_redis()
58
+ self._client = redis.from_url(self._url) if self._url else redis.Redis()
59
+ return self._client
60
+
61
+ def register(self, kernel: object) -> None:
62
+ client = self._shared_client()
63
+ kernel.register("redis_client", client, replace=True)
64
+ kernel.register(
65
+ "redis_vector",
66
+ RedisVectorStore(client=client, key_prefix=f"{self._prefix}:vs"),
67
+ replace=True,
68
+ )
69
+ kernel.register(
70
+ "redis_memory",
71
+ RedisMemory(client=client, key=f"{self._prefix}:memory"),
72
+ replace=True,
73
+ )
74
+ kernel.register(
75
+ "redis_cache",
76
+ RedisStringCache(client=client, key_prefix=f"{self._prefix}:cache"),
77
+ replace=True,
78
+ )
79
+ if self._replace_defaults:
80
+ kernel.register(
81
+ "vector_store",
82
+ RedisVectorStore(client=client, key_prefix=f"{self._prefix}:vs"),
83
+ replace=True,
84
+ )
85
+ kernel.register(
86
+ "memory",
87
+ RedisMemory(client=client, key=f"{self._prefix}:memory"),
88
+ replace=True,
89
+ )
90
+
91
+ def unregister(self, kernel: object) -> None:
92
+ pass
93
+
94
+
95
+ #: Auto-discovered by ``app.load_entry_points()``.
96
+ plugin = RedisPlugin()
@@ -0,0 +1,135 @@
1
+ """Redis-backed :class:`~xyberos.contracts.VectorStore` (lazy ``redis``).
2
+
3
+ Vectors are stored as JSON inside one Redis hash per namespace
4
+ (``<prefix>:<namespace>``). On query the whole namespace is scanned and scored
5
+ with exact cosine similarity — the same approach the stdlib
6
+ ``SqliteVectorStore`` uses (it also scans rows and computes cosine in Python),
7
+ so behavior is identical and parity holds without requiring a RediSearch
8
+ vector module.
9
+
10
+ Because it is a full :class:`VectorStore`, it can be passed straight to
11
+ :class:`~xyberos.router.CacheResponder(store=..., embedder=...)` for
12
+ near-exact ``prompt -> answer`` caching.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib
18
+ import json
19
+ from collections.abc import Mapping, Sequence
20
+ from typing import Any
21
+
22
+ from xyberos.contracts.vector import ScoredHit, VectorStore
23
+ from xyberos.exceptions.provider import ProviderError
24
+
25
+
26
+ def _require_redis() -> Any:
27
+ try:
28
+ redis = importlib.import_module("redis")
29
+ except ImportError as exc:
30
+ raise ProviderError(
31
+ "the 'redis' package is required; install it with "
32
+ "'pip install xyberos[state]'"
33
+ ) from exc
34
+ return redis
35
+
36
+
37
+ def _decode(value: Any) -> str:
38
+ if isinstance(value, bytes):
39
+ return value.decode("utf-8")
40
+ return str(value)
41
+
42
+
43
+ def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
44
+ dot = sum(x * y for x, y in zip(a, b))
45
+ norm_a = sum(x * x for x in a) ** 0.5
46
+ norm_b = sum(x * x for x in b) ** 0.5
47
+ if norm_a == 0.0 or norm_b == 0.0:
48
+ return 0.0
49
+ return dot / (norm_a * norm_b)
50
+
51
+
52
+ class RedisVectorStore(VectorStore):
53
+ """A :class:`VectorStore` backed by Redis hashes (one per namespace)."""
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ url: str | None = None,
59
+ client: Any | None = None,
60
+ key_prefix: str = "xyberos:vs",
61
+ ) -> None:
62
+ self._url = url
63
+ self._client = client
64
+ self._prefix = key_prefix
65
+
66
+ def start(self) -> None:
67
+ """Connect to Redis (kernel lifecycle hook)."""
68
+ self._get_client()
69
+
70
+ def stop(self) -> None:
71
+ """Release the client (kernel lifecycle hook)."""
72
+ self._client = None
73
+
74
+ def upsert(
75
+ self,
76
+ namespace: str,
77
+ id: str,
78
+ vector: Sequence[float],
79
+ payload: Mapping[str, Any] | None = None,
80
+ ) -> None:
81
+ client = self._get_client()
82
+ record = json.dumps(
83
+ {"v": [float(value) for value in vector], "p": dict(payload or {})},
84
+ default=str,
85
+ )
86
+ client.hset(self._key(namespace), str(id), record)
87
+
88
+ def query(
89
+ self,
90
+ namespace: str,
91
+ vector: Sequence[float],
92
+ *,
93
+ top_k: int = 5,
94
+ threshold: float | None = None,
95
+ ) -> list[ScoredHit]:
96
+ client = self._get_client()
97
+ raw = client.hgetall(self._key(namespace))
98
+ if not raw:
99
+ return []
100
+ query_vector = [float(value) for value in vector]
101
+ scored: list[ScoredHit] = []
102
+ for raw_id, raw_record in raw.items():
103
+ record = json.loads(_decode(raw_record))
104
+ similarity = _cosine(query_vector, record["v"])
105
+ if threshold is not None and similarity < threshold:
106
+ continue
107
+ scored.append(
108
+ ScoredHit(
109
+ id=_decode(raw_id),
110
+ score=similarity,
111
+ payload=record.get("p"),
112
+ )
113
+ )
114
+ scored.sort(key=lambda hit: hit.score, reverse=True)
115
+ return scored[:top_k]
116
+
117
+ def delete(self, namespace: str, id: str) -> None:
118
+ client = self._get_client()
119
+ client.hdel(self._key(namespace), str(id))
120
+
121
+ def clear(self, namespace: str) -> None:
122
+ client = self._get_client()
123
+ client.delete(self._key(namespace))
124
+
125
+ # -- internals ----------------------------------------------------------
126
+
127
+ def _get_client(self) -> Any:
128
+ if self._client is not None:
129
+ return self._client
130
+ redis = _require_redis()
131
+ self._client = redis.from_url(self._url) if self._url else redis.Redis()
132
+ return self._client
133
+
134
+ def _key(self, namespace: str) -> str:
135
+ return f"{self._prefix}:{namespace}"
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: xyberos-redis
3
+ Version: 0.1.0
4
+ Summary: Redis plugin (RFC-0019, M4): RedisVectorStore + RedisMemory + cache backing for CacheResponder
5
+ License: Apache-2.0
6
+ Keywords: xyberos,plugin,redis,vector,memory,cache,state
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: xyberos>=1.0
10
+ Provides-Extra: state
11
+ Requires-Dist: redis; extra == "state"
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: fakeredis; extra == "test"
15
+
16
+ # xyberos-redis
17
+
18
+ **Redis plugin — RFC-0019, M4.** `RedisVectorStore` + `RedisMemory` + cache
19
+ backing for `CacheResponder`, all lazy-importing `redis`. Covers the
20
+ `[state]` extra scope (cache + state + vector).
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install -e ./redis
26
+ pip install redis # or: pip install xyberos[state]
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from xyberos import create_app
33
+ from xyberos_redis import RedisPlugin
34
+
35
+ app = create_app()
36
+ app.load_plugin(RedisPlugin(url="redis://localhost:6379")) # or set REDIS_URL
37
+
38
+ vector = app.resolve("redis_vector") # a VectorStore
39
+ memory = app.resolve("redis_memory") # a Memory
40
+ cache = app.resolve("redis_cache") # exact-match string cache
41
+ ```
42
+
43
+ `RedisPlugin(replace_defaults=True)` also swaps the app's `vector_store` /
44
+ `memory` providers.
45
+
46
+ ### CacheResponder backing
47
+
48
+ `RedisVectorStore` is a full `VectorStore`, so it plugs straight into the core
49
+ `CacheResponder` for near-exact `prompt -> answer` caching:
50
+
51
+ ```python
52
+ from xyberos.llm import HashEmbedder
53
+ from xyberos.router import CacheResponder
54
+ from xyberos_redis import RedisVectorStore
55
+
56
+ responder = CacheResponder(store=RedisVectorStore(url="redis://localhost:6379"),
57
+ embedder=HashEmbedder())
58
+ responder.teach("what is xyberos?", "a cognitive platform")
59
+ ```
60
+
61
+ ## Design
62
+
63
+ - **`RedisVectorStore`** — one Redis hash per namespace; vectors JSON-encoded;
64
+ queries scored with exact cosine (the same scan-and-score approach as the
65
+ stdlib `SqliteVectorStore`, so behavior — and parity — holds without needing
66
+ a RediSearch vector module).
67
+ - **`RedisMemory`** — one entry per `store()` appended to a Redis list;
68
+ `retrieve()` returns `MemoryEntry`s oldest-first, mirroring `SqliteMemory`.
69
+ - **`RedisStringCache`** — tiny exact-match `get/set/delete` with optional TTL.
70
+ - `redis` is imported **lazily**; a clear `ProviderError` is raised when missing.
71
+
72
+ ## Tests
73
+
74
+ ```bash
75
+ pip install pytest fakeredis
76
+ pytest tests/
77
+ ```
78
+
79
+ Tests run against **fakeredis** (an in-memory redis-py client), so they need no
80
+ Redis server; they skip cleanly when `redis`/`fakeredis` are absent. The vector
81
+ parity tests run the exact scenarios the `SqliteVectorStore` reference passes.
82
+
83
+ ## Ship location
84
+
85
+ `[state]` extra (`redis` already added to `pyproject.toml`).
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ tests/test_cache.py
4
+ tests/test_memory.py
5
+ tests/test_parity.py
6
+ tests/test_plugin.py
7
+ tests/test_vector.py
8
+ xyberos_redis/__init__.py
9
+ xyberos_redis/cache.py
10
+ xyberos_redis/memory.py
11
+ xyberos_redis/plugin.py
12
+ xyberos_redis/vector.py
13
+ xyberos_redis.egg-info/PKG-INFO
14
+ xyberos_redis.egg-info/SOURCES.txt
15
+ xyberos_redis.egg-info/dependency_links.txt
16
+ xyberos_redis.egg-info/entry_points.txt
17
+ xyberos_redis.egg-info/requires.txt
18
+ xyberos_redis.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [xyberos.plugins]
2
+ redis = xyberos_redis.plugin:plugin
@@ -0,0 +1,8 @@
1
+ xyberos>=1.0
2
+
3
+ [state]
4
+ redis
5
+
6
+ [test]
7
+ pytest
8
+ fakeredis
@@ -0,0 +1 @@
1
+ xyberos_redis