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
contextdb/client.py ADDED
@@ -0,0 +1,516 @@
1
+ """The :class:`ContextDB` client — the public entry point.
2
+
3
+ Wires together storage, embeddings, LLM, PII, graphs, formation, evolution,
4
+ retrieval, and audit behind a small surface:
5
+
6
+ * ``add`` / ``search`` / ``get`` / ``update`` / ``delete`` — CRUD + recall.
7
+ * ``add_conversation`` — run raw text through the formation pipeline.
8
+ * ``forget`` / ``stats`` / ``consolidate`` / ``prune`` — lifecycle operations.
9
+ * ``factual`` / ``experiential`` / ``working`` — typed memory sub-APIs.
10
+
11
+ The client is lazy: resources are created on the first await, which keeps
12
+ ``contextdb.init()`` cheap and side-effect-free.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime, timedelta, timezone
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ from contextdb.core.config import ContextDBConfig
21
+ from contextdb.core.exceptions import ContextDBError
22
+ from contextdb.core.models import MemoryItem, MemoryType
23
+ from contextdb.privacy.pii_detector import PIIDetector
24
+ from contextdb.store.sqlite_store import SQLiteStore
25
+ from contextdb.utils.embeddings import EmbeddingProvider, get_embedding_provider
26
+ from contextdb.utils.llm import LLMProvider, get_llm_provider
27
+
28
+ if TYPE_CHECKING:
29
+ from contextdb.agents.memory_bus import MemoryBus
30
+ from contextdb.agents.rl_manager import RLMemoryManager
31
+ from contextdb.dynamics.evolution import AutoLinker, Consolidator, Pruner
32
+ from contextdb.dynamics.formation import FormationPipeline
33
+ from contextdb.dynamics.retrieval import RetrievalEngine
34
+ from contextdb.graphs.base import BaseGraph
35
+ from contextdb.memory.experiential import ExperientialMemory
36
+ from contextdb.memory.factual import FactualMemory
37
+ from contextdb.memory.working import WorkingMemory
38
+ from contextdb.privacy.audit import AuditLogger
39
+ from contextdb.privacy.retention import RetentionManager
40
+
41
+
42
+ class ContextDB:
43
+ """Memory operating system for AI agents — the user-facing interface."""
44
+
45
+ def __init__(self, config: ContextDBConfig, user_id: str | None = None) -> None:
46
+ self.config = config
47
+ self.user_id = user_id
48
+ self._store: SQLiteStore | None = None
49
+ self._embedder: EmbeddingProvider | None = None
50
+ self._llm: LLMProvider | None = None
51
+ self._pii: PIIDetector | None = None
52
+ self._graphs: dict[str, BaseGraph] = {}
53
+ self._retrieval: RetrievalEngine | None = None
54
+ self._formation: FormationPipeline | None = None
55
+ self._auto_linker: AutoLinker | None = None
56
+ self._consolidator: Consolidator | None = None
57
+ self._pruner: Pruner | None = None
58
+ self._audit: AuditLogger | None = None
59
+ self._retention: RetentionManager | None = None
60
+ self._memory_bus: MemoryBus | None = None
61
+ self._rl_manager: RLMemoryManager | None = None
62
+ self._initialized = False
63
+
64
+ # ------------------------------------------------------------------ #
65
+ # Initialization
66
+ # ------------------------------------------------------------------ #
67
+
68
+ async def _ensure_init(self) -> None:
69
+ if self._initialized:
70
+ return
71
+ # Core building blocks
72
+ self._embedder = get_embedding_provider(
73
+ self.config.embedding_model,
74
+ self.config.llm_api_key,
75
+ dimension=self.config.embedding_dim,
76
+ )
77
+ dim = self._embedder.dimension()
78
+ self._store = SQLiteStore(
79
+ storage_url=self.config.storage_url,
80
+ user_id=self.user_id,
81
+ embedding_dim=dim,
82
+ )
83
+ await self._store.initialize()
84
+ self._llm = get_llm_provider(self.config.llm_model, self.config.llm_api_key)
85
+ self._pii = PIIDetector(
86
+ action=self.config.pii_action,
87
+ encryption_key=self.config.pii_encryption_key,
88
+ )
89
+
90
+ # Graphs (local imports to avoid circular references at module load)
91
+ from contextdb.graphs.semantic import SemanticGraph
92
+
93
+ semantic = SemanticGraph(self._store)
94
+ await semantic.initialize()
95
+ self._graphs["semantic"] = semantic
96
+
97
+ if self.config.enable_entity_graph:
98
+ from contextdb.graphs.entity import EntityGraph
99
+
100
+ entity_graph = EntityGraph(self._store, self._llm)
101
+ await entity_graph.initialize()
102
+ self._graphs["entity"] = entity_graph
103
+
104
+ if self.config.enable_multi_graph:
105
+ from contextdb.graphs.causal import CausalGraph
106
+ from contextdb.graphs.temporal import TemporalGraph
107
+
108
+ temporal = TemporalGraph(self._store)
109
+ await temporal.initialize()
110
+ self._graphs["temporal"] = temporal
111
+ causal = CausalGraph(self._store, self._llm)
112
+ await causal.initialize()
113
+ self._graphs["causal"] = causal
114
+
115
+ # Dynamics
116
+ from contextdb.dynamics.evolution import AutoLinker, Consolidator, Pruner
117
+ from contextdb.dynamics.formation import (
118
+ FormationPipeline,
119
+ MemoryCompressor,
120
+ MemoryExtractor,
121
+ Segmenter,
122
+ )
123
+ from contextdb.dynamics.retrieval import (
124
+ QueryClassifier,
125
+ RetrievalEngine,
126
+ RetrievalFuser,
127
+ )
128
+
129
+ self._auto_linker = AutoLinker(self._graphs)
130
+ self._retrieval = RetrievalEngine(
131
+ self._store, self._graphs, QueryClassifier(), RetrievalFuser()
132
+ )
133
+ self._formation = FormationPipeline(
134
+ Segmenter(),
135
+ MemoryExtractor(self._llm),
136
+ MemoryCompressor(self._llm),
137
+ self._pii,
138
+ self._embedder,
139
+ )
140
+ from contextdb.graphs.semantic import SemanticGraph as _SemanticGraphType
141
+
142
+ semantic_graph = self._graphs["semantic"]
143
+ assert isinstance(semantic_graph, _SemanticGraphType)
144
+ self._consolidator = Consolidator(self._store, semantic_graph, self._llm)
145
+ self._pruner = Pruner(self._store)
146
+
147
+ # Privacy
148
+ if self.config.enable_audit:
149
+ from contextdb.privacy.audit import AuditLogger
150
+
151
+ self._audit = AuditLogger(self._store)
152
+ await self._audit.initialize()
153
+
154
+ from contextdb.core.models import RetentionPolicy
155
+ from contextdb.privacy.retention import RetentionManager
156
+
157
+ self._retention = RetentionManager(
158
+ self._store,
159
+ self._audit,
160
+ RetentionPolicy(
161
+ default_ttl=(
162
+ timedelta(days=self.config.retention_ttl_days)
163
+ if self.config.retention_ttl_days
164
+ else None
165
+ )
166
+ ),
167
+ )
168
+
169
+ # RL (optional, paid tier)
170
+ if self.config.enable_rl_manager:
171
+ from contextdb.agents.rl_manager import RLMemoryManager
172
+
173
+ self._rl_manager = RLMemoryManager(self._llm)
174
+
175
+ self._initialized = True
176
+
177
+ # ------------------------------------------------------------------ #
178
+ # Accessors guarded against misuse
179
+ # ------------------------------------------------------------------ #
180
+
181
+ def _require_store(self) -> SQLiteStore:
182
+ if self._store is None:
183
+ raise ContextDBError("ContextDB not initialized; await _ensure_init() first.")
184
+ return self._store
185
+
186
+ # ------------------------------------------------------------------ #
187
+ # Core CRUD / search
188
+ # ------------------------------------------------------------------ #
189
+
190
+ async def add(
191
+ self,
192
+ content: str,
193
+ memory_type: MemoryType = MemoryType.FACTUAL,
194
+ metadata: dict[str, Any] | None = None,
195
+ event_time: datetime | None = None,
196
+ source: str = "",
197
+ entity_mentions: list[str] | None = None,
198
+ ) -> MemoryItem:
199
+ await self._ensure_init()
200
+ assert self._pii is not None
201
+ assert self._embedder is not None
202
+ store = self._require_store()
203
+
204
+ processed, pii_annotations = self._pii.process(content)
205
+
206
+ # Optional RL override: NOOP / UPDATE / DELETE short-circuit ADD.
207
+ if self._rl_manager is not None:
208
+ candidates = await store.list_memories(limit=20)
209
+ decision = await self._rl_manager.decide(processed, candidates)
210
+ action = decision.get("action", "ADD").upper()
211
+ if action == "NOOP":
212
+ raise ContextDBError("RL manager chose NOOP; nothing stored.")
213
+ if action == "UPDATE" and decision.get("target_memory_id"):
214
+ target = decision["target_memory_id"]
215
+ merged = decision.get("content") or processed
216
+ return await self.update(target, content=merged, metadata=metadata)
217
+ if action == "DELETE" and decision.get("target_memory_id"):
218
+ await self.delete(decision["target_memory_id"])
219
+
220
+ embedding = (await self._embedder.embed([processed]))[0]
221
+ item = MemoryItem(
222
+ content=processed,
223
+ embedding=embedding,
224
+ memory_type=memory_type,
225
+ source=source,
226
+ metadata=metadata or {},
227
+ event_time=event_time or datetime.now(tz=timezone.utc),
228
+ pii_annotations=pii_annotations,
229
+ entity_mentions=entity_mentions or [],
230
+ )
231
+ stored = await store.add(item)
232
+
233
+ if self.config.enable_auto_link and self._auto_linker is not None:
234
+ await self._auto_linker.link(
235
+ stored.id,
236
+ {
237
+ "content": stored.content,
238
+ "embedding": stored.embedding,
239
+ "event_time": stored.event_time,
240
+ },
241
+ )
242
+
243
+ if self._audit is not None:
244
+ await self._audit.log(
245
+ operation="CREATE",
246
+ memory_id=stored.id,
247
+ user_id=self.user_id,
248
+ details={"memory_type": memory_type.value},
249
+ )
250
+ return stored
251
+
252
+ async def search(
253
+ self,
254
+ query: str,
255
+ top_k: int = 10,
256
+ memory_type: MemoryType | None = None,
257
+ time_range: tuple[datetime, datetime] | None = None,
258
+ ) -> list[MemoryItem]:
259
+ await self._ensure_init()
260
+ assert self._embedder is not None
261
+ assert self._retrieval is not None
262
+ query_embedding = (await self._embedder.embed([query]))[0]
263
+ items = await self._retrieval.search(query, query_embedding, top_k=top_k)
264
+ if memory_type is not None:
265
+ items = [m for m in items if m.memory_type == memory_type]
266
+ if time_range is not None:
267
+ start, end = time_range
268
+ items = [m for m in items if m.event_time and start <= m.event_time <= end]
269
+ if self._audit is not None:
270
+ await self._audit.log(
271
+ operation="SEARCH",
272
+ user_id=self.user_id,
273
+ details={"query": query, "hits": len(items)},
274
+ )
275
+ return items
276
+
277
+ async def get(self, memory_id: str) -> MemoryItem | None:
278
+ await self._ensure_init()
279
+ item = await self._require_store().get(memory_id)
280
+ if self._audit is not None and item is not None:
281
+ await self._audit.log(
282
+ operation="READ", memory_id=memory_id, user_id=self.user_id
283
+ )
284
+ return item
285
+
286
+ async def update(
287
+ self,
288
+ memory_id: str,
289
+ content: str | None = None,
290
+ metadata: dict[str, Any] | None = None,
291
+ ) -> MemoryItem:
292
+ await self._ensure_init()
293
+ assert self._pii is not None
294
+ assert self._embedder is not None
295
+ kwargs: dict[str, Any] = {}
296
+ if content is not None:
297
+ processed, pii = self._pii.process(content)
298
+ embedding = (await self._embedder.embed([processed]))[0]
299
+ kwargs["content"] = processed
300
+ kwargs["embedding"] = embedding
301
+ kwargs["pii_annotations"] = pii
302
+ if metadata is not None:
303
+ kwargs["metadata"] = metadata
304
+ item = await self._require_store().update(memory_id, **kwargs)
305
+ if self._audit is not None:
306
+ await self._audit.log(
307
+ operation="UPDATE", memory_id=memory_id, user_id=self.user_id
308
+ )
309
+ return item
310
+
311
+ async def delete(self, memory_id: str, hard: bool = False) -> None:
312
+ await self._ensure_init()
313
+ await self._require_store().delete(memory_id, hard=hard)
314
+ if self._audit is not None:
315
+ await self._audit.log(
316
+ operation="DELETE", memory_id=memory_id, user_id=self.user_id
317
+ )
318
+
319
+ async def add_conversation(self, conversation: str, source: str = "") -> list[MemoryItem]:
320
+ """Run a raw conversation through the formation pipeline and store results."""
321
+ await self._ensure_init()
322
+ assert self._formation is not None
323
+ items = await self._formation.process(conversation, source=source)
324
+ stored: list[MemoryItem] = []
325
+ store = self._require_store()
326
+ for item in items:
327
+ saved = await store.add(item)
328
+ if self.config.enable_auto_link and self._auto_linker is not None:
329
+ await self._auto_linker.link(
330
+ saved.id,
331
+ {
332
+ "content": saved.content,
333
+ "embedding": saved.embedding,
334
+ "event_time": saved.event_time,
335
+ },
336
+ )
337
+ stored.append(saved)
338
+ if self._audit is not None:
339
+ await self._audit.log(
340
+ operation="CREATE",
341
+ user_id=self.user_id,
342
+ details={"count": len(stored), "source": source},
343
+ )
344
+ return stored
345
+
346
+ async def forget(
347
+ self,
348
+ user_id: str | None = None,
349
+ entity: str | None = None,
350
+ older_than: timedelta | None = None,
351
+ ) -> int:
352
+ """Bulk-delete memories.
353
+
354
+ Age-only forgets use a single SQL ``DELETE`` to stay O(1) in Python
355
+ memory. Entity-scoped forgets must inspect JSON-serialised
356
+ ``entity_mentions`` and free-text content, so they stream memories in
357
+ 500-row pages rather than loading the full table.
358
+ """
359
+ await self._ensure_init()
360
+ store = self._require_store()
361
+
362
+ # Fast path: age-only deletes lower to a single SQL statement.
363
+ if entity is None and older_than is not None:
364
+ cutoff = datetime.now(tz=timezone.utc) - older_than
365
+ deleted = await store.delete_older_than(
366
+ cutoff.isoformat(), user_id=user_id, hard=True
367
+ )
368
+ if self._audit is not None:
369
+ await self._audit.log(
370
+ operation="ERASE",
371
+ user_id=user_id,
372
+ details={"bulk": True, "count": deleted, "older_than": older_than.days},
373
+ )
374
+ return deleted
375
+
376
+ now = datetime.now(tz=timezone.utc)
377
+ needle = entity.lower() if entity is not None else None
378
+ deleted = 0
379
+ async for m in store.iter_memories(user_id=user_id, batch_size=500):
380
+ matches = True
381
+ if needle is not None:
382
+ ents = [e.lower() for e in m.entity_mentions]
383
+ if needle not in ents and needle not in m.content.lower():
384
+ matches = False
385
+ if older_than is not None and now - m.created_at < older_than:
386
+ matches = False
387
+ if matches:
388
+ await store.delete(m.id, hard=True)
389
+ deleted += 1
390
+ if self._audit is not None:
391
+ await self._audit.log(
392
+ operation="ERASE",
393
+ memory_id=m.id,
394
+ user_id=user_id,
395
+ details={"bulk": True},
396
+ )
397
+ return deleted
398
+
399
+ async def stats(self) -> dict[str, Any]:
400
+ await self._ensure_init()
401
+ store = self._require_store()
402
+ total = await store.count(self.user_id)
403
+ by_type = await store.count_by_type(self.user_id)
404
+ return {
405
+ "total_memories": total,
406
+ "user_id": self.user_id,
407
+ "by_type": by_type,
408
+ "graphs": list(self._graphs.keys()),
409
+ }
410
+
411
+ async def consolidate(self, min_cluster_size: int = 5) -> list[MemoryItem]:
412
+ await self._ensure_init()
413
+ assert self._consolidator is not None
414
+ return await self._consolidator.consolidate(min_cluster_size=min_cluster_size)
415
+
416
+ async def prune(self, strategy: str = "decay", **kwargs: Any) -> int:
417
+ await self._ensure_init()
418
+ assert self._pruner is not None
419
+ return await self._pruner.prune(strategy=strategy, **kwargs)
420
+
421
+ async def get_timeline(
422
+ self,
423
+ entity: str | None = None,
424
+ start: datetime | None = None,
425
+ end: datetime | None = None,
426
+ ) -> list[MemoryItem]:
427
+ await self._ensure_init()
428
+ if "temporal" in self._graphs:
429
+ from contextdb.graphs.temporal import TemporalGraph
430
+
431
+ temporal = self._graphs["temporal"]
432
+ assert isinstance(temporal, TemporalGraph)
433
+ return await temporal.get_timeline(entity=entity, start=start, end=end)
434
+ store = self._require_store()
435
+ memories = await store.list_memories(limit=10000)
436
+ filtered = [m for m in memories if m.event_time is not None]
437
+ filtered.sort(key=lambda m: m.event_time or datetime.min.replace(tzinfo=timezone.utc))
438
+ if start is not None:
439
+ filtered = [m for m in filtered if m.event_time and m.event_time >= start]
440
+ if end is not None:
441
+ filtered = [m for m in filtered if m.event_time and m.event_time <= end]
442
+ if entity is not None:
443
+ filtered = [
444
+ m
445
+ for m in filtered
446
+ if entity.lower() in " ".join(m.entity_mentions).lower()
447
+ or entity.lower() in m.content.lower()
448
+ ]
449
+ return filtered
450
+
451
+ async def get_entity(self, name: str) -> dict[str, Any]:
452
+ await self._ensure_init()
453
+ if "entity" in self._graphs:
454
+ from contextdb.graphs.entity import EntityGraph
455
+
456
+ entity_graph = self._graphs["entity"]
457
+ assert isinstance(entity_graph, EntityGraph)
458
+ return await entity_graph.get_entity_profile(name)
459
+ return {"name": name, "memories": [], "attributes": {}}
460
+
461
+ # ------------------------------------------------------------------ #
462
+ # Typed memory surfaces
463
+ # ------------------------------------------------------------------ #
464
+
465
+ @property
466
+ def factual(self) -> FactualMemory:
467
+ from contextdb.memory.factual import FactualMemory
468
+
469
+ return FactualMemory(self, self.user_id)
470
+
471
+ @property
472
+ def experiential(self) -> ExperientialMemory:
473
+ from contextdb.memory.experiential import ExperientialMemory
474
+
475
+ return ExperientialMemory(self, self.user_id)
476
+
477
+ def working(self, session_id: str, max_tokens: int = 4000) -> WorkingMemory:
478
+ from contextdb.memory.working import WorkingMemory
479
+
480
+ return WorkingMemory(self, session_id, max_tokens=max_tokens)
481
+
482
+ @property
483
+ def privacy(self) -> RetentionManager:
484
+ if self._retention is None:
485
+ raise ContextDBError(
486
+ "ContextDB not initialized; call await db._ensure_init() first."
487
+ )
488
+ return self._retention
489
+
490
+ @property
491
+ def audit(self) -> AuditLogger | None:
492
+ return self._audit
493
+
494
+ def bus(self) -> MemoryBus:
495
+ """Return (or create) the in-process multi-agent event bus."""
496
+ from contextdb.agents.memory_bus import MemoryBus
497
+
498
+ if self._memory_bus is None:
499
+ self._memory_bus = MemoryBus()
500
+ return self._memory_bus
501
+
502
+ # ------------------------------------------------------------------ #
503
+ # Resource management
504
+ # ------------------------------------------------------------------ #
505
+
506
+ async def close(self) -> None:
507
+ if self._store is not None:
508
+ await self._store.close()
509
+ self._initialized = False
510
+
511
+ async def __aenter__(self) -> ContextDB:
512
+ await self._ensure_init()
513
+ return self
514
+
515
+ async def __aexit__(self, *_: object) -> None:
516
+ await self.close()
@@ -0,0 +1,41 @@
1
+ """Core primitives: configuration, exception hierarchy, and data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextdb.core.config import ContextDBConfig
6
+ from contextdb.core.exceptions import (
7
+ ConfigError,
8
+ ContextDBError,
9
+ MemoryNotFoundError,
10
+ PrivacyError,
11
+ StorageError,
12
+ )
13
+ from contextdb.core.models import (
14
+ Edge,
15
+ Entity,
16
+ GraphType,
17
+ MemoryItem,
18
+ MemoryStatus,
19
+ MemoryType,
20
+ PIIAnnotation,
21
+ PIIType,
22
+ RetentionPolicy,
23
+ )
24
+
25
+ __all__ = [
26
+ "ConfigError",
27
+ "ContextDBConfig",
28
+ "ContextDBError",
29
+ "Edge",
30
+ "Entity",
31
+ "GraphType",
32
+ "MemoryItem",
33
+ "MemoryNotFoundError",
34
+ "MemoryStatus",
35
+ "MemoryType",
36
+ "PIIAnnotation",
37
+ "PIIType",
38
+ "PrivacyError",
39
+ "RetentionPolicy",
40
+ "StorageError",
41
+ ]
@@ -0,0 +1,89 @@
1
+ """Configuration for ContextDB.
2
+
3
+ :class:`ContextDBConfig` is the single source of truth for runtime settings.
4
+ It is a :class:`pydantic_settings.BaseSettings` subclass, so fields may be
5
+ populated from environment variables prefixed with ``CONTEXTDB_`` in addition
6
+ to keyword arguments.
7
+
8
+ The one exception is :attr:`ContextDBConfig.llm_api_key`, which falls back to
9
+ the standard ``OPENAI_API_KEY`` environment variable when not supplied
10
+ explicitly. This mirrors the behavior of the OpenAI SDK and lets users point
11
+ ContextDB at their existing key without renaming it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Literal
18
+
19
+ from pydantic import Field, field_validator
20
+ from pydantic_settings import BaseSettings, SettingsConfigDict
21
+
22
+ PIIAction = Literal["redact", "encrypt", "flag", "allow"]
23
+
24
+
25
+ class ContextDBConfig(BaseSettings):
26
+ """Runtime configuration for a :class:`ContextDB` instance."""
27
+
28
+ model_config = SettingsConfigDict(
29
+ env_prefix="CONTEXTDB_",
30
+ env_file=None,
31
+ extra="ignore",
32
+ case_sensitive=False,
33
+ )
34
+
35
+ storage_url: str = Field(
36
+ default="sqlite:///contextdb.db",
37
+ description="Storage backend URL. SQLite for local dev, Postgres for production.",
38
+ )
39
+ embedding_model: str = Field(
40
+ default="text-embedding-3-small",
41
+ description="Embedding model name (OpenAI by default).",
42
+ )
43
+ embedding_dim: int = Field(
44
+ default=1536,
45
+ description="Embedding vector dimensionality; must match embedding_model.",
46
+ )
47
+ llm_model: str = Field(
48
+ default="gpt-4o-mini",
49
+ description="LLM used for extraction, compression, and reasoning steps.",
50
+ )
51
+ llm_api_key: str | None = Field(
52
+ default=None,
53
+ description="API key for the LLM provider. Falls back to OPENAI_API_KEY env var.",
54
+ )
55
+ pii_action: PIIAction = Field(
56
+ default="redact",
57
+ description="How detected PII should be handled before storage.",
58
+ )
59
+ pii_encryption_key: str | None = Field(
60
+ default=None,
61
+ description=(
62
+ "Secret used when pii_action='encrypt'. Falls back to "
63
+ "CONTEXTDB_PII_KEY env var. Without either, encrypt degrades "
64
+ "to redact (originals unrecoverable)."
65
+ ),
66
+ )
67
+ retention_ttl_days: int | None = Field(
68
+ default=730,
69
+ description="Default retention horizon in days. None disables TTL enforcement.",
70
+ )
71
+ log_level: str = Field(
72
+ default="INFO",
73
+ description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL).",
74
+ )
75
+
76
+ # Tier / feature flags. Single-graph semantic memory is always on; every
77
+ # richer pathway is explicit so operators can cleanly A/B free vs paid.
78
+ enable_entity_graph: bool = Field(default=True)
79
+ enable_multi_graph: bool = Field(default=False)
80
+ enable_rl_manager: bool = Field(default=False)
81
+ enable_audit: bool = Field(default=True)
82
+ enable_auto_link: bool = Field(default=True)
83
+
84
+ @field_validator("llm_api_key", mode="before")
85
+ @classmethod
86
+ def _default_api_key_from_env(cls, value: str | None) -> str | None:
87
+ if value:
88
+ return value
89
+ return os.environ.get("OPENAI_API_KEY")
@@ -0,0 +1,29 @@
1
+ """Exception hierarchy for ContextDB.
2
+
3
+ All exceptions raised by ContextDB derive from :class:`ContextDBError`, so
4
+ callers can catch everything with a single ``except`` clause when they want to.
5
+ Specific subclasses exist for the common failure modes so callers can handle
6
+ them individually where it matters.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+
12
+ class ContextDBError(Exception):
13
+ """Base class for all ContextDB errors."""
14
+
15
+
16
+ class MemoryNotFoundError(ContextDBError):
17
+ """Raised when a memory lookup by id returns no result."""
18
+
19
+
20
+ class StorageError(ContextDBError):
21
+ """Raised when the underlying storage backend fails."""
22
+
23
+
24
+ class PrivacyError(ContextDBError):
25
+ """Raised when a privacy constraint is violated (PII handling, retention)."""
26
+
27
+
28
+ class ConfigError(ContextDBError):
29
+ """Raised when ContextDB is misconfigured."""