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
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
|
+
]
|
contextdb/core/config.py
ADDED
|
@@ -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."""
|