pycontextdb 0.1.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.
- contextdb/__init__.py +72 -0
- contextdb/agents/__init__.py +8 -0
- contextdb/agents/memory_bus.py +80 -0
- contextdb/agents/rl_manager.py +89 -0
- contextdb/cli.py +107 -0
- contextdb/client.py +516 -0
- contextdb/core/__init__.py +41 -0
- contextdb/core/config.py +89 -0
- contextdb/core/exceptions.py +29 -0
- contextdb/core/models.py +151 -0
- contextdb/dynamics/__init__.py +25 -0
- contextdb/dynamics/evolution.py +168 -0
- contextdb/dynamics/formation.py +193 -0
- contextdb/dynamics/retrieval.py +130 -0
- contextdb/graphs/__init__.py +17 -0
- contextdb/graphs/base.py +46 -0
- contextdb/graphs/causal.py +224 -0
- contextdb/graphs/entity.py +251 -0
- contextdb/graphs/semantic.py +156 -0
- contextdb/graphs/temporal.py +173 -0
- contextdb/integrations/__init__.py +10 -0
- contextdb/integrations/autogen.py +39 -0
- contextdb/integrations/crewai.py +41 -0
- contextdb/integrations/langchain.py +132 -0
- contextdb/integrations/openai_tools.py +124 -0
- contextdb/memory/__init__.py +9 -0
- contextdb/memory/experiential.py +102 -0
- contextdb/memory/factual.py +58 -0
- contextdb/memory/working.py +90 -0
- contextdb/privacy/__init__.py +9 -0
- contextdb/privacy/audit.py +199 -0
- contextdb/privacy/pii_detector.py +173 -0
- contextdb/privacy/retention.py +99 -0
- contextdb/py.typed +0 -0
- contextdb/store/__init__.py +15 -0
- contextdb/store/base.py +67 -0
- contextdb/store/sqlite_store.py +517 -0
- contextdb/store/vector_index.py +241 -0
- contextdb/utils/__init__.py +22 -0
- contextdb/utils/embeddings.py +159 -0
- contextdb/utils/llm.py +139 -0
- contextdb/utils/migrations.py +159 -0
- pycontextdb-0.1.0.dist-info/METADATA +589 -0
- pycontextdb-0.1.0.dist-info/RECORD +47 -0
- pycontextdb-0.1.0.dist-info/WHEEL +4 -0
- pycontextdb-0.1.0.dist-info/entry_points.txt +2 -0
- pycontextdb-0.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""SQLite-backed implementation of :class:`BaseStore`.
|
|
2
|
+
|
|
3
|
+
Uses ``aiosqlite`` for async SQLite access. Vectors are stored as float32
|
|
4
|
+
``BLOB`` columns on the memory row, and mirrored into a :class:`VectorIndex`
|
|
5
|
+
for fast similarity search. The index is rebuilt on first use if missing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import aiosqlite
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
from contextdb.core.exceptions import MemoryNotFoundError, StorageError
|
|
20
|
+
from contextdb.core.models import (
|
|
21
|
+
MemoryItem,
|
|
22
|
+
MemoryStatus,
|
|
23
|
+
MemoryType,
|
|
24
|
+
PIIAnnotation,
|
|
25
|
+
RetentionPolicy,
|
|
26
|
+
)
|
|
27
|
+
from contextdb.store.base import BaseStore
|
|
28
|
+
from contextdb.store.vector_index import VectorIndex, get_vector_index
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from collections.abc import AsyncIterator, Mapping
|
|
32
|
+
|
|
33
|
+
SCHEMA = """
|
|
34
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
content TEXT NOT NULL,
|
|
37
|
+
embedding BLOB,
|
|
38
|
+
embedding_dim INTEGER,
|
|
39
|
+
memory_type TEXT NOT NULL DEFAULT 'FACTUAL',
|
|
40
|
+
source TEXT DEFAULT '',
|
|
41
|
+
metadata TEXT DEFAULT '{}',
|
|
42
|
+
user_id TEXT,
|
|
43
|
+
event_time TEXT,
|
|
44
|
+
ingestion_time TEXT NOT NULL,
|
|
45
|
+
pii_annotations TEXT DEFAULT '[]',
|
|
46
|
+
retention_policy TEXT,
|
|
47
|
+
created_at TEXT NOT NULL,
|
|
48
|
+
updated_at TEXT NOT NULL,
|
|
49
|
+
access_count INTEGER DEFAULT 0,
|
|
50
|
+
last_accessed TEXT,
|
|
51
|
+
confidence REAL DEFAULT 1.0,
|
|
52
|
+
status TEXT DEFAULT 'ACTIVE',
|
|
53
|
+
entity_mentions TEXT DEFAULT '[]',
|
|
54
|
+
tags TEXT DEFAULT '[]'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_memories_user_id ON memories(user_id);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(memory_type);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_storage_url(url: str) -> str:
|
|
65
|
+
"""Extract a filesystem path from a sqlite://[/...] URL."""
|
|
66
|
+
prefix = "sqlite:///"
|
|
67
|
+
if url.startswith(prefix):
|
|
68
|
+
return url[len(prefix) :] or ":memory:"
|
|
69
|
+
if url == "sqlite://:memory:" or url == "sqlite://":
|
|
70
|
+
return ":memory:"
|
|
71
|
+
return url
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _embedding_to_blob(embedding: list[float] | None) -> tuple[bytes | None, int | None]:
|
|
75
|
+
if embedding is None:
|
|
76
|
+
return None, None
|
|
77
|
+
arr = np.asarray(embedding, dtype=np.float32)
|
|
78
|
+
return arr.tobytes(), len(embedding)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _blob_to_embedding(blob: bytes | None) -> list[float] | None:
|
|
82
|
+
if blob is None:
|
|
83
|
+
return None
|
|
84
|
+
arr: NDArray[np.float32] = np.frombuffer(blob, dtype=np.float32)
|
|
85
|
+
return [float(x) for x in arr]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _row_to_item(row: Mapping[str, Any]) -> MemoryItem:
|
|
89
|
+
retention_raw = row["retention_policy"]
|
|
90
|
+
retention = (
|
|
91
|
+
RetentionPolicy.model_validate_json(retention_raw) if retention_raw else None
|
|
92
|
+
)
|
|
93
|
+
pii = [PIIAnnotation.model_validate(a) for a in json.loads(row["pii_annotations"] or "[]")]
|
|
94
|
+
return MemoryItem(
|
|
95
|
+
id=row["id"],
|
|
96
|
+
content=row["content"],
|
|
97
|
+
embedding=_blob_to_embedding(row["embedding"]),
|
|
98
|
+
memory_type=MemoryType(row["memory_type"]),
|
|
99
|
+
source=row["source"] or "",
|
|
100
|
+
metadata=json.loads(row["metadata"] or "{}"),
|
|
101
|
+
event_time=datetime.fromisoformat(row["event_time"]) if row["event_time"] else None,
|
|
102
|
+
ingestion_time=datetime.fromisoformat(row["ingestion_time"]),
|
|
103
|
+
pii_annotations=pii,
|
|
104
|
+
retention_policy=retention,
|
|
105
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
106
|
+
updated_at=datetime.fromisoformat(row["updated_at"]),
|
|
107
|
+
access_count=int(row["access_count"]),
|
|
108
|
+
last_accessed=(
|
|
109
|
+
datetime.fromisoformat(row["last_accessed"]) if row["last_accessed"] else None
|
|
110
|
+
),
|
|
111
|
+
confidence=float(row["confidence"]),
|
|
112
|
+
status=MemoryStatus(row["status"]),
|
|
113
|
+
entity_mentions=json.loads(row["entity_mentions"] or "[]"),
|
|
114
|
+
tags=json.loads(row["tags"] or "[]"),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SQLiteStore(BaseStore):
|
|
119
|
+
"""Durable, async SQLite store with in-memory vector index cache.
|
|
120
|
+
|
|
121
|
+
Single-process concurrency: writes are serialized through an
|
|
122
|
+
:class:`asyncio.Lock` while reads run freely. WAL journaling is enabled
|
|
123
|
+
so concurrent readers never block a writer. For multi-process access
|
|
124
|
+
use the PostgreSQL backend instead — SQLite's file-level locking does
|
|
125
|
+
not guarantee safety across processes even with WAL.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
storage_url: str = "sqlite:///contextdb.db",
|
|
131
|
+
user_id: str | None = None,
|
|
132
|
+
vector_index: VectorIndex | None = None,
|
|
133
|
+
embedding_dim: int = 1536,
|
|
134
|
+
) -> None:
|
|
135
|
+
self._path = _parse_storage_url(storage_url)
|
|
136
|
+
self._user_id = user_id
|
|
137
|
+
self._conn: aiosqlite.Connection | None = None
|
|
138
|
+
self._index: VectorIndex | None = vector_index
|
|
139
|
+
self._embedding_dim = embedding_dim
|
|
140
|
+
self._index_loaded = False
|
|
141
|
+
self._write_lock: asyncio.Lock = asyncio.Lock()
|
|
142
|
+
|
|
143
|
+
async def initialize(self) -> None:
|
|
144
|
+
if self._conn is not None:
|
|
145
|
+
return
|
|
146
|
+
self._conn = await aiosqlite.connect(self._path)
|
|
147
|
+
self._conn.row_factory = aiosqlite.Row
|
|
148
|
+
# WAL lets readers proceed while a writer holds the reserved lock; the
|
|
149
|
+
# busy timeout absorbs short contention windows instead of raising
|
|
150
|
+
# SQLITE_BUSY. Both are safe to re-execute on reconnect.
|
|
151
|
+
await self._conn.execute("PRAGMA journal_mode=WAL")
|
|
152
|
+
await self._conn.execute("PRAGMA busy_timeout=5000")
|
|
153
|
+
await self._conn.executescript(SCHEMA)
|
|
154
|
+
await self._conn.commit()
|
|
155
|
+
|
|
156
|
+
def _require_conn(self) -> aiosqlite.Connection:
|
|
157
|
+
if self._conn is None:
|
|
158
|
+
raise StorageError("SQLiteStore is not initialized. Call initialize() first.")
|
|
159
|
+
return self._conn
|
|
160
|
+
|
|
161
|
+
async def _ensure_index(self) -> VectorIndex:
|
|
162
|
+
if self._index is None:
|
|
163
|
+
self._index = get_vector_index(self._embedding_dim)
|
|
164
|
+
if not self._index_loaded:
|
|
165
|
+
conn = self._require_conn()
|
|
166
|
+
cursor = await conn.execute(
|
|
167
|
+
"SELECT id, embedding, embedding_dim FROM memories "
|
|
168
|
+
"WHERE embedding IS NOT NULL AND status = 'ACTIVE'"
|
|
169
|
+
)
|
|
170
|
+
rows = await cursor.fetchall()
|
|
171
|
+
if rows:
|
|
172
|
+
ids = [row["id"] for row in rows]
|
|
173
|
+
vectors = np.stack(
|
|
174
|
+
[np.frombuffer(row["embedding"], dtype=np.float32) for row in rows],
|
|
175
|
+
axis=0,
|
|
176
|
+
)
|
|
177
|
+
self._index.add(ids, vectors)
|
|
178
|
+
self._index_loaded = True
|
|
179
|
+
return self._index
|
|
180
|
+
|
|
181
|
+
async def add(self, item: MemoryItem) -> MemoryItem:
|
|
182
|
+
conn = self._require_conn()
|
|
183
|
+
blob, dim = _embedding_to_blob(item.embedding)
|
|
184
|
+
async with self._write_lock:
|
|
185
|
+
await conn.execute(
|
|
186
|
+
"""
|
|
187
|
+
INSERT INTO memories (
|
|
188
|
+
id, content, embedding, embedding_dim, memory_type, source,
|
|
189
|
+
metadata, user_id, event_time, ingestion_time, pii_annotations,
|
|
190
|
+
retention_policy, created_at, updated_at, access_count, last_accessed,
|
|
191
|
+
confidence, status, entity_mentions, tags
|
|
192
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
193
|
+
""",
|
|
194
|
+
(
|
|
195
|
+
item.id,
|
|
196
|
+
item.content,
|
|
197
|
+
blob,
|
|
198
|
+
dim,
|
|
199
|
+
item.memory_type.value,
|
|
200
|
+
item.source,
|
|
201
|
+
json.dumps(item.metadata),
|
|
202
|
+
self._user_id,
|
|
203
|
+
item.event_time.isoformat() if item.event_time else None,
|
|
204
|
+
item.ingestion_time.isoformat(),
|
|
205
|
+
json.dumps([a.model_dump(mode="json") for a in item.pii_annotations]),
|
|
206
|
+
item.retention_policy.model_dump_json() if item.retention_policy else None,
|
|
207
|
+
item.created_at.isoformat(),
|
|
208
|
+
item.updated_at.isoformat(),
|
|
209
|
+
item.access_count,
|
|
210
|
+
item.last_accessed.isoformat() if item.last_accessed else None,
|
|
211
|
+
item.confidence,
|
|
212
|
+
item.status.value,
|
|
213
|
+
json.dumps(item.entity_mentions),
|
|
214
|
+
json.dumps(item.tags),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
await conn.commit()
|
|
218
|
+
if item.embedding is not None:
|
|
219
|
+
index = await self._ensure_index()
|
|
220
|
+
index.add([item.id], np.asarray([item.embedding], dtype=np.float32))
|
|
221
|
+
return item
|
|
222
|
+
|
|
223
|
+
async def get(self, memory_id: str) -> MemoryItem | None:
|
|
224
|
+
conn = self._require_conn()
|
|
225
|
+
cursor = await conn.execute("SELECT * FROM memories WHERE id = ?", (memory_id,))
|
|
226
|
+
row = await cursor.fetchone()
|
|
227
|
+
if row is None:
|
|
228
|
+
return None
|
|
229
|
+
item = _row_to_item(dict(row))
|
|
230
|
+
now_iso = datetime.now(tz=item.ingestion_time.tzinfo).isoformat()
|
|
231
|
+
await conn.execute(
|
|
232
|
+
"UPDATE memories SET access_count = access_count + 1, last_accessed = ? WHERE id = ?",
|
|
233
|
+
(now_iso, memory_id),
|
|
234
|
+
)
|
|
235
|
+
await conn.commit()
|
|
236
|
+
return item
|
|
237
|
+
|
|
238
|
+
async def update(self, memory_id: str, **kwargs: object) -> MemoryItem:
|
|
239
|
+
conn = self._require_conn()
|
|
240
|
+
current = await self.get_raw(memory_id)
|
|
241
|
+
if current is None:
|
|
242
|
+
raise MemoryNotFoundError(memory_id)
|
|
243
|
+
|
|
244
|
+
allowed = {
|
|
245
|
+
"content",
|
|
246
|
+
"embedding",
|
|
247
|
+
"metadata",
|
|
248
|
+
"status",
|
|
249
|
+
"source",
|
|
250
|
+
"confidence",
|
|
251
|
+
"pii_annotations",
|
|
252
|
+
"entity_mentions",
|
|
253
|
+
"tags",
|
|
254
|
+
"event_time",
|
|
255
|
+
"memory_type",
|
|
256
|
+
"access_count",
|
|
257
|
+
"last_accessed",
|
|
258
|
+
}
|
|
259
|
+
unknown = set(kwargs) - allowed
|
|
260
|
+
if unknown:
|
|
261
|
+
raise ValueError(f"Unknown update fields: {unknown}")
|
|
262
|
+
|
|
263
|
+
sets: list[str] = []
|
|
264
|
+
params: list[Any] = []
|
|
265
|
+
for k, v in kwargs.items():
|
|
266
|
+
if k == "embedding":
|
|
267
|
+
blob, dim = _embedding_to_blob(v) # type: ignore[arg-type]
|
|
268
|
+
sets.append("embedding = ?")
|
|
269
|
+
sets.append("embedding_dim = ?")
|
|
270
|
+
params.extend([blob, dim])
|
|
271
|
+
elif k == "metadata":
|
|
272
|
+
sets.append("metadata = ?")
|
|
273
|
+
params.append(json.dumps(v))
|
|
274
|
+
elif k == "pii_annotations":
|
|
275
|
+
sets.append("pii_annotations = ?")
|
|
276
|
+
assert isinstance(v, list)
|
|
277
|
+
params.append(
|
|
278
|
+
json.dumps([a.model_dump(mode="json") for a in v])
|
|
279
|
+
)
|
|
280
|
+
elif k == "entity_mentions" or k == "tags":
|
|
281
|
+
sets.append(f"{k} = ?")
|
|
282
|
+
params.append(json.dumps(v))
|
|
283
|
+
elif k == "memory_type":
|
|
284
|
+
sets.append("memory_type = ?")
|
|
285
|
+
params.append(v.value if isinstance(v, MemoryType) else str(v))
|
|
286
|
+
elif k == "status":
|
|
287
|
+
sets.append("status = ?")
|
|
288
|
+
params.append(v.value if isinstance(v, MemoryStatus) else str(v))
|
|
289
|
+
elif k == "event_time" or k == "last_accessed":
|
|
290
|
+
sets.append(f"{k} = ?")
|
|
291
|
+
params.append(v.isoformat() if isinstance(v, datetime) else v)
|
|
292
|
+
else:
|
|
293
|
+
sets.append(f"{k} = ?")
|
|
294
|
+
params.append(v)
|
|
295
|
+
|
|
296
|
+
sets.append("updated_at = ?")
|
|
297
|
+
now = datetime.now(tz=current.updated_at.tzinfo)
|
|
298
|
+
params.append(now.isoformat())
|
|
299
|
+
params.append(memory_id)
|
|
300
|
+
|
|
301
|
+
async with self._write_lock:
|
|
302
|
+
await conn.execute(
|
|
303
|
+
f"UPDATE memories SET {', '.join(sets)} WHERE id = ?", params
|
|
304
|
+
)
|
|
305
|
+
await conn.commit()
|
|
306
|
+
|
|
307
|
+
if "embedding" in kwargs:
|
|
308
|
+
index = await self._ensure_index()
|
|
309
|
+
index.remove([memory_id])
|
|
310
|
+
if kwargs["embedding"] is not None:
|
|
311
|
+
index.add(
|
|
312
|
+
[memory_id],
|
|
313
|
+
np.asarray([kwargs["embedding"]], dtype=np.float32),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
refreshed = await self.get_raw(memory_id)
|
|
317
|
+
assert refreshed is not None
|
|
318
|
+
return refreshed
|
|
319
|
+
|
|
320
|
+
async def get_raw(self, memory_id: str) -> MemoryItem | None:
|
|
321
|
+
"""Fetch without side effects (no access counter bump)."""
|
|
322
|
+
conn = self._require_conn()
|
|
323
|
+
cursor = await conn.execute("SELECT * FROM memories WHERE id = ?", (memory_id,))
|
|
324
|
+
row = await cursor.fetchone()
|
|
325
|
+
return _row_to_item(dict(row)) if row else None
|
|
326
|
+
|
|
327
|
+
async def delete(self, memory_id: str, hard: bool = False) -> None:
|
|
328
|
+
conn = self._require_conn()
|
|
329
|
+
async with self._write_lock:
|
|
330
|
+
if hard:
|
|
331
|
+
await conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
|
|
332
|
+
else:
|
|
333
|
+
now = datetime.now(tz=timezone.utc).isoformat()
|
|
334
|
+
await conn.execute(
|
|
335
|
+
"UPDATE memories SET status = ?, updated_at = ? WHERE id = ?",
|
|
336
|
+
(MemoryStatus.DELETED.value, now, memory_id),
|
|
337
|
+
)
|
|
338
|
+
await conn.commit()
|
|
339
|
+
if self._index is not None and self._index_loaded:
|
|
340
|
+
self._index.remove([memory_id])
|
|
341
|
+
|
|
342
|
+
async def search_by_embedding(
|
|
343
|
+
self,
|
|
344
|
+
embedding: list[float],
|
|
345
|
+
top_k: int = 10,
|
|
346
|
+
filters: dict[str, object] | None = None,
|
|
347
|
+
) -> list[MemoryItem]:
|
|
348
|
+
conn = self._require_conn()
|
|
349
|
+
index = await self._ensure_index()
|
|
350
|
+
query = np.asarray(embedding, dtype=np.float32)
|
|
351
|
+
# Fetch extra to allow for filter culling.
|
|
352
|
+
raw = index.search(query, top_k=top_k * 3 if filters else top_k)
|
|
353
|
+
if not raw:
|
|
354
|
+
return []
|
|
355
|
+
|
|
356
|
+
ids = [mid for mid, _ in raw]
|
|
357
|
+
placeholders = ",".join(["?"] * len(ids))
|
|
358
|
+
cursor = await conn.execute(
|
|
359
|
+
f"SELECT * FROM memories WHERE id IN ({placeholders})", ids
|
|
360
|
+
)
|
|
361
|
+
rows = await cursor.fetchall()
|
|
362
|
+
items_by_id = {row["id"]: _row_to_item(dict(row)) for row in rows}
|
|
363
|
+
|
|
364
|
+
results: list[MemoryItem] = []
|
|
365
|
+
for mid, _ in raw:
|
|
366
|
+
item = items_by_id.get(mid)
|
|
367
|
+
if item is None or item.status != MemoryStatus.ACTIVE:
|
|
368
|
+
continue
|
|
369
|
+
if filters and not _passes_filters(item, filters):
|
|
370
|
+
continue
|
|
371
|
+
results.append(item)
|
|
372
|
+
if len(results) >= top_k:
|
|
373
|
+
break
|
|
374
|
+
return results
|
|
375
|
+
|
|
376
|
+
async def list_memories(
|
|
377
|
+
self,
|
|
378
|
+
user_id: str | None = None,
|
|
379
|
+
memory_type: MemoryType | None = None,
|
|
380
|
+
status: MemoryStatus = MemoryStatus.ACTIVE,
|
|
381
|
+
limit: int = 100,
|
|
382
|
+
offset: int = 0,
|
|
383
|
+
) -> list[MemoryItem]:
|
|
384
|
+
conn = self._require_conn()
|
|
385
|
+
clauses: list[str] = ["status = ?"]
|
|
386
|
+
params: list[Any] = [status.value]
|
|
387
|
+
if user_id is not None:
|
|
388
|
+
clauses.append("user_id = ?")
|
|
389
|
+
params.append(user_id)
|
|
390
|
+
if memory_type is not None:
|
|
391
|
+
clauses.append("memory_type = ?")
|
|
392
|
+
params.append(memory_type.value)
|
|
393
|
+
where = " AND ".join(clauses)
|
|
394
|
+
params.extend([limit, offset])
|
|
395
|
+
cursor = await conn.execute(
|
|
396
|
+
f"SELECT * FROM memories WHERE {where} "
|
|
397
|
+
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
398
|
+
params,
|
|
399
|
+
)
|
|
400
|
+
rows = await cursor.fetchall()
|
|
401
|
+
return [_row_to_item(dict(row)) for row in rows]
|
|
402
|
+
|
|
403
|
+
async def count(self, user_id: str | None = None) -> int:
|
|
404
|
+
conn = self._require_conn()
|
|
405
|
+
if user_id is None:
|
|
406
|
+
cursor = await conn.execute(
|
|
407
|
+
"SELECT COUNT(*) FROM memories WHERE status = 'ACTIVE'"
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
cursor = await conn.execute(
|
|
411
|
+
"SELECT COUNT(*) FROM memories WHERE status = 'ACTIVE' AND user_id = ?",
|
|
412
|
+
(user_id,),
|
|
413
|
+
)
|
|
414
|
+
row = await cursor.fetchone()
|
|
415
|
+
return int(row[0]) if row else 0
|
|
416
|
+
|
|
417
|
+
async def count_by_type(self, user_id: str | None = None) -> dict[str, int]:
|
|
418
|
+
"""Return active-memory counts bucketed by :class:`MemoryType`.
|
|
419
|
+
|
|
420
|
+
Single aggregate SQL query — does not load rows into memory.
|
|
421
|
+
"""
|
|
422
|
+
conn = self._require_conn()
|
|
423
|
+
params: list[Any] = []
|
|
424
|
+
sql = "SELECT memory_type, COUNT(*) FROM memories WHERE status = 'ACTIVE'"
|
|
425
|
+
if user_id is not None:
|
|
426
|
+
sql += " AND user_id = ?"
|
|
427
|
+
params.append(user_id)
|
|
428
|
+
sql += " GROUP BY memory_type"
|
|
429
|
+
cursor = await conn.execute(sql, params)
|
|
430
|
+
rows = await cursor.fetchall()
|
|
431
|
+
counts: dict[str, int] = {mt.value: 0 for mt in MemoryType}
|
|
432
|
+
for row in rows:
|
|
433
|
+
counts[str(row[0])] = int(row[1])
|
|
434
|
+
return counts
|
|
435
|
+
|
|
436
|
+
async def iter_memories(
|
|
437
|
+
self,
|
|
438
|
+
user_id: str | None = None,
|
|
439
|
+
memory_type: MemoryType | None = None,
|
|
440
|
+
status: MemoryStatus = MemoryStatus.ACTIVE,
|
|
441
|
+
batch_size: int = 500,
|
|
442
|
+
) -> AsyncIterator[MemoryItem]:
|
|
443
|
+
"""Stream memories in fixed-size pages to bound peak memory."""
|
|
444
|
+
offset = 0
|
|
445
|
+
while True:
|
|
446
|
+
page = await self.list_memories(
|
|
447
|
+
user_id=user_id,
|
|
448
|
+
memory_type=memory_type,
|
|
449
|
+
status=status,
|
|
450
|
+
limit=batch_size,
|
|
451
|
+
offset=offset,
|
|
452
|
+
)
|
|
453
|
+
if not page:
|
|
454
|
+
return
|
|
455
|
+
for item in page:
|
|
456
|
+
yield item
|
|
457
|
+
if len(page) < batch_size:
|
|
458
|
+
return
|
|
459
|
+
offset += batch_size
|
|
460
|
+
|
|
461
|
+
async def delete_older_than(
|
|
462
|
+
self,
|
|
463
|
+
iso_cutoff: str,
|
|
464
|
+
user_id: str | None = None,
|
|
465
|
+
hard: bool = True,
|
|
466
|
+
) -> int:
|
|
467
|
+
"""Bulk delete memories with ``created_at < iso_cutoff``.
|
|
468
|
+
|
|
469
|
+
Returns the number of affected rows. Runs as a single SQL statement
|
|
470
|
+
rather than load-then-delete, so it stays O(1) in Python memory.
|
|
471
|
+
"""
|
|
472
|
+
conn = self._require_conn()
|
|
473
|
+
params: list[Any] = [iso_cutoff]
|
|
474
|
+
where = "created_at < ?"
|
|
475
|
+
if user_id is not None:
|
|
476
|
+
where += " AND user_id = ?"
|
|
477
|
+
params.append(user_id)
|
|
478
|
+
async with self._write_lock:
|
|
479
|
+
if hard:
|
|
480
|
+
cursor = await conn.execute(
|
|
481
|
+
f"DELETE FROM memories WHERE {where}", params
|
|
482
|
+
)
|
|
483
|
+
else:
|
|
484
|
+
cursor = await conn.execute(
|
|
485
|
+
f"UPDATE memories SET status = 'DELETED', updated_at = ? "
|
|
486
|
+
f"WHERE {where}",
|
|
487
|
+
[datetime.now(tz=timezone.utc).isoformat(), *params],
|
|
488
|
+
)
|
|
489
|
+
await conn.commit()
|
|
490
|
+
# Drop removed ids from the index lazily on next rebuild.
|
|
491
|
+
if self._index is not None and self._index_loaded:
|
|
492
|
+
self._index_loaded = False
|
|
493
|
+
self._index = None
|
|
494
|
+
return int(cursor.rowcount or 0)
|
|
495
|
+
|
|
496
|
+
async def close(self) -> None:
|
|
497
|
+
if self._conn is not None:
|
|
498
|
+
await self._conn.close()
|
|
499
|
+
self._conn = None
|
|
500
|
+
|
|
501
|
+
def _vectors_from_rows(self, rows: list[Mapping[str, Any]]) -> NDArray[np.float32]:
|
|
502
|
+
return np.stack(
|
|
503
|
+
[np.frombuffer(row["embedding"], dtype=np.float32) for row in rows], axis=0
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _passes_filters(item: MemoryItem, filters: dict[str, object]) -> bool:
|
|
508
|
+
for key, value in filters.items():
|
|
509
|
+
if value is None:
|
|
510
|
+
continue
|
|
511
|
+
if key == "memory_type" and item.memory_type.value != value:
|
|
512
|
+
return False
|
|
513
|
+
if key == "status" and item.status.value != value:
|
|
514
|
+
return False
|
|
515
|
+
if key == "source" and item.source != value:
|
|
516
|
+
return False
|
|
517
|
+
return True
|