memry 0.2.0__py3-none-any.whl

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.
memry/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """Memry - the open, self-hostable memory layer for AI agents. https://memry.tech"""
2
+
3
+ from .config import Config, DecayConfig, EmbeddingConfig, LLMConfig, RetrievalConfig
4
+ from .models import (
5
+ AddAction,
6
+ AddResult,
7
+ CandidateFact,
8
+ ContextResult,
9
+ Episode,
10
+ Memory,
11
+ MemoryEvent,
12
+ Scope,
13
+ SearchResult,
14
+ )
15
+ from .store import MemoryStore
16
+
17
+ __version__ = "0.2.0"
18
+
19
+ __all__ = [
20
+ "MemoryStore",
21
+ "Config",
22
+ "LLMConfig",
23
+ "EmbeddingConfig",
24
+ "RetrievalConfig",
25
+ "DecayConfig",
26
+ "Memory",
27
+ "Episode",
28
+ "MemoryEvent",
29
+ "Scope",
30
+ "SearchResult",
31
+ "AddResult",
32
+ "AddAction",
33
+ "CandidateFact",
34
+ "ContextResult",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,18 @@
1
+ from .base import MemoryBackend
2
+
3
+
4
+ def build_backend(config) -> MemoryBackend:
5
+ if config.backend == "mem0":
6
+ from .mem0_adapter import Mem0Backend
7
+
8
+ return Mem0Backend(config)
9
+ if config.backend == "postgres":
10
+ from .postgres import PostgresBackend
11
+
12
+ return PostgresBackend(config)
13
+ from .local import LocalBackend
14
+
15
+ return LocalBackend(config.db_path, ann=config.ann)
16
+
17
+
18
+ __all__ = ["MemoryBackend", "build_backend"]
memry/backends/ann.py ADDED
@@ -0,0 +1,102 @@
1
+ """Optional HNSW sidecar index (usearch) for approximate nearest-neighbor
2
+ search at scale.
3
+
4
+ Install with ``pip install memry[ann]``. Without it - or below the configured
5
+ row threshold - LocalBackend uses exact brute-force cosine, which is faster at
6
+ small sizes anyway. The sidecar is a cache, never the source of truth: it can
7
+ be deleted at any time and is rebuilt from SQLite (``memry reindex`` or lazily
8
+ on model mismatch). ANN results are always exact-rescored before ranking.
9
+
10
+ Keys are stable integers from the ``ann_keys`` table (SQLite rowids can be
11
+ renumbered by VACUUM, so they are not used directly).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+
21
+ try: # pragma: no cover - environment dependent
22
+ from usearch.index import Index as _UsearchIndex
23
+
24
+ HAS_USEARCH = True
25
+ except ImportError: # pragma: no cover
26
+ _UsearchIndex = None
27
+ HAS_USEARCH = False
28
+
29
+
30
+ class HnswSidecar:
31
+ """Persisted usearch index keyed by ann_keys.key."""
32
+
33
+ def __init__(self, db_path: str, dimensions: int, model_id: str) -> None:
34
+ self.dimensions = dimensions
35
+ self.model_id = model_id
36
+ self._persist = db_path != ":memory:"
37
+ self.index_path = Path(f"{db_path}.usearch") if self._persist else None
38
+ self.meta_path = Path(f"{db_path}.usearch.json") if self._persist else None
39
+ self.index = _UsearchIndex(ndim=dimensions, metric="cos")
40
+ self._loaded_ok = self._try_load()
41
+
42
+ @property
43
+ def size(self) -> int:
44
+ return len(self.index)
45
+
46
+ @property
47
+ def needs_rebuild(self) -> bool:
48
+ return not self._loaded_ok
49
+
50
+ def _try_load(self) -> bool:
51
+ if not self._persist or self.index_path is None or not self.index_path.exists():
52
+ return False
53
+ try:
54
+ meta = json.loads(self.meta_path.read_text(encoding="utf-8"))
55
+ if meta.get("model_id") != self.model_id or meta.get("dimensions") != self.dimensions:
56
+ return False
57
+ self.index.load(str(self.index_path))
58
+ return True
59
+ except Exception:
60
+ return False
61
+
62
+ def save(self) -> None:
63
+ if not self._persist or self.index_path is None:
64
+ return
65
+ try:
66
+ self.index.save(str(self.index_path))
67
+ self.meta_path.write_text(
68
+ json.dumps({"model_id": self.model_id, "dimensions": self.dimensions}),
69
+ encoding="utf-8",
70
+ )
71
+ except Exception:
72
+ pass # the sidecar is a cache; persistence failures must not break writes
73
+
74
+ def add(self, key: int, vector: list[float]) -> None:
75
+ vec = np.asarray(vector, dtype=np.float32)
76
+ try:
77
+ if self.index.contains(key):
78
+ self.index.remove(key)
79
+ self.index.add(key, vec)
80
+ except Exception:
81
+ self._loaded_ok = False # force rebuild on next opportunity
82
+
83
+ def remove(self, key: int) -> None:
84
+ try:
85
+ if self.index.contains(key):
86
+ self.index.remove(key)
87
+ except Exception:
88
+ self._loaded_ok = False
89
+
90
+ def search(self, vector: list[float], k: int) -> list[int]:
91
+ vec = np.asarray(vector, dtype=np.float32)
92
+ matches = self.index.search(vec, k)
93
+ keys = np.asarray(matches.keys).reshape(-1)
94
+ return [int(key) for key in keys]
95
+
96
+ def rebuild(self, items: list[tuple[int, bytes]]) -> None:
97
+ """items: (key, float32-blob) pairs for every active memory."""
98
+ self.index = _UsearchIndex(ndim=self.dimensions, metric="cos")
99
+ for key, blob in items:
100
+ self.index.add(key, np.frombuffer(blob, dtype=np.float32))
101
+ self._loaded_ok = True
102
+ self.save()
memry/backends/base.py ADDED
@@ -0,0 +1,173 @@
1
+ """Storage backend interface.
2
+
3
+ The application layer (``MemoryStore``) and the intelligence layer only ever
4
+ talk to this interface, so backends are replaceable: the default is the local
5
+ SQLite engine; a Mem0 adapter ships as an optional interop/benchmark backend,
6
+ and Postgres/Qdrant/etc. can be added without touching callers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from abc import ABC, abstractmethod
12
+ from typing import Any
13
+
14
+ from ..models import Entity, EntityMention, Episode, Memory, MemoryEvent, MergeProposal, Scope
15
+
16
+
17
+ class MemoryBackend(ABC):
18
+ """Persistence contract: episodes (raw), memories (derived), events (audit)."""
19
+
20
+ # -- episodes -------------------------------------------------------
21
+ @abstractmethod
22
+ def add_episodes(self, episodes: list[Episode]) -> None: ...
23
+
24
+ @abstractmethod
25
+ def list_episodes(self, scope: Scope, limit: int = 100) -> list[Episode]: ...
26
+
27
+ # -- memories -------------------------------------------------------
28
+ @abstractmethod
29
+ def insert_memory(self, memory: Memory, embedding: list[float] | None = None) -> Memory:
30
+ """Persist a new memory. Returns the stored memory (backends may
31
+ assign their own id)."""
32
+
33
+ @abstractmethod
34
+ def update_memory(
35
+ self,
36
+ memory_id: str,
37
+ *,
38
+ content: str | None = None,
39
+ embedding: list[float] | None = None,
40
+ embedding_model: str | None = None,
41
+ importance: float | None = None,
42
+ memory_type: str | None = None,
43
+ categories: list[str] | None = None,
44
+ entities: list[str] | None = None,
45
+ metadata: dict[str, Any] | None = None,
46
+ source_episode_ids: list[str] | None = None,
47
+ ) -> Memory | None: ...
48
+
49
+ @abstractmethod
50
+ def invalidate_memory(
51
+ self, memory_id: str, *, superseded_by: str | None = None
52
+ ) -> Memory | None:
53
+ """Temporal soft-delete: mark the memory as no longer valid."""
54
+
55
+ @abstractmethod
56
+ def delete_memory(self, memory_id: str) -> bool:
57
+ """Hard delete (rarely what you want; prefer invalidate)."""
58
+
59
+ @abstractmethod
60
+ def get_memory(self, memory_id: str) -> Memory | None: ...
61
+
62
+ @abstractmethod
63
+ def list_memories(
64
+ self,
65
+ scope: Scope,
66
+ *,
67
+ include_invalid: bool = False,
68
+ limit: int = 100,
69
+ offset: int = 0,
70
+ categories: list[str] | None = None,
71
+ ) -> list[Memory]: ...
72
+
73
+ # -- search primitives ---------------------------------------------
74
+ @abstractmethod
75
+ def vector_search(
76
+ self,
77
+ embedding: list[float],
78
+ embedding_model: str,
79
+ scope: Scope,
80
+ limit: int = 20,
81
+ include_invalid: bool = False,
82
+ categories: list[str] | None = None,
83
+ ) -> list[tuple[Memory, float]]:
84
+ """Cosine similarity over stored vectors (same embedding model only)."""
85
+
86
+ @abstractmethod
87
+ def keyword_search(
88
+ self,
89
+ query: str,
90
+ scope: Scope,
91
+ limit: int = 20,
92
+ include_invalid: bool = False,
93
+ categories: list[str] | None = None,
94
+ ) -> list[tuple[Memory, float]]:
95
+ """Full-text (BM25) search. Higher score = better."""
96
+
97
+ def native_search(
98
+ self, query: str, scope: Scope, limit: int = 20
99
+ ) -> list[tuple[Memory, float]] | None:
100
+ """Backends with their own fused retrieval (e.g. Mem0) return results
101
+ here; ``None`` means "use memry's hybrid fusion" (the default)."""
102
+ return None
103
+
104
+ # -- entities ---------------------------------------------------------
105
+ # Default implementations are no-ops so adapters without entity support
106
+ # (e.g. Mem0) stay valid; LocalBackend and PostgresBackend override all.
107
+ def insert_entity(self, entity: Entity) -> Entity:
108
+ return entity
109
+
110
+ def get_entity(self, entity_id: str) -> Entity | None:
111
+ return None
112
+
113
+ def find_entities(self, normalized: str, scope: Scope) -> list[Entity]:
114
+ """Active (unmerged) entities with this normalized name, in scope."""
115
+ return []
116
+
117
+ def list_entities(
118
+ self, scope: Scope, *, include_merged: bool = False, limit: int = 100
119
+ ) -> list[Entity]:
120
+ return []
121
+
122
+ def add_mention(self, mention: EntityMention) -> None:
123
+ return None
124
+
125
+ def entity_mentions(self, entity_id: str) -> list[EntityMention]:
126
+ return []
127
+
128
+ def entity_memories(self, entity_id: str, limit: int = 10) -> list[Memory]:
129
+ """Memories that mention this entity (following merges)."""
130
+ return []
131
+
132
+ def merge_entities(self, keep_id: str, merge_id: str) -> bool:
133
+ """Fold ``merge_id`` into ``keep_id`` (repoint mentions, mark merged)."""
134
+ return False
135
+
136
+ def add_proposal(self, proposal: MergeProposal) -> MergeProposal:
137
+ return proposal
138
+
139
+ def get_proposal(self, proposal_id: str) -> MergeProposal | None:
140
+ return None
141
+
142
+ def find_proposal(self, entity_a: str, entity_b: str) -> MergeProposal | None:
143
+ """Existing proposal for this unordered pair, any status."""
144
+ return None
145
+
146
+ def list_proposals(
147
+ self, scope: Scope, *, status: str | None = "proposed", limit: int = 100
148
+ ) -> list[MergeProposal]:
149
+ return []
150
+
151
+ def set_proposal_status(self, proposal_id: str, status: str) -> MergeProposal | None:
152
+ return None
153
+
154
+ # -- events / audit ---------------------------------------------------
155
+ @abstractmethod
156
+ def add_event(self, event: MemoryEvent) -> None: ...
157
+
158
+ @abstractmethod
159
+ def history(self, memory_id: str) -> list[MemoryEvent]: ...
160
+
161
+ # -- maintenance ------------------------------------------------------
162
+ @abstractmethod
163
+ def all_memories_iter(self, include_invalid: bool = True) -> list[Memory]:
164
+ """All memories, for reindexing/decay sweeps."""
165
+
166
+ @abstractmethod
167
+ def stats(self) -> dict[str, Any]: ...
168
+
169
+ @abstractmethod
170
+ def reset(self) -> None: ...
171
+
172
+ def close(self) -> None: # pragma: no cover - trivial default
173
+ pass