pycontextdb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contextdb/__init__.py +72 -0
- contextdb/agents/__init__.py +8 -0
- contextdb/agents/memory_bus.py +80 -0
- contextdb/agents/rl_manager.py +89 -0
- contextdb/cli.py +107 -0
- contextdb/client.py +516 -0
- contextdb/core/__init__.py +41 -0
- contextdb/core/config.py +89 -0
- contextdb/core/exceptions.py +29 -0
- contextdb/core/models.py +151 -0
- contextdb/dynamics/__init__.py +25 -0
- contextdb/dynamics/evolution.py +168 -0
- contextdb/dynamics/formation.py +193 -0
- contextdb/dynamics/retrieval.py +130 -0
- contextdb/graphs/__init__.py +17 -0
- contextdb/graphs/base.py +46 -0
- contextdb/graphs/causal.py +224 -0
- contextdb/graphs/entity.py +251 -0
- contextdb/graphs/semantic.py +156 -0
- contextdb/graphs/temporal.py +173 -0
- contextdb/integrations/__init__.py +10 -0
- contextdb/integrations/autogen.py +39 -0
- contextdb/integrations/crewai.py +41 -0
- contextdb/integrations/langchain.py +132 -0
- contextdb/integrations/openai_tools.py +124 -0
- contextdb/memory/__init__.py +9 -0
- contextdb/memory/experiential.py +102 -0
- contextdb/memory/factual.py +58 -0
- contextdb/memory/working.py +90 -0
- contextdb/privacy/__init__.py +9 -0
- contextdb/privacy/audit.py +199 -0
- contextdb/privacy/pii_detector.py +173 -0
- contextdb/privacy/retention.py +99 -0
- contextdb/py.typed +0 -0
- contextdb/store/__init__.py +15 -0
- contextdb/store/base.py +67 -0
- contextdb/store/sqlite_store.py +517 -0
- contextdb/store/vector_index.py +241 -0
- contextdb/utils/__init__.py +22 -0
- contextdb/utils/embeddings.py +159 -0
- contextdb/utils/llm.py +139 -0
- contextdb/utils/migrations.py +159 -0
- pycontextdb-0.1.0.dist-info/METADATA +589 -0
- pycontextdb-0.1.0.dist-info/RECORD +47 -0
- pycontextdb-0.1.0.dist-info/WHEEL +4 -0
- pycontextdb-0.1.0.dist-info/entry_points.txt +2 -0
- pycontextdb-0.1.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,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
|
+
]
|
contextdb/store/base.py
ADDED
|
@@ -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."""
|