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 +36 -0
- memry/backends/__init__.py +18 -0
- memry/backends/ann.py +102 -0
- memry/backends/base.py +173 -0
- memry/backends/local.py +847 -0
- memry/backends/mem0_adapter.py +161 -0
- memry/backends/postgres.py +584 -0
- memry/cli.py +300 -0
- memry/config.py +211 -0
- memry/evals/__init__.py +0 -0
- memry/evals/harness.py +117 -0
- memry/intelligence/__init__.py +0 -0
- memry/intelligence/context.py +45 -0
- memry/intelligence/decay.py +61 -0
- memry/intelligence/entities.py +183 -0
- memry/intelligence/extraction.py +175 -0
- memry/intelligence/reconcile.py +209 -0
- memry/mcp_server.py +198 -0
- memry/models.py +203 -0
- memry/providers/__init__.py +0 -0
- memry/providers/embeddings.py +176 -0
- memry/providers/llm.py +175 -0
- memry/rest.py +403 -0
- memry/retrieval.py +98 -0
- memry/store.py +421 -0
- memry-0.2.0.dist-info/METADATA +248 -0
- memry-0.2.0.dist-info/RECORD +30 -0
- memry-0.2.0.dist-info/WHEEL +4 -0
- memry-0.2.0.dist-info/entry_points.txt +2 -0
- memry-0.2.0.dist-info/licenses/LICENSE +190 -0
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
|