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,90 @@
1
+ """Working memory — short-lived, token-budgeted session scratchpad.
2
+
3
+ Working memories are the agent's "what am I doing right now" buffer. They are
4
+ scoped to a session, bounded by a token budget (characters ÷ 4 as a coarse
5
+ proxy for tokens), and trimmed FIFO when the budget is exceeded.
6
+
7
+ Items are stored with ``memory_type=WORKING`` and a ``session_id`` metadata
8
+ tag. The retention policy short-circuits them to a 24-hour TTL by default.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from contextdb.core.models import MemoryItem, MemoryStatus, MemoryType
16
+
17
+ if TYPE_CHECKING:
18
+ from contextdb.client import ContextDB
19
+
20
+
21
+ def _approx_tokens(text: str) -> int:
22
+ """Rough token estimate: 4 chars ≈ 1 token (GPT-style average)."""
23
+ return max(1, len(text) // 4)
24
+
25
+
26
+ class WorkingMemory:
27
+ """Token-budgeted FIFO scratchpad scoped to a session."""
28
+
29
+ def __init__(
30
+ self,
31
+ client: ContextDB,
32
+ session_id: str,
33
+ max_tokens: int = 4000,
34
+ ) -> None:
35
+ self.client = client
36
+ self.session_id = session_id
37
+ self.max_tokens = max_tokens
38
+
39
+ async def push(
40
+ self,
41
+ content: str,
42
+ metadata: dict[str, Any] | None = None,
43
+ ) -> MemoryItem:
44
+ meta = dict(metadata or {})
45
+ meta["session_id"] = self.session_id
46
+ item = await self.client.add(
47
+ content=content,
48
+ memory_type=MemoryType.WORKING,
49
+ metadata=meta,
50
+ )
51
+ await self._evict_if_over_budget()
52
+ return item
53
+
54
+ async def list_session(self) -> list[MemoryItem]:
55
+ await self.client._ensure_init()
56
+ store = self.client._require_store()
57
+ items = await store.list_memories(memory_type=MemoryType.WORKING, limit=10000)
58
+ return [
59
+ m
60
+ for m in items
61
+ if m.metadata.get("session_id") == self.session_id
62
+ and m.status == MemoryStatus.ACTIVE
63
+ ]
64
+
65
+ async def context_window(self) -> str:
66
+ items = await self.list_session()
67
+ items.sort(key=lambda m: m.created_at)
68
+ return "\n".join(m.content for m in items)
69
+
70
+ async def clear(self) -> int:
71
+ items = await self.list_session()
72
+ await self.client._ensure_init()
73
+ store = self.client._require_store()
74
+ for item in items:
75
+ await store.delete(item.id, hard=True)
76
+ return len(items)
77
+
78
+ async def _evict_if_over_budget(self) -> None:
79
+ items = await self.list_session()
80
+ items.sort(key=lambda m: m.created_at)
81
+ total = sum(_approx_tokens(m.content) for m in items)
82
+ if total <= self.max_tokens:
83
+ return
84
+ await self.client._ensure_init()
85
+ store = self.client._require_store()
86
+ for item in items:
87
+ if total <= self.max_tokens:
88
+ break
89
+ await store.delete(item.id, hard=True)
90
+ total -= _approx_tokens(item.content)
@@ -0,0 +1,9 @@
1
+ """Privacy primitives: PII detection, retention enforcement, audit trail."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextdb.privacy.audit import AuditEntry, AuditLogger
6
+ from contextdb.privacy.pii_detector import PIIDetector
7
+ from contextdb.privacy.retention import RetentionManager
8
+
9
+ __all__ = ["AuditEntry", "AuditLogger", "PIIDetector", "RetentionManager"]
@@ -0,0 +1,199 @@
1
+ """Hash-chained audit trail for every memory-touching operation.
2
+
3
+ Each :class:`AuditEntry` carries the SHA-256 hash of the previous entry, so
4
+ tampering with any record invalidates the chain downstream. This gives us
5
+ tamper-evidence without a signing authority — enough for internal compliance
6
+ and most external audits short of regulated industries (which should add a
7
+ signing key on top).
8
+
9
+ Verifying the chain is O(n) in entry count but reads are sequential and
10
+ bounded by storage. For production workloads, consider periodic anchoring
11
+ to an external log (e.g., a ledger) — not in scope for v0.1.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ from datetime import datetime, timezone
19
+ from typing import TYPE_CHECKING, Any
20
+ from uuid import uuid4
21
+
22
+ from pydantic import BaseModel, Field
23
+
24
+ if TYPE_CHECKING:
25
+ from contextdb.store.sqlite_store import SQLiteStore
26
+
27
+ _SCHEMA = """
28
+ CREATE TABLE IF NOT EXISTS audit_log (
29
+ id TEXT PRIMARY KEY,
30
+ sequence INTEGER NOT NULL,
31
+ operation TEXT NOT NULL,
32
+ memory_id TEXT,
33
+ user_id TEXT,
34
+ details TEXT DEFAULT '{}',
35
+ previous_hash TEXT NOT NULL,
36
+ entry_hash TEXT NOT NULL,
37
+ timestamp TEXT NOT NULL
38
+ );
39
+ CREATE INDEX IF NOT EXISTS idx_audit_sequence ON audit_log(sequence);
40
+ CREATE INDEX IF NOT EXISTS idx_audit_memory ON audit_log(memory_id);
41
+ CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
42
+ """
43
+
44
+ _GENESIS_HASH = "0" * 64
45
+
46
+
47
+ class AuditEntry(BaseModel):
48
+ """A single record in the hash-chained audit log."""
49
+
50
+ id: str = Field(default_factory=lambda: str(uuid4()))
51
+ sequence: int
52
+ operation: str
53
+ memory_id: str | None = None
54
+ user_id: str | None = None
55
+ details: dict[str, Any] = Field(default_factory=dict)
56
+ previous_hash: str
57
+ entry_hash: str
58
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc))
59
+
60
+ def canonical_payload(self) -> str:
61
+ return json.dumps(
62
+ {
63
+ "id": self.id,
64
+ "sequence": self.sequence,
65
+ "operation": self.operation,
66
+ "memory_id": self.memory_id,
67
+ "user_id": self.user_id,
68
+ "details": self.details,
69
+ "previous_hash": self.previous_hash,
70
+ "timestamp": self.timestamp.isoformat(),
71
+ },
72
+ sort_keys=True,
73
+ separators=(",", ":"),
74
+ )
75
+
76
+
77
+ def _compute_hash(payload: str) -> str:
78
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
79
+
80
+
81
+ class AuditLogger:
82
+ """Append-only audit log with per-entry SHA-256 chaining."""
83
+
84
+ def __init__(self, store: SQLiteStore) -> None:
85
+ self.store = store
86
+
87
+ async def initialize(self) -> None:
88
+ conn = self.store._require_conn()
89
+ await conn.executescript(_SCHEMA)
90
+ await conn.commit()
91
+
92
+ async def log(
93
+ self,
94
+ operation: str,
95
+ memory_id: str | None = None,
96
+ user_id: str | None = None,
97
+ details: dict[str, Any] | None = None,
98
+ ) -> AuditEntry:
99
+ conn = self.store._require_conn()
100
+ cursor = await conn.execute(
101
+ "SELECT sequence, entry_hash FROM audit_log ORDER BY sequence DESC LIMIT 1"
102
+ )
103
+ row = await cursor.fetchone()
104
+ sequence = (row["sequence"] + 1) if row else 1
105
+ previous_hash = row["entry_hash"] if row else _GENESIS_HASH
106
+
107
+ entry = AuditEntry(
108
+ sequence=sequence,
109
+ operation=operation,
110
+ memory_id=memory_id,
111
+ user_id=user_id,
112
+ details=details or {},
113
+ previous_hash=previous_hash,
114
+ entry_hash="",
115
+ )
116
+ entry.entry_hash = _compute_hash(entry.canonical_payload())
117
+
118
+ await conn.execute(
119
+ "INSERT INTO audit_log "
120
+ "(id, sequence, operation, memory_id, user_id, details, "
121
+ "previous_hash, entry_hash, timestamp) VALUES (?,?,?,?,?,?,?,?,?)",
122
+ (
123
+ entry.id,
124
+ entry.sequence,
125
+ entry.operation,
126
+ entry.memory_id,
127
+ entry.user_id,
128
+ json.dumps(entry.details),
129
+ entry.previous_hash,
130
+ entry.entry_hash,
131
+ entry.timestamp.isoformat(),
132
+ ),
133
+ )
134
+ await conn.commit()
135
+ return entry
136
+
137
+ async def get_history(
138
+ self,
139
+ memory_id: str | None = None,
140
+ user_id: str | None = None,
141
+ limit: int = 1000,
142
+ ) -> list[AuditEntry]:
143
+ conn = self.store._require_conn()
144
+ clauses: list[str] = []
145
+ params: list[Any] = []
146
+ if memory_id is not None:
147
+ clauses.append("memory_id = ?")
148
+ params.append(memory_id)
149
+ if user_id is not None:
150
+ clauses.append("user_id = ?")
151
+ params.append(user_id)
152
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
153
+ params.append(limit)
154
+ cursor = await conn.execute(
155
+ f"SELECT * FROM audit_log {where} ORDER BY sequence ASC LIMIT ?",
156
+ params,
157
+ )
158
+ rows = await cursor.fetchall()
159
+ return [
160
+ AuditEntry(
161
+ id=row["id"],
162
+ sequence=int(row["sequence"]),
163
+ operation=row["operation"],
164
+ memory_id=row["memory_id"],
165
+ user_id=row["user_id"],
166
+ details=json.loads(row["details"] or "{}"),
167
+ previous_hash=row["previous_hash"],
168
+ entry_hash=row["entry_hash"],
169
+ timestamp=datetime.fromisoformat(row["timestamp"]),
170
+ )
171
+ for row in rows
172
+ ]
173
+
174
+ async def verify_chain(self) -> bool:
175
+ conn = self.store._require_conn()
176
+ cursor = await conn.execute(
177
+ "SELECT * FROM audit_log ORDER BY sequence ASC"
178
+ )
179
+ rows = await cursor.fetchall()
180
+ expected_prev = _GENESIS_HASH
181
+ for row in rows:
182
+ if row["previous_hash"] != expected_prev:
183
+ return False
184
+ entry = AuditEntry(
185
+ id=row["id"],
186
+ sequence=int(row["sequence"]),
187
+ operation=row["operation"],
188
+ memory_id=row["memory_id"],
189
+ user_id=row["user_id"],
190
+ details=json.loads(row["details"] or "{}"),
191
+ previous_hash=row["previous_hash"],
192
+ entry_hash=row["entry_hash"],
193
+ timestamp=datetime.fromisoformat(row["timestamp"]),
194
+ )
195
+ expected_hash = _compute_hash(entry.canonical_payload())
196
+ if expected_hash != row["entry_hash"]:
197
+ return False
198
+ expected_prev = row["entry_hash"]
199
+ return True
@@ -0,0 +1,173 @@
1
+ """Regex-based PII detection, redaction, and encryption.
2
+
3
+ Covers the common categories (email, phone, SSN, credit card) with well-known
4
+ patterns. Name and address detection is heuristic here; richer recognizers
5
+ can be plugged in later (spaCy NER, Presidio, etc.).
6
+
7
+ Offsets in returned :class:`PIIAnnotation` objects index the **original**
8
+ content, which lets callers redact from the tail forward without corrupting
9
+ indices.
10
+
11
+ Three actions are supported:
12
+
13
+ * ``redact`` — replace each PII span with ``[<TYPE>]`` and keep plaintext
14
+ in :attr:`PIIAnnotation.original` (useful for operator audit on a trusted
15
+ store).
16
+ * ``encrypt`` — replace each PII span with ``[<TYPE>]`` but store a Fernet
17
+ ciphertext in :attr:`PIIAnnotation.original`. Reversible via :meth:`decrypt`
18
+ when the caller holds the key.
19
+ * ``flag`` / ``allow`` — leave text intact; only annotations are produced.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import hashlib
26
+ import logging
27
+ import os
28
+ import re
29
+ from typing import Literal
30
+
31
+ from cryptography.fernet import Fernet, InvalidToken
32
+
33
+ from contextdb.core.models import PIIAnnotation, PIIType
34
+
35
+ PIIAction = Literal["redact", "encrypt", "flag", "allow"]
36
+
37
+ _logger = logging.getLogger(__name__)
38
+
39
+ # Patterns applied in order. The credit-card pattern comes before phone so
40
+ # 16-digit card numbers without dashes don't get misclassified when the phone
41
+ # pattern is tightened.
42
+ _PATTERNS: list[tuple[PIIType, re.Pattern[str]]] = [
43
+ (
44
+ PIIType.EMAIL,
45
+ re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
46
+ ),
47
+ (
48
+ PIIType.SSN,
49
+ re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
50
+ ),
51
+ (
52
+ PIIType.CREDIT_CARD,
53
+ # 13-16 digit sequences optionally grouped by hyphens or spaces.
54
+ re.compile(r"\b(?:\d{4}[-\s]){3}\d{3,4}\b|\b\d{13,16}\b"),
55
+ ),
56
+ (
57
+ PIIType.PHONE,
58
+ re.compile(
59
+ r"\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b"
60
+ ),
61
+ ),
62
+ ]
63
+
64
+
65
+ def _derive_fernet_key(raw: str) -> bytes:
66
+ """Derive a 32-byte urlsafe-base64 Fernet key from any string.
67
+
68
+ We SHA-256 the input so users can supply a human-readable secret without
69
+ having to know Fernet's key format. This is intentionally not PBKDF2 —
70
+ the input is already expected to be a high-entropy operator secret, not
71
+ a user-memorable password.
72
+ """
73
+ digest = hashlib.sha256(raw.encode("utf-8")).digest()
74
+ return base64.urlsafe_b64encode(digest)
75
+
76
+
77
+ class PIIDetector:
78
+ """Detect and apply a policy to PII in free-form text."""
79
+
80
+ def __init__(
81
+ self,
82
+ action: PIIAction = "redact",
83
+ encryption_key: str | None = None,
84
+ ) -> None:
85
+ self.action: PIIAction = action
86
+ self._fernet: Fernet | None = None
87
+ if action == "encrypt":
88
+ key = encryption_key or os.environ.get("CONTEXTDB_PII_KEY")
89
+ if key:
90
+ self._fernet = Fernet(_derive_fernet_key(key))
91
+ else:
92
+ _logger.warning(
93
+ "PII action is 'encrypt' but no key is configured "
94
+ "(pass encryption_key= or set CONTEXTDB_PII_KEY). "
95
+ "Falling back to redact; originals will NOT be recoverable."
96
+ )
97
+
98
+ def detect(self, text: str) -> list[PIIAnnotation]:
99
+ """Return non-overlapping PII spans sorted by start offset."""
100
+ found: list[PIIAnnotation] = []
101
+ taken: list[tuple[int, int]] = []
102
+ for pii_type, pattern in _PATTERNS:
103
+ for match in pattern.finditer(text):
104
+ start, end = match.start(), match.end()
105
+ if any(_overlaps(start, end, s, e) for s, e in taken):
106
+ continue
107
+ taken.append((start, end))
108
+ original = match.group()
109
+ stored_original = (
110
+ self._fernet.encrypt(original.encode("utf-8")).decode("ascii")
111
+ if self._fernet is not None
112
+ else original
113
+ )
114
+ found.append(
115
+ PIIAnnotation(
116
+ pii_type=pii_type,
117
+ start=start,
118
+ end=end,
119
+ original=stored_original,
120
+ redacted=f"[{pii_type.value}]",
121
+ )
122
+ )
123
+ found.sort(key=lambda a: a.start)
124
+ return found
125
+
126
+ def redact(
127
+ self,
128
+ text: str,
129
+ annotations: list[PIIAnnotation] | None = None,
130
+ ) -> str:
131
+ """Replace each PII span with its typed placeholder (e.g. ``[EMAIL]``)."""
132
+ if annotations is None:
133
+ annotations = self.detect(text)
134
+ result = text
135
+ for ann in sorted(annotations, key=lambda a: a.start, reverse=True):
136
+ result = result[: ann.start] + ann.redacted + result[ann.end :]
137
+ return result
138
+
139
+ def process(self, text: str) -> tuple[str, list[PIIAnnotation]]:
140
+ """Detect PII and apply the configured action.
141
+
142
+ Returns ``(processed_text, annotations)``. For ``allow``/``flag`` the
143
+ text is returned unchanged; for ``redact``/``encrypt`` the text is
144
+ returned with placeholders substituted. When action is ``encrypt``
145
+ the :attr:`PIIAnnotation.original` field holds a Fernet ciphertext
146
+ that can be round-tripped via :meth:`decrypt`.
147
+ """
148
+ annotations = self.detect(text)
149
+ if self.action in {"redact", "encrypt"}:
150
+ return self.redact(text, annotations), annotations
151
+ return text, annotations
152
+
153
+ def decrypt(self, annotation: PIIAnnotation) -> str:
154
+ """Recover the plaintext original from an encrypted annotation.
155
+
156
+ Raises :class:`ValueError` if the detector is not configured for
157
+ encryption or if the ciphertext is tampered / wrong key.
158
+ """
159
+ if self._fernet is None:
160
+ raise ValueError(
161
+ "PIIDetector is not configured for encryption. Pass "
162
+ "encryption_key= or set CONTEXTDB_PII_KEY when constructing."
163
+ )
164
+ try:
165
+ return self._fernet.decrypt(annotation.original.encode("ascii")).decode(
166
+ "utf-8"
167
+ )
168
+ except InvalidToken as exc:
169
+ raise ValueError("Invalid or tampered PII ciphertext.") from exc
170
+
171
+
172
+ def _overlaps(a_start: int, a_end: int, b_start: int, b_end: int) -> bool:
173
+ return a_start < b_end and b_start < a_end
@@ -0,0 +1,99 @@
1
+ """Retention policy enforcement.
2
+
3
+ Applies the configured :class:`~contextdb.core.models.RetentionPolicy` to
4
+ the store: anything older than the per-type TTL gets archived (soft) or
5
+ erased (hard, when ``right_to_erasure`` is honored and the user asks).
6
+
7
+ The enforcer is explicit — nothing is deleted on a schedule unless
8
+ :meth:`RetentionManager.enforce` is called. Operators can wire it to a
9
+ cron / periodic task, but the library does not start background threads.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime, timedelta, timezone
15
+ from typing import TYPE_CHECKING
16
+
17
+ from contextdb.core.models import MemoryStatus, MemoryType, RetentionPolicy
18
+
19
+ if TYPE_CHECKING:
20
+ from contextdb.privacy.audit import AuditLogger
21
+ from contextdb.store.sqlite_store import SQLiteStore
22
+
23
+
24
+ _TYPE_TO_TTL: dict[MemoryType, str] = {
25
+ MemoryType.FACTUAL: "factual_ttl",
26
+ MemoryType.EXPERIENTIAL: "experiential_ttl",
27
+ MemoryType.WORKING: "working_ttl",
28
+ }
29
+
30
+
31
+ class RetentionManager:
32
+ """Apply retention TTLs and honor right-to-erasure requests."""
33
+
34
+ def __init__(
35
+ self,
36
+ store: SQLiteStore,
37
+ audit: AuditLogger | None,
38
+ policy: RetentionPolicy,
39
+ ) -> None:
40
+ self.store = store
41
+ self.audit = audit
42
+ self.policy = policy
43
+
44
+ def _ttl_for(self, memory_type: MemoryType) -> timedelta | None:
45
+ field = _TYPE_TO_TTL[memory_type]
46
+ ttl = getattr(self.policy, field, None)
47
+ if ttl is None:
48
+ return self.policy.default_ttl
49
+ assert isinstance(ttl, timedelta)
50
+ return ttl
51
+
52
+ async def enforce(self, hard: bool = False) -> int:
53
+ """Archive (or hard-delete) memories past their TTL.
54
+
55
+ Returns the number of affected rows.
56
+ """
57
+ now = datetime.now(tz=timezone.utc)
58
+ affected = 0
59
+ for memory_type in MemoryType:
60
+ ttl = self._ttl_for(memory_type)
61
+ if ttl is None:
62
+ continue
63
+ cutoff = now - ttl
64
+ memories = await self.store.list_memories(memory_type=memory_type, limit=100000)
65
+ for memory in memories:
66
+ if memory.created_at > cutoff:
67
+ continue
68
+ if hard:
69
+ await self.store.delete(memory.id, hard=True)
70
+ operation = "ERASE"
71
+ else:
72
+ await self.store.update(memory.id, status=MemoryStatus.ARCHIVED)
73
+ operation = "ARCHIVE"
74
+ affected += 1
75
+ if self.audit is not None:
76
+ await self.audit.log(
77
+ operation=operation,
78
+ memory_id=memory.id,
79
+ details={"reason": "retention_ttl", "type": memory_type.value},
80
+ )
81
+ return affected
82
+
83
+ async def erase_user(self, user_id: str) -> int:
84
+ """Honor a right-to-erasure request for a specific user."""
85
+ if not self.policy.right_to_erasure:
86
+ return 0
87
+ memories = await self.store.list_memories(user_id=user_id, limit=100000)
88
+ deleted = 0
89
+ for memory in memories:
90
+ await self.store.delete(memory.id, hard=True)
91
+ deleted += 1
92
+ if self.audit is not None:
93
+ await self.audit.log(
94
+ operation="ERASE",
95
+ memory_id=memory.id,
96
+ user_id=user_id,
97
+ details={"reason": "right_to_erasure"},
98
+ )
99
+ return deleted
contextdb/py.typed ADDED
File without changes
@@ -0,0 +1,15 @@
1
+ """Storage backends and vector indices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextdb.store.base import BaseStore
6
+ from contextdb.store.sqlite_store import SQLiteStore
7
+ from contextdb.store.vector_index import NumpyIndex, VectorIndex, get_vector_index
8
+
9
+ __all__ = [
10
+ "BaseStore",
11
+ "NumpyIndex",
12
+ "SQLiteStore",
13
+ "VectorIndex",
14
+ "get_vector_index",
15
+ ]
@@ -0,0 +1,67 @@
1
+ """Abstract storage backend contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING
7
+
8
+ from contextdb.core.models import MemoryStatus
9
+
10
+ if TYPE_CHECKING:
11
+ from contextdb.core.models import MemoryItem, MemoryType
12
+
13
+
14
+ class BaseStore(ABC):
15
+ """Abstract base for all persistent memory stores.
16
+
17
+ Implementations must be async and safe to call concurrently from one
18
+ event loop. They are not required to be safe across processes.
19
+ """
20
+
21
+ @abstractmethod
22
+ async def initialize(self) -> None:
23
+ """Create schema / open connections. Idempotent."""
24
+
25
+ @abstractmethod
26
+ async def add(self, item: MemoryItem) -> MemoryItem:
27
+ """Persist a memory and return the stored copy."""
28
+
29
+ @abstractmethod
30
+ async def get(self, memory_id: str) -> MemoryItem | None:
31
+ """Fetch one memory by id; increments access counters."""
32
+
33
+ @abstractmethod
34
+ async def update(self, memory_id: str, **kwargs: object) -> MemoryItem:
35
+ """Partial update; unknown keys raise ValueError."""
36
+
37
+ @abstractmethod
38
+ async def delete(self, memory_id: str, hard: bool = False) -> None:
39
+ """Soft delete by default (status=DELETED); ``hard=True`` removes the row."""
40
+
41
+ @abstractmethod
42
+ async def search_by_embedding(
43
+ self,
44
+ embedding: list[float],
45
+ top_k: int = 10,
46
+ filters: dict[str, object] | None = None,
47
+ ) -> list[MemoryItem]:
48
+ """Return top-k most similar memories by cosine similarity."""
49
+
50
+ @abstractmethod
51
+ async def list_memories(
52
+ self,
53
+ user_id: str | None = None,
54
+ memory_type: MemoryType | None = None,
55
+ status: MemoryStatus = MemoryStatus.ACTIVE,
56
+ limit: int = 100,
57
+ offset: int = 0,
58
+ ) -> list[MemoryItem]:
59
+ """List memories with optional filters."""
60
+
61
+ @abstractmethod
62
+ async def count(self, user_id: str | None = None) -> int:
63
+ """Count active memories, optionally scoped to a user."""
64
+
65
+ @abstractmethod
66
+ async def close(self) -> None:
67
+ """Release resources."""