crewai-memory-firestore 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,55 @@
1
+ Metadata-Version: 2.5
2
+ Name: crewai-memory-firestore
3
+ Version: 0.1.0
4
+ Summary: Google Firestore StorageBackend for CrewAI unified Memory — hierarchical scopes, categories, metadata filters and native vector search (find_nearest)
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,firestore,gcp,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-memory-core>=0.1.0
16
+ Requires-Dist: google-cloud-firestore>=2.16
17
+ Description-Content-Type: text/markdown
18
+
19
+ # crewai-memory-firestore
20
+
21
+ A **Google Firestore** `StorageBackend` for [CrewAI](https://docs.crewai.com/en/concepts/memory)'s unified `Memory` — hierarchical scopes, categories, metadata filters, importance/recency, and **native vector search** via `find_nearest`.
22
+
23
+ ```bash
24
+ pip install crewai-memory-firestore
25
+ ```
26
+
27
+ ```python
28
+ from crewai import Crew
29
+ from crewai.memory import Memory
30
+ from crewai_memory_firestore import FirestoreMemoryBackend
31
+
32
+ backend = FirestoreMemoryBackend(project_id="my-project", collection="crewai_memory", dimensions=3072) # match your embedder
33
+ memory = Memory(storage=backend)
34
+ crew = Crew(agents=[...], tasks=[...], memory=memory)
35
+ ```
36
+
37
+ Or once for every `Crew(memory=True)`: `set_memory_storage_factory(lambda spec: FirestoreMemoryBackend(...))`. Authentication uses Application Default Credentials.
38
+
39
+ ## How it works
40
+
41
+ - One collection, one document per record (doc id = record id) with the embedding stored as a Firestore `Vector`.
42
+ - `search` uses native `find_nearest` (cosine) after a scope pre-filter. A scope *subtree* is two queries (the exact scope, and the `scope/…` range) merged by similarity; score is `1 - distance`. `categories` / `metadata_filter` / `min_score` are applied on the candidates.
43
+ - Firestore requires a **composite vector index** `scope ASC + embedding (flat, dims)`. With `create_index=True` (default) the backend creates it through the Admin API if missing; the build is asynchronous and vector queries fail with `FAILED_PRECONDITION` until it is READY (a few minutes). Needs `datastore.indexes.create`/`list`, or create it yourself and pass `create_index=False`:
44
+
45
+ ```bash
46
+ gcloud firestore indexes composite create --collection-group=crewai_memory --query-scope=COLLECTION \
47
+ --field-config=order=ASCENDING,field-path=scope \
48
+ --field-config=vector-config='{"dimension":"3072","flat":"{}"}',field-path=embedding
49
+ ```
50
+
51
+ Docs: <https://skamalj.github.io/agentstate-reducer/> · part of [crewai-memory](https://github.com/skamalj/crewai-memory)
52
+
53
+ ## License
54
+
55
+ MIT
@@ -0,0 +1,37 @@
1
+ # crewai-memory-firestore
2
+
3
+ A **Google Firestore** `StorageBackend` for [CrewAI](https://docs.crewai.com/en/concepts/memory)'s unified `Memory` — hierarchical scopes, categories, metadata filters, importance/recency, and **native vector search** via `find_nearest`.
4
+
5
+ ```bash
6
+ pip install crewai-memory-firestore
7
+ ```
8
+
9
+ ```python
10
+ from crewai import Crew
11
+ from crewai.memory import Memory
12
+ from crewai_memory_firestore import FirestoreMemoryBackend
13
+
14
+ backend = FirestoreMemoryBackend(project_id="my-project", collection="crewai_memory", dimensions=3072) # match your embedder
15
+ memory = Memory(storage=backend)
16
+ crew = Crew(agents=[...], tasks=[...], memory=memory)
17
+ ```
18
+
19
+ Or once for every `Crew(memory=True)`: `set_memory_storage_factory(lambda spec: FirestoreMemoryBackend(...))`. Authentication uses Application Default Credentials.
20
+
21
+ ## How it works
22
+
23
+ - One collection, one document per record (doc id = record id) with the embedding stored as a Firestore `Vector`.
24
+ - `search` uses native `find_nearest` (cosine) after a scope pre-filter. A scope *subtree* is two queries (the exact scope, and the `scope/…` range) merged by similarity; score is `1 - distance`. `categories` / `metadata_filter` / `min_score` are applied on the candidates.
25
+ - Firestore requires a **composite vector index** `scope ASC + embedding (flat, dims)`. With `create_index=True` (default) the backend creates it through the Admin API if missing; the build is asynchronous and vector queries fail with `FAILED_PRECONDITION` until it is READY (a few minutes). Needs `datastore.indexes.create`/`list`, or create it yourself and pass `create_index=False`:
26
+
27
+ ```bash
28
+ gcloud firestore indexes composite create --collection-group=crewai_memory --query-scope=COLLECTION \
29
+ --field-config=order=ASCENDING,field-path=scope \
30
+ --field-config=vector-config='{"dimension":"3072","flat":"{}"}',field-path=embedding
31
+ ```
32
+
33
+ Docs: <https://skamalj.github.io/agentstate-reducer/> · part of [crewai-memory](https://github.com/skamalj/crewai-memory)
34
+
35
+ ## License
36
+
37
+ MIT
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "crewai-memory-firestore"
7
+ version = "0.1.0"
8
+ description = "Google Firestore StorageBackend for CrewAI unified Memory — hierarchical scopes, categories, metadata filters and native vector search (find_nearest)"
9
+ authors = [{name = "Kamal", email = "skamalj@gmail.com"}]
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "crewai-memory-core>=0.1.0",
14
+ "google-cloud-firestore>=2.16",
15
+ ]
16
+ keywords = ["crewai", "memory", "storage-backend", "firestore", "gcp", "long-term-memory", "agent-memory", "vector-search"]
17
+ classifiers = [
18
+ "Programming Language :: Python :: 3",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: OS Independent",
21
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/skamalj/crewai-memory"
26
+ Repository = "https://github.com/skamalj/crewai-memory.git"
27
+ Documentation = "https://skamalj.github.io/agentstate-reducer/"
28
+
29
+ [dependency-groups]
30
+ dev = ["pytest>=7.0", "pytest-asyncio"]
31
+
32
+ [tool.pytest.ini_options]
33
+ asyncio_mode = "auto"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/crewai_memory_firestore"]
@@ -0,0 +1,166 @@
1
+ """Google Firestore ``StorageBackend`` for CrewAI unified ``Memory``.
2
+
3
+ One collection, one document per ``MemoryRecord`` (doc id = record id) with the
4
+ embedding stored as a Firestore ``Vector``. ``search`` uses native
5
+ ``find_nearest`` (cosine) after a scope pre-filter; a scope *subtree* needs two
6
+ queries (the exact scope, and the ``scope/…`` range), merged by similarity.
7
+ Built on ``crewai-memory-core``.
8
+
9
+ Firestore needs a composite **vector index** ``scope ASC + embedding (flat)``;
10
+ with ``create_index=True`` (default) it is created through the Admin API if
11
+ missing (asynchronous build — vector queries fail with FAILED_PRECONDITION
12
+ until READY).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import List, Optional, Tuple
19
+
20
+ from google.cloud import firestore
21
+ from google.cloud.firestore_v1.base_query import FieldFilter
22
+ from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
23
+ from google.cloud.firestore_v1.vector import Vector
24
+
25
+ from crewai_memory_core import MemoryBackend, ROW_FIELDS, norm_scope
26
+
27
+ __all__ = ["FirestoreMemoryBackend"]
28
+
29
+ logger = logging.getLogger(__name__)
30
+ _HIGH = chr(0xF8FF)
31
+
32
+
33
+ class FirestoreMemoryBackend(MemoryBackend):
34
+ """CrewAI ``StorageBackend`` on Firestore with native vector search.
35
+
36
+ Example:
37
+ ```python
38
+ from crewai.memory import Memory
39
+ from crewai_memory_firestore import FirestoreMemoryBackend
40
+
41
+ backend = FirestoreMemoryBackend(project_id="my-project", collection="crewai_memory", dimensions=3072)
42
+ memory = Memory(storage=backend)
43
+ ```
44
+ """
45
+
46
+ supports_native_vector_search = True
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ project_id: str,
52
+ collection: str = "crewai_memory",
53
+ dimensions: int = 3072,
54
+ client: Optional["firestore.Client"] = None,
55
+ create_index: bool = True,
56
+ database: str = "(default)",
57
+ ) -> None:
58
+ self.dimensions = dimensions
59
+ self._project = project_id
60
+ self._database = database
61
+ self._collection = collection
62
+ self._client = client or firestore.Client(project=project_id, database=database)
63
+ self._col = self._client.collection(collection)
64
+ if create_index:
65
+ self._ensure_vector_index()
66
+
67
+ # ------------------------------------------------------------------ index bootstrap
68
+ def _ensure_vector_index(self) -> None:
69
+ from google.cloud import firestore_admin_v1 as fa
70
+
71
+ admin = fa.FirestoreAdminClient()
72
+ parent = f"projects/{self._project}/databases/{self._database}/collectionGroups/{self._collection}"
73
+ for ix in admin.list_indexes(parent=parent):
74
+ paths = [f.field_path for f in ix.fields]
75
+ if "embedding" in paths and "scope" in paths:
76
+ return
77
+ index = fa.Index(
78
+ query_scope=fa.Index.QueryScope.COLLECTION,
79
+ fields=[
80
+ fa.Index.IndexField(field_path="scope", order=fa.Index.IndexField.Order.ASCENDING),
81
+ fa.Index.IndexField(
82
+ field_path="embedding",
83
+ vector_config=fa.Index.IndexField.VectorConfig(
84
+ dimension=self.dimensions, flat=fa.Index.IndexField.VectorConfig.FlatIndex()
85
+ ),
86
+ ),
87
+ ],
88
+ )
89
+ try:
90
+ admin.create_index(parent=parent, index=index)
91
+ logger.info("FirestoreMemoryBackend: creating vector index on %s (async)", self._collection)
92
+ except Exception as exc: # already exists / racing creation
93
+ logger.info("FirestoreMemoryBackend: vector index create skipped: %s", exc)
94
+
95
+ # ------------------------------------------------------------------ helpers
96
+ @staticmethod
97
+ def _row(d: dict) -> dict:
98
+ row = {f: d.get(f) for f in ROW_FIELDS}
99
+ row["scope"] = norm_scope(d.get("scope"))
100
+ emb = d.get("embedding")
101
+ row["embedding"] = list(emb.to_map_value()["value"]) if isinstance(emb, Vector) else (list(emb) if emb else None)
102
+ return row
103
+
104
+ def _scope_queries(self, scope_prefix: Optional[str]):
105
+ """Firestore has no OR-with-range on one field, so a subtree is two queries."""
106
+ p = norm_scope(scope_prefix)
107
+ if p == "/":
108
+ # Every scope starts with "/", so a range on `scope` selects all rows while
109
+ # still using the scope+embedding composite index (a filter-less
110
+ # find_nearest would need a separate embedding-only index).
111
+ return [self._col.where(filter=FieldFilter("scope", ">=", "/"))]
112
+ return [
113
+ self._col.where(filter=FieldFilter("scope", "==", p)),
114
+ self._col.where(filter=FieldFilter("scope", ">=", p + "/")).where(filter=FieldFilter("scope", "<", p + "/" + _HIGH)),
115
+ ]
116
+
117
+ # ------------------------------------------------------------------ primitives
118
+ def _put(self, rows: List[dict]) -> None:
119
+ batch = self._client.batch()
120
+ for row in rows:
121
+ doc = {f: row.get(f) for f in ROW_FIELDS if f != "embedding"}
122
+ doc["scope"] = norm_scope(row["scope"])
123
+ doc["categories"] = list(row.get("categories") or [])
124
+ doc["metadata"] = dict(row.get("metadata") or {})
125
+ doc["private"] = bool(row.get("private", False))
126
+ if row.get("embedding"):
127
+ doc["embedding"] = Vector(row["embedding"])
128
+ batch.set(self._col.document(row["id"]), doc)
129
+ batch.commit()
130
+
131
+ def _get(self, record_id: str) -> Optional[dict]:
132
+ snap = self._col.document(record_id).get()
133
+ return self._row(snap.to_dict()) if snap.exists else None
134
+
135
+ def _delete_ids(self, ids: List[str]) -> int:
136
+ n = 0
137
+ batch = self._client.batch()
138
+ for rid in ids:
139
+ ref = self._col.document(rid)
140
+ if ref.get().exists:
141
+ batch.delete(ref)
142
+ n += 1
143
+ batch.commit()
144
+ return n
145
+
146
+ def _scan(self, scope_prefix: Optional[str]) -> List[dict]:
147
+ rows: List[dict] = []
148
+ for q in self._scope_queries(scope_prefix):
149
+ rows.extend(self._row(d.to_dict()) for d in q.stream())
150
+ return rows
151
+
152
+ def _vector_search(
153
+ self, vector: List[float], scope_prefix: Optional[str], limit: int
154
+ ) -> List[Tuple[dict, float]]:
155
+ out: List[Tuple[dict, float]] = []
156
+ for q in self._scope_queries(scope_prefix):
157
+ vq = q.find_nearest(
158
+ vector_field="embedding", query_vector=Vector(vector),
159
+ distance_measure=DistanceMeasure.COSINE, limit=limit, distance_result_field="_distance",
160
+ )
161
+ for d in vq.stream():
162
+ data = d.to_dict()
163
+ dist = data.pop("_distance", None)
164
+ out.append((self._row(data), 1.0 - float(dist) if dist is not None else 0.0))
165
+ out.sort(key=lambda rs: -rs[1])
166
+ return out[:limit]
@@ -0,0 +1,68 @@
1
+ """Firestore StorageBackend: the shared contract suite against a real Firestore database.
2
+
3
+ The backend creates the composite vector index (scope ASC + embedding flat) via
4
+ the Admin API; index builds are asynchronous so the fixture waits for READY and
5
+ the collection name is fixed per DIMS so the index is reused across runs.
6
+ """
7
+ import os
8
+ import time
9
+
10
+ import pytest
11
+
12
+ from crewai_memory_core.contract import * # noqa: F401,F403
13
+ from crewai_memory_core.contract import DIMS
14
+ from crewai_memory_firestore import FirestoreMemoryBackend
15
+
16
+ PROJECT = os.environ.get("GCP_PROJECT") or os.environ.get("GOOGLE_CLOUD_PROJECT") or "gcdeveloper-new"
17
+ COLLECTION = f"crewai_mem_test_d{DIMS}"
18
+
19
+
20
+ def _available() -> bool:
21
+ try:
22
+ from google.cloud import firestore
23
+ firestore.Client(project=PROJECT).collection("x").limit(1).get()
24
+ return True
25
+ except Exception:
26
+ return False
27
+
28
+
29
+ pytestmark = pytest.mark.skipif(not _available(), reason="Firestore not reachable")
30
+
31
+
32
+ def _wait_index_ready(timeout: int = 600) -> None:
33
+ from google.cloud import firestore_admin_v1 as fa
34
+ admin = fa.FirestoreAdminClient()
35
+ parent = f"projects/{PROJECT}/databases/(default)/collectionGroups/{COLLECTION}"
36
+ deadline = time.time() + timeout
37
+ while time.time() < deadline:
38
+ for ix in admin.list_indexes(parent=parent):
39
+ paths = [f.field_path for f in ix.fields]
40
+ if "embedding" in paths and "scope" in paths and ix.state == fa.Index.State.READY:
41
+ return
42
+ time.sleep(10)
43
+ raise RuntimeError("vector index not READY in time")
44
+
45
+
46
+ @pytest.fixture(scope="module")
47
+ def _store():
48
+ b = FirestoreMemoryBackend(project_id=PROJECT, collection=COLLECTION, dimensions=DIMS)
49
+ _wait_index_ready()
50
+ yield b
51
+ b.reset()
52
+
53
+
54
+ @pytest.fixture()
55
+ def backend(_store):
56
+ _store.reset()
57
+ yield _store
58
+ _store.reset()
59
+
60
+
61
+ def test_native_vector_path_used(backend, monkeypatch):
62
+ from crewai_memory_core.contract import _seed, EMB
63
+ _seed(backend)
64
+ called = []
65
+ orig = backend._vector_search
66
+ monkeypatch.setattr(backend, "_vector_search", lambda *a, **k: (called.append(1), orig(*a, **k))[1])
67
+ assert backend.search(EMB(["sushi"])[0], scope_prefix="/crew", limit=5)
68
+ assert called