crewai-memory-core 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,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ dist/
7
+ build/
8
+ uv.lock
9
+ .env
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.5
2
+ Name: crewai-memory-core
3
+ Version: 0.1.0
4
+ Summary: Shared core for CrewAI unified-memory StorageBackend implementations — build a Memory backend with five small primitives
5
+ Project-URL: Homepage, https://github.com/skamalj/crewai-memory
6
+ Project-URL: Repository, https://github.com/skamalj/crewai-memory.git
7
+ Project-URL: Documentation, https://skamalj.github.io/agentstate-reducer/
8
+ Author-email: Kamal <skamalj@gmail.com>
9
+ Keywords: agent-memory,crewai,long-term-memory,memory,storage-backend,vector-search
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: crewai>=1.10.0
16
+ Description-Content-Type: text/markdown
17
+
18
+ # crewai-memory-core
19
+
20
+ Shared core for building [CrewAI](https://docs.crewai.com/en/concepts/memory) unified-memory **`StorageBackend`** implementations. CrewAI 1.10+ has one `Memory` engine (LLM analysis, consolidation, scope inference, composite scoring) over a pluggable storage protocol; this core implements the *whole* protocol — `save` / `search` / `delete` / `update` / `get_record` / `list_records` / `get_scope_info` / `list_scopes` / `list_categories` / `count` / `reset` / `touch_records` and the async variants — so a backend only supplies five primitives:
21
+
22
+ ```python
23
+ from crewai_memory_core import MemoryBackend
24
+
25
+ class MyBackend(MemoryBackend):
26
+ def _put(self, rows): ... # upsert rows by id
27
+ def _get(self, record_id): ... # -> row | None
28
+ def _delete_ids(self, ids): ... # -> int deleted
29
+ def _scan(self, scope_prefix): ... # every row in the scope subtree
30
+ # optional — native ANN; default ranks _scan rows by cosine in Python
31
+ def _vector_search(self, vector, scope_prefix, limit): ... # -> [(row, score), ...]
32
+ ```
33
+
34
+ A `row` is a dict of the `MemoryRecord` fields plus `embedding`. Scope paths are hierarchical (`/company/team/user`); a prefix matches the scope itself and everything under it, never a sibling that merely shares characters (`/a` does not match `/ab`).
35
+
36
+ Use a backend with CrewAI:
37
+
38
+ ```python
39
+ from crewai import Crew
40
+ from crewai.memory import Memory
41
+
42
+ memory = Memory(storage=MyBackend(...))
43
+ crew = Crew(agents=[...], tasks=[...], memory=memory) # or set_memory_storage_factory(...) once at startup
44
+ ```
45
+
46
+ `crewai_memory_core.testing` ships `FakeEmbedder` (deterministic, no network — pass as `Memory(embedder=...)`) and `InMemoryBackend`; `crewai_memory_core.contract` is an importable test suite every provider runs, including an end-to-end pass through CrewAI's real `Memory` engine with zero LLM calls.
47
+
48
+ Concrete backends: [`crewai-memory-dynamodb`](https://pypi.org/project/crewai-memory-dynamodb/), [`crewai-memory-postgres`](https://pypi.org/project/crewai-memory-postgres/), [`crewai-memory-cosmosdb`](https://pypi.org/project/crewai-memory-cosmosdb/), [`crewai-memory-firestore`](https://pypi.org/project/crewai-memory-firestore/).
49
+
50
+ Docs: <https://skamalj.github.io/agentstate-reducer/>
51
+
52
+ ## License
53
+
54
+ MIT
@@ -0,0 +1,37 @@
1
+ # crewai-memory-core
2
+
3
+ Shared core for building [CrewAI](https://docs.crewai.com/en/concepts/memory) unified-memory **`StorageBackend`** implementations. CrewAI 1.10+ has one `Memory` engine (LLM analysis, consolidation, scope inference, composite scoring) over a pluggable storage protocol; this core implements the *whole* protocol — `save` / `search` / `delete` / `update` / `get_record` / `list_records` / `get_scope_info` / `list_scopes` / `list_categories` / `count` / `reset` / `touch_records` and the async variants — so a backend only supplies five primitives:
4
+
5
+ ```python
6
+ from crewai_memory_core import MemoryBackend
7
+
8
+ class MyBackend(MemoryBackend):
9
+ def _put(self, rows): ... # upsert rows by id
10
+ def _get(self, record_id): ... # -> row | None
11
+ def _delete_ids(self, ids): ... # -> int deleted
12
+ def _scan(self, scope_prefix): ... # every row in the scope subtree
13
+ # optional — native ANN; default ranks _scan rows by cosine in Python
14
+ def _vector_search(self, vector, scope_prefix, limit): ... # -> [(row, score), ...]
15
+ ```
16
+
17
+ A `row` is a dict of the `MemoryRecord` fields plus `embedding`. Scope paths are hierarchical (`/company/team/user`); a prefix matches the scope itself and everything under it, never a sibling that merely shares characters (`/a` does not match `/ab`).
18
+
19
+ Use a backend with CrewAI:
20
+
21
+ ```python
22
+ from crewai import Crew
23
+ from crewai.memory import Memory
24
+
25
+ memory = Memory(storage=MyBackend(...))
26
+ crew = Crew(agents=[...], tasks=[...], memory=memory) # or set_memory_storage_factory(...) once at startup
27
+ ```
28
+
29
+ `crewai_memory_core.testing` ships `FakeEmbedder` (deterministic, no network — pass as `Memory(embedder=...)`) and `InMemoryBackend`; `crewai_memory_core.contract` is an importable test suite every provider runs, including an end-to-end pass through CrewAI's real `Memory` engine with zero LLM calls.
30
+
31
+ Concrete backends: [`crewai-memory-dynamodb`](https://pypi.org/project/crewai-memory-dynamodb/), [`crewai-memory-postgres`](https://pypi.org/project/crewai-memory-postgres/), [`crewai-memory-cosmosdb`](https://pypi.org/project/crewai-memory-cosmosdb/), [`crewai-memory-firestore`](https://pypi.org/project/crewai-memory-firestore/).
32
+
33
+ Docs: <https://skamalj.github.io/agentstate-reducer/>
34
+
35
+ ## License
36
+
37
+ MIT
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "crewai-memory-core"
7
+ version = "0.1.0"
8
+ description = "Shared core for CrewAI unified-memory StorageBackend implementations — build a Memory backend with five small primitives"
9
+ authors = [{name = "Kamal", email = "skamalj@gmail.com"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "crewai>=1.10.0",
14
+ ]
15
+ keywords = ["crewai", "memory", "storage-backend", "long-term-memory", "agent-memory", "vector-search"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/skamalj/crewai-memory"
25
+ Repository = "https://github.com/skamalj/crewai-memory.git"
26
+ Documentation = "https://skamalj.github.io/agentstate-reducer/"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/crewai_memory_core"]
@@ -0,0 +1,290 @@
1
+ """Shared core for CrewAI unified-memory ``StorageBackend`` implementations.
2
+
3
+ CrewAI 1.10+ has one ``Memory`` engine (LLM analysis, consolidation, composite
4
+ scoring) and a pluggable ``crewai.memory.storage.backend.StorageBackend``
5
+ protocol underneath it. ``MemoryBackend`` implements the *whole* protocol —
6
+ save / search / delete / update / get_record / list_records / get_scope_info /
7
+ list_scopes / list_categories / count / reset / touch_records and the async
8
+ variants — on top of five primitives a datastore supplies:
9
+
10
+ _put(rows) -> None upsert rows by id
11
+ _get(record_id) -> row | None
12
+ _delete_ids(ids) -> int
13
+ _scan(scope_prefix) -> list[row] every row in the scope subtree
14
+ _vector_search(vector, scope_prefix, limit) -> list[(row, score)] (optional)
15
+
16
+ A ``row`` is a plain dict with the ``MemoryRecord`` fields (``id``, ``content``,
17
+ ``scope``, ``categories``, ``metadata``, ``importance``, ``created_at``,
18
+ ``last_accessed``, ``source``, ``private``) plus ``embedding``. ``score`` is a
19
+ cosine similarity, higher is better. The default ``_vector_search`` ranks
20
+ ``_scan`` rows in Python, so a backend works before it has a native path.
21
+
22
+ Scope semantics: ``scope_prefix="/a"`` matches ``/a`` and everything under
23
+ ``/a/...`` but not ``/ab``; ``"/"`` or ``None`` matches everything.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import math
30
+ from datetime import datetime
31
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
32
+
33
+ from crewai.memory.types import MemoryRecord, ScopeInfo
34
+
35
+ __all__ = ["MemoryBackend", "record_to_row", "row_to_record", "in_scope", "norm_scope", "cosine_similarity"]
36
+
37
+ ROW_FIELDS = (
38
+ "id", "content", "scope", "categories", "metadata", "importance",
39
+ "created_at", "last_accessed", "source", "private", "embedding",
40
+ )
41
+
42
+
43
+ def norm_scope(scope: Optional[str]) -> str:
44
+ """Normalise a scope path: leading slash, no trailing slash, ``/`` for root."""
45
+ s = (scope or "/").strip()
46
+ if not s.startswith("/"):
47
+ s = "/" + s
48
+ s = s.rstrip("/")
49
+ return s or "/"
50
+
51
+
52
+ def in_scope(scope: str, prefix: Optional[str]) -> bool:
53
+ """True if ``scope`` is ``prefix`` itself or lies under it."""
54
+ p = norm_scope(prefix)
55
+ if p == "/":
56
+ return True
57
+ s = norm_scope(scope)
58
+ return s == p or s.startswith(p + "/")
59
+
60
+
61
+ def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
62
+ dot = sum(x * y for x, y in zip(a, b))
63
+ na = math.sqrt(sum(x * x for x in a))
64
+ nb = math.sqrt(sum(y * y for y in b))
65
+ return dot / (na * nb) if na and nb else 0.0
66
+
67
+
68
+ def _parse_dt(val: Any) -> datetime:
69
+ if isinstance(val, datetime):
70
+ return val
71
+ if val is None:
72
+ return datetime.utcnow()
73
+ return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
74
+
75
+
76
+ def record_to_row(record: MemoryRecord) -> dict:
77
+ return {
78
+ "id": record.id,
79
+ "content": record.content,
80
+ "scope": norm_scope(record.scope),
81
+ "categories": list(record.categories or []),
82
+ "metadata": dict(record.metadata or {}),
83
+ "importance": float(record.importance),
84
+ "created_at": record.created_at.isoformat(),
85
+ "last_accessed": record.last_accessed.isoformat(),
86
+ "source": record.source,
87
+ "private": bool(record.private),
88
+ "embedding": list(record.embedding) if record.embedding else None,
89
+ }
90
+
91
+
92
+ def row_to_record(row: dict) -> MemoryRecord:
93
+ return MemoryRecord(
94
+ id=str(row["id"]),
95
+ content=str(row.get("content", "")),
96
+ scope=norm_scope(row.get("scope")),
97
+ categories=list(row.get("categories") or []),
98
+ metadata=dict(row.get("metadata") or {}),
99
+ importance=float(row.get("importance", 0.5)),
100
+ created_at=_parse_dt(row.get("created_at")),
101
+ last_accessed=_parse_dt(row.get("last_accessed")),
102
+ embedding=list(row["embedding"]) if row.get("embedding") else None,
103
+ source=row.get("source") or None,
104
+ private=bool(row.get("private", False)),
105
+ )
106
+
107
+
108
+ def _matches(row: dict, categories: Optional[List[str]], metadata_filter: Optional[Dict[str, Any]],
109
+ older_than: Optional[datetime] = None) -> bool:
110
+ if categories and not any(c in (row.get("categories") or []) for c in categories):
111
+ return False
112
+ if metadata_filter:
113
+ md = row.get("metadata") or {}
114
+ if not all(md.get(k) == v for k, v in metadata_filter.items()):
115
+ return False
116
+ if older_than is not None and _parse_dt(row.get("created_at")) >= older_than:
117
+ return False
118
+ return True
119
+
120
+
121
+ class MemoryBackend:
122
+ """A CrewAI ``StorageBackend`` implemented over five datastore primitives."""
123
+
124
+ supports_native_vector_search: bool = False
125
+
126
+ # ------------------------------------------------------------------ primitives
127
+ def _put(self, rows: List[dict]) -> None:
128
+ raise NotImplementedError
129
+
130
+ def _get(self, record_id: str) -> Optional[dict]:
131
+ raise NotImplementedError
132
+
133
+ def _delete_ids(self, ids: List[str]) -> int:
134
+ raise NotImplementedError
135
+
136
+ def _scan(self, scope_prefix: Optional[str]) -> List[dict]:
137
+ """Every row whose scope is ``scope_prefix`` or under it (all rows for "/" / None)."""
138
+ raise NotImplementedError
139
+
140
+ def _vector_search(
141
+ self, vector: List[float], scope_prefix: Optional[str], limit: int
142
+ ) -> List[Tuple[dict, float]]:
143
+ """Up to ``limit`` ``(row, cosine_similarity)`` pairs in the scope subtree, best first."""
144
+ scored = [
145
+ (row, cosine_similarity(vector, row["embedding"]))
146
+ for row in self._scan(scope_prefix)
147
+ if row.get("embedding")
148
+ ]
149
+ scored.sort(key=lambda rs: -rs[1])
150
+ return scored[:limit]
151
+
152
+ # ------------------------------------------------------------------ StorageBackend
153
+ def save(self, records: List[MemoryRecord]) -> None:
154
+ if records:
155
+ self._put([record_to_row(r) for r in records])
156
+
157
+ def update(self, record: MemoryRecord) -> None:
158
+ self._put([record_to_row(record)])
159
+
160
+ def get_record(self, record_id: str) -> Optional[MemoryRecord]:
161
+ row = self._get(record_id)
162
+ return row_to_record(row) if row else None
163
+
164
+ def search(
165
+ self,
166
+ query_embedding: List[float],
167
+ scope_prefix: Optional[str] = None,
168
+ categories: Optional[List[str]] = None,
169
+ metadata_filter: Optional[Dict[str, Any]] = None,
170
+ limit: int = 10,
171
+ min_score: float = 0.0,
172
+ ) -> List[Tuple[MemoryRecord, float]]:
173
+ if not query_embedding:
174
+ return []
175
+ oversample = limit * 3 if (categories or metadata_filter) else limit
176
+ hits = self._vector_search(list(query_embedding), scope_prefix, oversample)
177
+ out: List[Tuple[MemoryRecord, float]] = []
178
+ for row, score in hits:
179
+ if not in_scope(row["scope"], scope_prefix):
180
+ continue
181
+ if not _matches(row, categories, metadata_filter):
182
+ continue
183
+ if score >= min_score:
184
+ out.append((row_to_record(row), float(score)))
185
+ if len(out) >= limit:
186
+ break
187
+ return out
188
+
189
+ def delete(
190
+ self,
191
+ scope_prefix: Optional[str] = None,
192
+ categories: Optional[List[str]] = None,
193
+ record_ids: Optional[List[str]] = None,
194
+ older_than: Optional[datetime] = None,
195
+ metadata_filter: Optional[Dict[str, Any]] = None,
196
+ ) -> int:
197
+ if record_ids and not (categories or metadata_filter or older_than):
198
+ return self._delete_ids(list(record_ids))
199
+ rows = self._scan(scope_prefix)
200
+ if record_ids:
201
+ wanted = set(record_ids)
202
+ rows = [r for r in rows if r["id"] in wanted]
203
+ ids = [r["id"] for r in rows if _matches(r, categories, metadata_filter, older_than)]
204
+ return self._delete_ids(ids) if ids else 0
205
+
206
+ def list_records(self, scope_prefix: Optional[str] = None, limit: int = 200, offset: int = 0) -> List[MemoryRecord]:
207
+ rows = sorted(self._scan(scope_prefix), key=lambda r: _parse_dt(r.get("created_at")), reverse=True)
208
+ return [row_to_record(r) for r in rows[offset : offset + limit]]
209
+
210
+ def get_scope_info(self, scope: str) -> ScopeInfo:
211
+ scope = norm_scope(scope)
212
+ rows = self._scan(scope)
213
+ cats: set = set()
214
+ oldest = newest = None
215
+ children: set = set()
216
+ child_prefix = "/" if scope == "/" else scope + "/"
217
+ for r in rows:
218
+ sc = norm_scope(r["scope"])
219
+ if sc.startswith(child_prefix):
220
+ first = sc[len(child_prefix):].split("/", 1)[0]
221
+ if first:
222
+ children.add(child_prefix + first)
223
+ cats.update(r.get("categories") or [])
224
+ dt = _parse_dt(r.get("created_at"))
225
+ oldest = dt if oldest is None or dt < oldest else oldest
226
+ newest = dt if newest is None or dt > newest else newest
227
+ return ScopeInfo(
228
+ path=scope, record_count=len(rows), categories=sorted(cats),
229
+ oldest_record=oldest, newest_record=newest, child_scopes=sorted(children),
230
+ )
231
+
232
+ def list_scopes(self, parent: str = "/") -> List[str]:
233
+ parent = norm_scope(parent)
234
+ child_prefix = "/" if parent == "/" else parent + "/"
235
+ children: set = set()
236
+ for r in self._scan(parent):
237
+ sc = norm_scope(r["scope"])
238
+ if sc.startswith(child_prefix) and sc != parent:
239
+ first = sc[len(child_prefix):].split("/", 1)[0]
240
+ if first:
241
+ children.add(child_prefix + first)
242
+ return sorted(children)
243
+
244
+ def list_categories(self, scope_prefix: Optional[str] = None) -> Dict[str, int]:
245
+ counts: Dict[str, int] = {}
246
+ for r in self._scan(scope_prefix):
247
+ for c in r.get("categories") or []:
248
+ counts[c] = counts.get(c, 0) + 1
249
+ return counts
250
+
251
+ def count(self, scope_prefix: Optional[str] = None) -> int:
252
+ return len(self._scan(scope_prefix))
253
+
254
+ def reset(self, scope_prefix: Optional[str] = None) -> None:
255
+ ids = [r["id"] for r in self._scan(scope_prefix)]
256
+ if ids:
257
+ self._delete_ids(ids)
258
+
259
+ def touch_records(self, record_ids: List[str]) -> None:
260
+ """Bump ``last_accessed`` (called by ``Memory.recall``; best-effort)."""
261
+ now = datetime.utcnow().isoformat()
262
+ rows = []
263
+ for rid in record_ids:
264
+ row = self._get(rid)
265
+ if row:
266
+ row["last_accessed"] = now
267
+ rows.append(row)
268
+ if rows:
269
+ self._put(rows)
270
+
271
+ def close(self) -> None: # pragma: no cover - default no-op
272
+ pass
273
+
274
+ # ------------------------------------------------------------------ async surface
275
+ async def asave(self, records: List[MemoryRecord]) -> None:
276
+ await asyncio.to_thread(self.save, records)
277
+
278
+ async def asearch(self, query_embedding: List[float], scope_prefix: Optional[str] = None,
279
+ categories: Optional[List[str]] = None, metadata_filter: Optional[Dict[str, Any]] = None,
280
+ limit: int = 10, min_score: float = 0.0) -> List[Tuple[MemoryRecord, float]]:
281
+ return await asyncio.to_thread(
282
+ self.search, query_embedding, scope_prefix, categories, metadata_filter, limit, min_score
283
+ )
284
+
285
+ async def adelete(self, scope_prefix: Optional[str] = None, categories: Optional[List[str]] = None,
286
+ record_ids: Optional[List[str]] = None, older_than: Optional[datetime] = None,
287
+ metadata_filter: Optional[Dict[str, Any]] = None) -> int:
288
+ return await asyncio.to_thread(
289
+ self.delete, scope_prefix, categories, record_ids, older_than, metadata_filter
290
+ )
@@ -0,0 +1,244 @@
1
+ """Reusable ``StorageBackend`` contract tests.
2
+
3
+ A provider's test module supplies a ``backend`` fixture (fresh, empty store)
4
+ and does ``from crewai_memory_core.contract import *``. Every test below then
5
+ runs against that backend, including an end-to-end pass through CrewAI's real
6
+ ``Memory`` engine with the deterministic ``FakeEmbedder`` (no LLM: all record
7
+ fields are provided so the encoding flow takes its zero-LLM fast path, and
8
+ recall uses ``depth="shallow"``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import time
14
+ from datetime import datetime, timedelta
15
+
16
+ import pytest
17
+ from crewai.memory.types import MemoryRecord
18
+
19
+ from .testing import FakeEmbedder
20
+
21
+ DIMS = 64
22
+ EMB = FakeEmbedder(DIMS)
23
+
24
+ __all__ = [n for n in dir() if n.startswith("test_")] # filled after definitions
25
+
26
+
27
+ def _rec(content, scope="/", categories=None, importance=0.5, metadata=None, source=None, private=False, created=None):
28
+ return MemoryRecord(
29
+ content=content, scope=scope, categories=categories or [], importance=importance,
30
+ metadata=metadata or {}, source=source, private=private,
31
+ embedding=EMB([content])[0], **({"created_at": created} if created else {}),
32
+ )
33
+
34
+
35
+ def _seed(b):
36
+ recs = [
37
+ _rec("the user loves sushi and japanese food", "/crew/support/user/kamal", ["food", "pref"], 0.9, {"lang": "en"}),
38
+ _rec("the user writes python every day", "/crew/support/user/kamal", ["tech"], 0.6, {"lang": "en"}),
39
+ _rec("the user lives by the sea in a big city", "/crew/support/user/kamal", ["place"], 0.4, {"lang": "fr"}),
40
+ _rec("priya loves sushi", "/crew/support/user/priya", ["food"], 0.5),
41
+ _rec("company policy: remote work allowed", "/company", ["policy"], 0.8),
42
+ ]
43
+ b.save(recs)
44
+ return recs
45
+
46
+
47
+ def _wait(fn, cond, tries=15, delay=1.0):
48
+ for _ in range(tries):
49
+ out = fn()
50
+ if cond(out):
51
+ return out
52
+ time.sleep(delay)
53
+ return fn()
54
+
55
+
56
+ # ── save / get / update / delete ──────────────────────────────────────────────
57
+
58
+
59
+ def test_save_and_get_roundtrip(backend):
60
+ r = _rec("hello world", "/a/b", ["x"], 0.7, {"k": 1, "s": "v"}, source="u1", private=True)
61
+ backend.save([r])
62
+ got = _wait(lambda: backend.get_record(r.id), lambda g: g is not None)
63
+ assert got.id == r.id and got.content == "hello world" and got.scope == "/a/b"
64
+ assert got.categories == ["x"] and got.metadata == {"k": 1, "s": "v"}
65
+ assert got.importance == pytest.approx(0.7) and got.source == "u1" and got.private is True
66
+ assert abs((got.created_at - r.created_at).total_seconds()) < 1
67
+
68
+
69
+ def test_get_missing_is_none(backend):
70
+ assert backend.get_record("nope") is None
71
+
72
+
73
+ def test_update_replaces_record(backend):
74
+ r = _rec("v1", "/a")
75
+ backend.save([r])
76
+ r2 = r.model_copy(update={"content": "v2", "importance": 0.99, "embedding": EMB(["v2"])[0]})
77
+ backend.update(r2)
78
+ got = _wait(lambda: backend.get_record(r.id), lambda g: g and g.content == "v2")
79
+ assert got.content == "v2" and got.importance == pytest.approx(0.99)
80
+ assert backend.count() == 1
81
+
82
+
83
+ def test_delete_by_ids(backend):
84
+ recs = _seed(backend)
85
+ _wait(lambda: backend.count(), lambda n: n == 5)
86
+ assert backend.delete(record_ids=[recs[0].id, recs[1].id]) == 2
87
+ assert _wait(lambda: backend.count(), lambda n: n == 3) == 3
88
+
89
+
90
+ def test_delete_by_scope_categories_metadata_and_age(backend):
91
+ _seed(backend)
92
+ old = _rec("ancient fact", "/crew/support/user/kamal", ["old"], created=datetime.utcnow() - timedelta(days=400))
93
+ backend.save([old])
94
+ _wait(lambda: backend.count(), lambda n: n == 6)
95
+ assert backend.delete(scope_prefix="/crew/support/user/kamal", categories=["tech"]) == 1
96
+ assert backend.delete(scope_prefix="/crew", metadata_filter={"lang": "fr"}) == 1
97
+ assert backend.delete(older_than=datetime.utcnow() - timedelta(days=30)) == 1
98
+ assert _wait(lambda: backend.count(), lambda n: n == 3) == 3
99
+ assert backend.delete(scope_prefix="/crew/support/user/kamal") == 1 # remaining kamal record
100
+ assert backend.count("/company") == 1
101
+
102
+
103
+ def test_reset_scope_and_all(backend):
104
+ _seed(backend)
105
+ _wait(lambda: backend.count(), lambda n: n == 5)
106
+ backend.reset("/crew/support/user")
107
+ assert _wait(lambda: backend.count(), lambda n: n == 1) == 1
108
+ backend.reset()
109
+ assert _wait(lambda: backend.count(), lambda n: n == 0) == 0
110
+
111
+
112
+ # ── scopes / categories / listing ─────────────────────────────────────────────
113
+
114
+
115
+ def test_scope_prefix_is_path_aware(backend):
116
+ backend.save([_rec("a", "/a"), _rec("ab", "/ab"), _rec("a-child", "/a/child")])
117
+ _wait(lambda: backend.count(), lambda n: n == 3)
118
+ assert backend.count("/a") == 2 # /a and /a/child, not /ab
119
+ assert backend.count("/ab") == 1
120
+ assert backend.count("/") == 3 and backend.count(None) == 3
121
+
122
+
123
+ def test_list_scopes_immediate_children(backend):
124
+ _seed(backend)
125
+ _wait(lambda: backend.count(), lambda n: n == 5)
126
+ assert backend.list_scopes("/") == ["/company", "/crew"]
127
+ assert backend.list_scopes("/crew/support") == ["/crew/support/user"]
128
+ assert backend.list_scopes("/crew/support/user") == ["/crew/support/user/kamal", "/crew/support/user/priya"]
129
+ assert backend.list_scopes("/nowhere") == []
130
+
131
+
132
+ def test_get_scope_info(backend):
133
+ _seed(backend)
134
+ _wait(lambda: backend.count(), lambda n: n == 5)
135
+ info = backend.get_scope_info("/crew/support/user")
136
+ assert info.path == "/crew/support/user" and info.record_count == 4
137
+ assert set(info.categories) == {"food", "pref", "tech", "place"}
138
+ assert info.child_scopes == ["/crew/support/user/kamal", "/crew/support/user/priya"]
139
+ assert info.oldest_record is not None and info.newest_record is not None
140
+ assert backend.get_scope_info("/empty").record_count == 0
141
+
142
+
143
+ def test_list_categories_counts(backend):
144
+ _seed(backend)
145
+ _wait(lambda: backend.count(), lambda n: n == 5)
146
+ assert backend.list_categories() == {"food": 2, "pref": 1, "tech": 1, "place": 1, "policy": 1}
147
+ assert backend.list_categories("/crew/support/user/kamal") == {"food": 1, "pref": 1, "tech": 1, "place": 1}
148
+
149
+
150
+ def test_list_records_newest_first_with_paging(backend):
151
+ recs = [_rec(f"r{i}", "/x", created=datetime.utcnow() - timedelta(minutes=10 - i)) for i in range(5)]
152
+ backend.save(recs)
153
+ _wait(lambda: backend.count(), lambda n: n == 5)
154
+ page1 = backend.list_records("/x", limit=2)
155
+ page2 = backend.list_records("/x", limit=2, offset=2)
156
+ assert [r.content for r in page1] == ["r4", "r3"] and [r.content for r in page2] == ["r2", "r1"]
157
+
158
+
159
+ # ── search ────────────────────────────────────────────────────────────────────
160
+
161
+
162
+ def test_search_ranks_by_similarity_with_scores(backend):
163
+ _seed(backend)
164
+ q = EMB(["sushi and japanese food"])[0]
165
+ hits = _wait(lambda: backend.search(q, scope_prefix="/crew/support/user/kamal", limit=3), lambda h: len(h) == 3)
166
+ assert hits[0][0].content.startswith("the user loves sushi")
167
+ scores = [s for _, s in hits]
168
+ assert scores == sorted(scores, reverse=True) and all(-1.0 <= s <= 1.0001 for s in scores)
169
+
170
+
171
+ def test_search_scope_prefix_covers_subtree_only(backend):
172
+ _seed(backend)
173
+ q = EMB(["sushi"])[0]
174
+ hits = _wait(lambda: backend.search(q, scope_prefix="/crew/support/user", limit=10), lambda h: len(h) == 4)
175
+ assert {r.scope for r, _ in hits} == {"/crew/support/user/kamal", "/crew/support/user/priya"}
176
+ assert all(r.scope.startswith("/crew") for r, _ in backend.search(q, scope_prefix="/crew", limit=10))
177
+ assert backend.search(q, scope_prefix="/nowhere", limit=10) == []
178
+ assert len(backend.search(q, limit=10)) == 5
179
+
180
+
181
+ def test_search_categories_metadata_and_min_score(backend):
182
+ _seed(backend)
183
+ q = EMB(["the user"])[0]
184
+ food = _wait(lambda: backend.search(q, scope_prefix="/crew", categories=["food"], limit=10), lambda h: len(h) == 2)
185
+ assert all("food" in r.categories for r, _ in food)
186
+ en = backend.search(q, scope_prefix="/crew", metadata_filter={"lang": "en"}, limit=10)
187
+ assert len(en) == 2 and all(r.metadata.get("lang") == "en" for r, _ in en)
188
+ assert backend.search(q, scope_prefix="/crew", min_score=0.999, limit=10) == []
189
+
190
+
191
+ def test_search_limit(backend):
192
+ _seed(backend)
193
+ q = EMB(["user"])[0]
194
+ assert len(_wait(lambda: backend.search(q, limit=2), lambda h: len(h) == 2)) == 2
195
+
196
+
197
+ def test_touch_records_bumps_last_accessed(backend):
198
+ r = _rec("touch me", "/t")
199
+ backend.save([r])
200
+ _wait(lambda: backend.get_record(r.id), lambda g: g is not None)
201
+ before = backend.get_record(r.id).last_accessed
202
+ time.sleep(0.01)
203
+ backend.touch_records([r.id])
204
+ after = _wait(lambda: backend.get_record(r.id).last_accessed, lambda a: a > before)
205
+ assert after > before
206
+
207
+
208
+ async def test_async_surface(backend):
209
+ r = _rec("async fact", "/async", ["a"])
210
+ await backend.asave([r])
211
+ _wait(lambda: backend.count("/async"), lambda n: n == 1)
212
+ hits = await backend.asearch(EMB(["async fact"])[0], scope_prefix="/async", limit=5)
213
+ assert hits and hits[0][0].id == r.id
214
+ assert await backend.adelete(record_ids=[r.id]) == 1
215
+
216
+
217
+ # ── end to end through CrewAI's Memory engine (no LLM) ────────────────────────
218
+
219
+
220
+ def test_crewai_memory_engine_roundtrip(backend):
221
+ from crewai.memory import Memory
222
+
223
+ mem = Memory(storage=backend, embedder=EMB, consolidation_threshold=1.0) # 1.0 disables LLM consolidation
224
+ try:
225
+ r1 = mem.remember("the user loves sushi", scope="/user/kamal", categories=["food"], importance=0.9)
226
+ r2 = mem.remember("the user writes python", scope="/user/kamal", categories=["tech"], importance=0.6)
227
+ mem.remember("weather is sunny", scope="/misc", categories=["weather"], importance=0.1)
228
+ assert r1 is not None and r2 is not None
229
+ _wait(lambda: backend.count(), lambda n: n == 3)
230
+
231
+ matches = _wait(lambda: mem.recall("what food does the user like?", scope="/user/kamal", depth="shallow", limit=2),
232
+ lambda m: len(m) == 2)
233
+ assert matches[0].record.content == "the user loves sushi"
234
+ assert 0.0 <= matches[0].score <= 1.0 and "semantic" in matches[0].match_reasons
235
+
236
+ assert mem.list_scopes("/") == ["/misc", "/user"]
237
+ assert mem.info("/user/kamal").record_count == 2
238
+ assert mem.forget(scope="/misc") == 1
239
+ assert _wait(lambda: backend.count(), lambda n: n == 2) == 2
240
+ finally:
241
+ mem.close()
242
+
243
+
244
+ __all__ = [n for n in list(globals()) if n.startswith("test_")]
@@ -0,0 +1,63 @@
1
+ """Test helpers shared by the provider suites (and handy for users' tests).
2
+
3
+ ``FakeEmbedder`` is a deterministic, dependency-free embedder with CrewAI's
4
+ embedder call signature (``list[str] -> list[list[float]]``): a hashed
5
+ bag-of-words projected into ``dims`` dimensions and L2-normalised. Texts that
6
+ share words are close; no network call is made. Pass it as
7
+ ``Memory(embedder=FakeEmbedder(64))``.
8
+
9
+ ``InMemoryBackend`` is the smallest ``MemoryBackend`` — a dict — used to test
10
+ the core machinery without any datastore.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import math
17
+ import re
18
+ from typing import Dict, List, Optional
19
+
20
+ from . import MemoryBackend, in_scope
21
+
22
+ __all__ = ["FakeEmbedder", "InMemoryBackend"]
23
+
24
+ _WORD = re.compile(r"[a-z0-9]+")
25
+
26
+
27
+ class FakeEmbedder:
28
+ def __init__(self, dims: int = 64) -> None:
29
+ self.dims = dims
30
+
31
+ def _embed(self, text: str) -> List[float]:
32
+ vec = [0.0] * self.dims
33
+ for word in _WORD.findall(text.lower()):
34
+ h = int(hashlib.md5(word.encode()).hexdigest(), 16)
35
+ vec[h % self.dims] += 1.0
36
+ norm = math.sqrt(sum(v * v for v in vec))
37
+ return [v / norm for v in vec] if norm else [1.0 / math.sqrt(self.dims)] * self.dims
38
+
39
+ def __call__(self, texts: List[str]) -> List[List[float]]:
40
+ return [self._embed(t) for t in texts]
41
+
42
+
43
+ class InMemoryBackend(MemoryBackend):
44
+ def __init__(self) -> None:
45
+ self._rows: Dict[str, dict] = {}
46
+
47
+ def _put(self, rows: List[dict]) -> None:
48
+ for r in rows:
49
+ self._rows[r["id"]] = dict(r)
50
+
51
+ def _get(self, record_id: str) -> Optional[dict]:
52
+ r = self._rows.get(record_id)
53
+ return dict(r) if r else None
54
+
55
+ def _delete_ids(self, ids: List[str]) -> int:
56
+ n = 0
57
+ for i in ids:
58
+ if self._rows.pop(i, None) is not None:
59
+ n += 1
60
+ return n
61
+
62
+ def _scan(self, scope_prefix: Optional[str]) -> List[dict]:
63
+ return [dict(r) for r in self._rows.values() if in_scope(r["scope"], scope_prefix)]
@@ -0,0 +1,17 @@
1
+ """Core contract on the dict-backed InMemoryBackend (offline)."""
2
+ import pytest
3
+
4
+ from crewai_memory_core.contract import * # noqa: F401,F403
5
+ from crewai_memory_core import in_scope, norm_scope
6
+ from crewai_memory_core.testing import InMemoryBackend
7
+
8
+
9
+ @pytest.fixture()
10
+ def backend():
11
+ return InMemoryBackend()
12
+
13
+
14
+ def test_norm_and_in_scope():
15
+ assert norm_scope(None) == "/" and norm_scope("a/b/") == "/a/b" and norm_scope("/") == "/"
16
+ assert in_scope("/a/b", "/a") and in_scope("/a", "/a") and not in_scope("/ab", "/a")
17
+ assert in_scope("/anything", "/") and in_scope("/x", None)