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.
Files changed (47) hide show
  1. contextdb/__init__.py +72 -0
  2. contextdb/agents/__init__.py +8 -0
  3. contextdb/agents/memory_bus.py +80 -0
  4. contextdb/agents/rl_manager.py +89 -0
  5. contextdb/cli.py +107 -0
  6. contextdb/client.py +516 -0
  7. contextdb/core/__init__.py +41 -0
  8. contextdb/core/config.py +89 -0
  9. contextdb/core/exceptions.py +29 -0
  10. contextdb/core/models.py +151 -0
  11. contextdb/dynamics/__init__.py +25 -0
  12. contextdb/dynamics/evolution.py +168 -0
  13. contextdb/dynamics/formation.py +193 -0
  14. contextdb/dynamics/retrieval.py +130 -0
  15. contextdb/graphs/__init__.py +17 -0
  16. contextdb/graphs/base.py +46 -0
  17. contextdb/graphs/causal.py +224 -0
  18. contextdb/graphs/entity.py +251 -0
  19. contextdb/graphs/semantic.py +156 -0
  20. contextdb/graphs/temporal.py +173 -0
  21. contextdb/integrations/__init__.py +10 -0
  22. contextdb/integrations/autogen.py +39 -0
  23. contextdb/integrations/crewai.py +41 -0
  24. contextdb/integrations/langchain.py +132 -0
  25. contextdb/integrations/openai_tools.py +124 -0
  26. contextdb/memory/__init__.py +9 -0
  27. contextdb/memory/experiential.py +102 -0
  28. contextdb/memory/factual.py +58 -0
  29. contextdb/memory/working.py +90 -0
  30. contextdb/privacy/__init__.py +9 -0
  31. contextdb/privacy/audit.py +199 -0
  32. contextdb/privacy/pii_detector.py +173 -0
  33. contextdb/privacy/retention.py +99 -0
  34. contextdb/py.typed +0 -0
  35. contextdb/store/__init__.py +15 -0
  36. contextdb/store/base.py +67 -0
  37. contextdb/store/sqlite_store.py +517 -0
  38. contextdb/store/vector_index.py +241 -0
  39. contextdb/utils/__init__.py +22 -0
  40. contextdb/utils/embeddings.py +159 -0
  41. contextdb/utils/llm.py +139 -0
  42. contextdb/utils/migrations.py +159 -0
  43. pycontextdb-0.1.0.dist-info/METADATA +589 -0
  44. pycontextdb-0.1.0.dist-info/RECORD +47 -0
  45. pycontextdb-0.1.0.dist-info/WHEEL +4 -0
  46. pycontextdb-0.1.0.dist-info/entry_points.txt +2 -0
  47. 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