contextos-memory-runtime 1.0.0rc2__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.
- contextos/__init__.py +3 -0
- contextos/__main__.py +6 -0
- contextos/api/__init__.py +1 -0
- contextos/api/routes/__init__.py +1 -0
- contextos/api/routes/desktop.py +322 -0
- contextos/api/routes/ingest.py +17 -0
- contextos/api/routes/memories.py +84 -0
- contextos/api/routes/models.py +81 -0
- contextos/api/routes/retrieval.py +89 -0
- contextos/api/routes/system.py +216 -0
- contextos/api/server.py +195 -0
- contextos/benchmarks/__init__.py +1 -0
- contextos/benchmarks/compilation.py +245 -0
- contextos/benchmarks/connectors.py +423 -0
- contextos/benchmarks/explainability.py +103 -0
- contextos/benchmarks/final.py +406 -0
- contextos/benchmarks/graph.py +310 -0
- contextos/benchmarks/graph_adversarial.py +525 -0
- contextos/benchmarks/mcp.py +324 -0
- contextos/benchmarks/model_routing.py +203 -0
- contextos/benchmarks/optimization.py +305 -0
- contextos/benchmarks/rescue_integration.py +127 -0
- contextos/benchmarks/retrieval.py +266 -0
- contextos/benchmarks/temporal.py +377 -0
- contextos/benchmarks/temporal_hotpath.py +76 -0
- contextos/benchmarks/terminal.py +62 -0
- contextos/cli/__init__.py +1 -0
- contextos/cli/app.py +932 -0
- contextos/cli/dashboard.py +174 -0
- contextos/cli/formatters.py +299 -0
- contextos/config/__init__.py +1 -0
- contextos/config/settings.py +160 -0
- contextos/connectors/__init__.py +6 -0
- contextos/connectors/fake.py +11 -0
- contextos/connectors/json_import.py +125 -0
- contextos/connectors/local_files.py +102 -0
- contextos/connectors/manager.py +293 -0
- contextos/connectors/models.py +62 -0
- contextos/connectors/protocols.py +11 -0
- contextos/core/__init__.py +103 -0
- contextos/core/enums.py +489 -0
- contextos/core/exceptions.py +293 -0
- contextos/core/models.py +1147 -0
- contextos/core/protocols.py +549 -0
- contextos/daemon/__init__.py +1 -0
- contextos/daemon/manager.py +510 -0
- contextos/daemon/state.py +127 -0
- contextos/daemon/wiring.py +296 -0
- contextos/demo.py +217 -0
- contextos/embedding/__init__.py +1 -0
- contextos/embedding/deterministic.py +76 -0
- contextos/embedding/sentence_transformers.py +80 -0
- contextos/mcp/__init__.py +5 -0
- contextos/mcp/server.py +269 -0
- contextos/providers/__init__.py +13 -0
- contextos/providers/fake.py +217 -0
- contextos/providers/ollama.py +297 -0
- contextos/providers/openai_compatible.py +337 -0
- contextos/services/__init__.py +1 -0
- contextos/services/compilation.py +535 -0
- contextos/services/explainability.py +553 -0
- contextos/services/extraction.py +311 -0
- contextos/services/graph.py +524 -0
- contextos/services/graph_retrieval.py +143 -0
- contextos/services/ingestion.py +143 -0
- contextos/services/inspection.py +174 -0
- contextos/services/memory.py +291 -0
- contextos/services/model_service.py +409 -0
- contextos/services/optimization.py +426 -0
- contextos/services/privacy.py +331 -0
- contextos/services/retrieval.py +302 -0
- contextos/services/retrieval_index.py +88 -0
- contextos/services/router.py +302 -0
- contextos/services/secret_scanner.py +207 -0
- contextos/services/telemetry_query.py +102 -0
- contextos/services/temporal.py +500 -0
- contextos/services/token_counter.py +222 -0
- contextos/storage/__init__.py +1 -0
- contextos/storage/connector_repo.py +67 -0
- contextos/storage/database.py +497 -0
- contextos/storage/event_repo.py +137 -0
- contextos/storage/graph_repo.py +228 -0
- contextos/storage/lexical/__init__.py +1 -0
- contextos/storage/lexical/bm25.py +134 -0
- contextos/storage/memory_repo.py +589 -0
- contextos/storage/relation_repo.py +80 -0
- contextos/storage/telemetry_repo.py +481 -0
- contextos/storage/vector/__init__.py +1 -0
- contextos/storage/vector/in_memory.py +162 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
- contextos_memory_runtime-1.0.0rc2.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""SQLite-backed Memory repository.
|
|
2
|
+
|
|
3
|
+
Implements the MemoryRepository protocol for CRUD operations on memories.
|
|
4
|
+
Handles serialization between Pydantic Memory models and SQLite rows.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import asyncio
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
import aiosqlite
|
|
17
|
+
|
|
18
|
+
from contextos.core.enums import (
|
|
19
|
+
VALID_TRANSITIONS,
|
|
20
|
+
CandidateTemporalStatus,
|
|
21
|
+
MemoryStatus,
|
|
22
|
+
MemoryType,
|
|
23
|
+
PrivacyLevel,
|
|
24
|
+
RelationType,
|
|
25
|
+
TemporalOutcome,
|
|
26
|
+
TemporalPrecision,
|
|
27
|
+
)
|
|
28
|
+
from contextos.core.exceptions import (
|
|
29
|
+
ConcurrencyError, DuplicateMemoryError, InvalidTransitionError, MemoryNotFoundError,
|
|
30
|
+
)
|
|
31
|
+
from contextos.core.models import (
|
|
32
|
+
Memory,
|
|
33
|
+
MemoryFilters,
|
|
34
|
+
MemoryRelation,
|
|
35
|
+
MemorySlot,
|
|
36
|
+
MemoryUpdate,
|
|
37
|
+
TemporalDecision,
|
|
38
|
+
TemporalResolutionResult,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _utcnow_iso() -> str:
|
|
45
|
+
return datetime.now(timezone.utc).isoformat()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _row_to_memory(row: aiosqlite.Row) -> Memory:
|
|
49
|
+
"""Convert a SQLite row to a Memory model."""
|
|
50
|
+
data = dict(row)
|
|
51
|
+
|
|
52
|
+
# Parse JSON fields
|
|
53
|
+
data["tags"] = json.loads(data.get("tags", "[]"))
|
|
54
|
+
slot_json = data.pop("slot_json", None)
|
|
55
|
+
data.pop("slot_key", None)
|
|
56
|
+
data["slot"] = MemorySlot.model_validate_json(slot_json) if slot_json else None
|
|
57
|
+
|
|
58
|
+
# Convert UUID strings
|
|
59
|
+
for field in ("id", "provenance_event_id", "superseded_by", "supersedes"):
|
|
60
|
+
if data.get(field) is not None:
|
|
61
|
+
data[field] = UUID(data[field])
|
|
62
|
+
|
|
63
|
+
# Convert enum strings
|
|
64
|
+
data["status"] = MemoryStatus(data["status"])
|
|
65
|
+
data["type"] = MemoryType(data["type"])
|
|
66
|
+
data["privacy_level"] = PrivacyLevel(data["privacy_level"])
|
|
67
|
+
data["temporal_precision"] = TemporalPrecision(
|
|
68
|
+
data.get("temporal_precision", TemporalPrecision.UNKNOWN.value)
|
|
69
|
+
)
|
|
70
|
+
data["temporal_status"] = CandidateTemporalStatus(
|
|
71
|
+
data.get("temporal_status", CandidateTemporalStatus.UNSPECIFIED.value)
|
|
72
|
+
)
|
|
73
|
+
data["uncertain"] = bool(data.get("uncertain", 0))
|
|
74
|
+
data["negated"] = bool(data.get("negated", 0))
|
|
75
|
+
|
|
76
|
+
# Parse datetime strings
|
|
77
|
+
for field in (
|
|
78
|
+
"created_at", "updated_at", "last_accessed_at", "expires_at",
|
|
79
|
+
"observed_at", "valid_from", "valid_to",
|
|
80
|
+
):
|
|
81
|
+
val = data.get(field)
|
|
82
|
+
if val is not None:
|
|
83
|
+
parsed = datetime.fromisoformat(val)
|
|
84
|
+
data[field] = parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
|
|
85
|
+
|
|
86
|
+
if data.get("observed_at") is None:
|
|
87
|
+
data["observed_at"] = data["created_at"]
|
|
88
|
+
|
|
89
|
+
return Memory.model_validate(data)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _memory_to_row(memory: Memory) -> dict:
|
|
93
|
+
"""Convert a Memory model to a dict suitable for SQLite insertion."""
|
|
94
|
+
return {
|
|
95
|
+
"id": str(memory.id),
|
|
96
|
+
"content": memory.content,
|
|
97
|
+
"content_hash": memory.content_hash,
|
|
98
|
+
"type": memory.type.value,
|
|
99
|
+
"source_type": memory.source_type,
|
|
100
|
+
"source_uri": memory.source_uri,
|
|
101
|
+
"provenance_event_id": str(memory.provenance_event_id) if memory.provenance_event_id else None,
|
|
102
|
+
"status": memory.status.value,
|
|
103
|
+
"confidence": memory.confidence,
|
|
104
|
+
"importance": memory.importance,
|
|
105
|
+
"privacy_level": memory.privacy_level.value,
|
|
106
|
+
"token_count": memory.token_count,
|
|
107
|
+
"embedding_id": memory.embedding_id,
|
|
108
|
+
"superseded_by": str(memory.superseded_by) if memory.superseded_by else None,
|
|
109
|
+
"supersedes": str(memory.supersedes) if memory.supersedes else None,
|
|
110
|
+
"observed_at": memory.observed_at.astimezone(timezone.utc).isoformat(),
|
|
111
|
+
"valid_from": (
|
|
112
|
+
memory.valid_from.astimezone(timezone.utc).isoformat()
|
|
113
|
+
if memory.valid_from else None
|
|
114
|
+
),
|
|
115
|
+
"valid_to": (
|
|
116
|
+
memory.valid_to.astimezone(timezone.utc).isoformat()
|
|
117
|
+
if memory.valid_to else None
|
|
118
|
+
),
|
|
119
|
+
"temporal_precision": memory.temporal_precision.value,
|
|
120
|
+
"temporal_status": memory.temporal_status.value,
|
|
121
|
+
"temporal_expression": memory.temporal_expression,
|
|
122
|
+
"slot_json": memory.slot.model_dump_json() if memory.slot else None,
|
|
123
|
+
"slot_key": memory.slot.key if memory.slot else None,
|
|
124
|
+
"uncertain": int(memory.uncertain),
|
|
125
|
+
"negated": int(memory.negated),
|
|
126
|
+
"resolution_reason": memory.resolution_reason,
|
|
127
|
+
"resolution_confidence": memory.resolution_confidence,
|
|
128
|
+
"access_count": memory.access_count,
|
|
129
|
+
"created_at": memory.created_at.astimezone(timezone.utc).isoformat(),
|
|
130
|
+
"updated_at": memory.updated_at.astimezone(timezone.utc).isoformat(),
|
|
131
|
+
"last_accessed_at": (
|
|
132
|
+
memory.last_accessed_at.astimezone(timezone.utc).isoformat()
|
|
133
|
+
if memory.last_accessed_at
|
|
134
|
+
else None
|
|
135
|
+
),
|
|
136
|
+
"expires_at": (
|
|
137
|
+
memory.expires_at.astimezone(timezone.utc).isoformat()
|
|
138
|
+
if memory.expires_at
|
|
139
|
+
else None
|
|
140
|
+
),
|
|
141
|
+
"version": memory.version,
|
|
142
|
+
"tags": json.dumps(memory.tags),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SqliteMemoryRepository:
|
|
147
|
+
"""SQLite implementation of the MemoryRepository protocol."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, db: aiosqlite.Connection) -> None:
|
|
150
|
+
self._db = db
|
|
151
|
+
self._write_lock = asyncio.Lock()
|
|
152
|
+
|
|
153
|
+
@asynccontextmanager
|
|
154
|
+
async def _transaction(self):
|
|
155
|
+
async with self._write_lock:
|
|
156
|
+
await self._db.execute("BEGIN IMMEDIATE")
|
|
157
|
+
try:
|
|
158
|
+
yield
|
|
159
|
+
await self._db.commit()
|
|
160
|
+
except BaseException:
|
|
161
|
+
await self._db.rollback()
|
|
162
|
+
raise
|
|
163
|
+
|
|
164
|
+
async def get(self, memory_id: UUID) -> Memory | None:
|
|
165
|
+
cursor = await self._db.execute(
|
|
166
|
+
"SELECT * FROM memories WHERE id = ?", (str(memory_id),)
|
|
167
|
+
)
|
|
168
|
+
row = await cursor.fetchone()
|
|
169
|
+
if row is None:
|
|
170
|
+
return None
|
|
171
|
+
return _row_to_memory(row)
|
|
172
|
+
|
|
173
|
+
async def get_many(self, memory_ids: set[str]) -> dict[str, Memory]:
|
|
174
|
+
"""Hydrate ranked index candidates in bounded SQLite batches."""
|
|
175
|
+
values: dict[str, Memory] = {}
|
|
176
|
+
ordered = sorted(memory_ids)
|
|
177
|
+
for start in range(0, len(ordered), 400):
|
|
178
|
+
batch = ordered[start:start + 400]
|
|
179
|
+
placeholders = ", ".join("?" for _ in batch)
|
|
180
|
+
cursor = await self._db.execute(
|
|
181
|
+
f"SELECT * FROM memories WHERE id IN ({placeholders})", batch
|
|
182
|
+
)
|
|
183
|
+
for row in await cursor.fetchall():
|
|
184
|
+
memory = _row_to_memory(row)
|
|
185
|
+
values[str(memory.id)] = memory
|
|
186
|
+
return values
|
|
187
|
+
|
|
188
|
+
async def list(self, filters: MemoryFilters) -> list[Memory]:
|
|
189
|
+
query = "SELECT * FROM memories WHERE 1=1"
|
|
190
|
+
params: list = []
|
|
191
|
+
|
|
192
|
+
if filters.status is not None:
|
|
193
|
+
query += " AND status = ?"
|
|
194
|
+
params.append(filters.status.value)
|
|
195
|
+
|
|
196
|
+
if filters.type is not None:
|
|
197
|
+
query += " AND type = ?"
|
|
198
|
+
params.append(filters.type.value)
|
|
199
|
+
|
|
200
|
+
if filters.privacy_level is not None:
|
|
201
|
+
query += " AND privacy_level = ?"
|
|
202
|
+
params.append(filters.privacy_level.value)
|
|
203
|
+
|
|
204
|
+
if filters.source_type is not None:
|
|
205
|
+
query += " AND source_type = ?"
|
|
206
|
+
params.append(filters.source_type)
|
|
207
|
+
|
|
208
|
+
if filters.min_confidence is not None:
|
|
209
|
+
query += " AND confidence >= ?"
|
|
210
|
+
params.append(filters.min_confidence)
|
|
211
|
+
|
|
212
|
+
if filters.min_importance is not None:
|
|
213
|
+
query += " AND importance >= ?"
|
|
214
|
+
params.append(filters.min_importance)
|
|
215
|
+
|
|
216
|
+
if filters.created_after is not None:
|
|
217
|
+
query += " AND julianday(created_at) >= julianday(?)"
|
|
218
|
+
params.append(filters.created_after.astimezone(timezone.utc).isoformat())
|
|
219
|
+
|
|
220
|
+
if filters.created_before is not None:
|
|
221
|
+
query += " AND julianday(created_at) <= julianday(?)"
|
|
222
|
+
params.append(filters.created_before.astimezone(timezone.utc).isoformat())
|
|
223
|
+
|
|
224
|
+
# Tag filtering: use JSON contains (SQLite json_each)
|
|
225
|
+
if filters.tags:
|
|
226
|
+
for tag in filters.tags:
|
|
227
|
+
query += " AND EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)"
|
|
228
|
+
params.append(tag)
|
|
229
|
+
|
|
230
|
+
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
231
|
+
params.extend([filters.limit, filters.offset])
|
|
232
|
+
|
|
233
|
+
cursor = await self._db.execute(query, params)
|
|
234
|
+
rows = await cursor.fetchall()
|
|
235
|
+
return [_row_to_memory(row) for row in rows]
|
|
236
|
+
|
|
237
|
+
async def create(self, memory: Memory) -> Memory:
|
|
238
|
+
row = _memory_to_row(memory)
|
|
239
|
+
columns = ", ".join(row.keys())
|
|
240
|
+
placeholders = ", ".join("?" for _ in row)
|
|
241
|
+
|
|
242
|
+
async with self._transaction():
|
|
243
|
+
try:
|
|
244
|
+
cursor = await self._db.execute(
|
|
245
|
+
f"INSERT INTO memories ({columns}) VALUES ({placeholders})",
|
|
246
|
+
list(row.values()),
|
|
247
|
+
)
|
|
248
|
+
except aiosqlite.IntegrityError as exc:
|
|
249
|
+
if await self.get(memory.id) is not None:
|
|
250
|
+
raise DuplicateMemoryError(str(memory.id)) from exc
|
|
251
|
+
raise
|
|
252
|
+
logger.debug("Created memory %s", memory.id)
|
|
253
|
+
return memory
|
|
254
|
+
|
|
255
|
+
async def update(
|
|
256
|
+
self, memory_id: UUID, update: MemoryUpdate, expected_version: int
|
|
257
|
+
) -> Memory:
|
|
258
|
+
# Build update fields
|
|
259
|
+
fields: dict = {}
|
|
260
|
+
if update.content is not None:
|
|
261
|
+
fields["content"] = update.content
|
|
262
|
+
# Recompute content hash
|
|
263
|
+
from contextos.core.models import _content_hash
|
|
264
|
+
fields["content_hash"] = _content_hash(update.content)
|
|
265
|
+
if update.type is not None:
|
|
266
|
+
fields["type"] = update.type.value
|
|
267
|
+
if update.confidence is not None:
|
|
268
|
+
fields["confidence"] = update.confidence
|
|
269
|
+
if update.importance is not None:
|
|
270
|
+
fields["importance"] = update.importance
|
|
271
|
+
if update.privacy_level is not None:
|
|
272
|
+
fields["privacy_level"] = update.privacy_level.value
|
|
273
|
+
if update.expires_at is not None:
|
|
274
|
+
fields["expires_at"] = update.expires_at.astimezone(timezone.utc).isoformat()
|
|
275
|
+
if update.tags is not None:
|
|
276
|
+
fields["tags"] = json.dumps(update.tags)
|
|
277
|
+
|
|
278
|
+
async with self._transaction():
|
|
279
|
+
current = await self._require_version(memory_id, expected_version)
|
|
280
|
+
if not fields:
|
|
281
|
+
return current
|
|
282
|
+
fields["updated_at"] = _utcnow_iso()
|
|
283
|
+
fields["version"] = expected_version + 1
|
|
284
|
+
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
|
285
|
+
values = list(fields.values()) + [str(memory_id), expected_version]
|
|
286
|
+
await self._db.execute(
|
|
287
|
+
f"UPDATE memories SET {set_clause} WHERE id = ? AND version = ?", values
|
|
288
|
+
)
|
|
289
|
+
updated = await self.get(memory_id)
|
|
290
|
+
assert updated is not None
|
|
291
|
+
return updated
|
|
292
|
+
|
|
293
|
+
async def _require_version(self, memory_id: UUID, expected_version: int) -> Memory:
|
|
294
|
+
current = await self.get(memory_id)
|
|
295
|
+
if current is None:
|
|
296
|
+
raise MemoryNotFoundError(str(memory_id))
|
|
297
|
+
if current.version != expected_version:
|
|
298
|
+
raise ConcurrencyError(str(memory_id), expected_version, current.version)
|
|
299
|
+
return current
|
|
300
|
+
|
|
301
|
+
async def update_status(
|
|
302
|
+
self, memory_id: UUID, new_status: MemoryStatus, expected_version: int
|
|
303
|
+
) -> Memory:
|
|
304
|
+
async with self._transaction():
|
|
305
|
+
current = await self._require_version(memory_id, expected_version)
|
|
306
|
+
# These states require a separate operation with additional data or
|
|
307
|
+
# physical deletion; a status-only write would leave invalid history.
|
|
308
|
+
if new_status in (MemoryStatus.SUPERSEDED, MemoryStatus.MERGED,
|
|
309
|
+
MemoryStatus.PURGED) or new_status not in VALID_TRANSITIONS[current.status]:
|
|
310
|
+
raise InvalidTransitionError(str(memory_id), current.status.value, new_status.value)
|
|
311
|
+
await self._db.execute(
|
|
312
|
+
"UPDATE memories SET status = ?, updated_at = ?, version = version + 1 "
|
|
313
|
+
"WHERE id = ? AND version = ?",
|
|
314
|
+
(new_status.value, _utcnow_iso(), str(memory_id), expected_version),
|
|
315
|
+
)
|
|
316
|
+
updated = await self.get(memory_id)
|
|
317
|
+
assert updated is not None
|
|
318
|
+
return updated
|
|
319
|
+
|
|
320
|
+
async def supersede(self, old_id: UUID, successor: Memory, expected_version: int) -> Memory:
|
|
321
|
+
"""Store successor and link both records atomically; retain the old record."""
|
|
322
|
+
if old_id == successor.id:
|
|
323
|
+
raise ValueError("A memory cannot supersede itself")
|
|
324
|
+
async with self._transaction():
|
|
325
|
+
old = await self._require_version(old_id, expected_version)
|
|
326
|
+
if MemoryStatus.SUPERSEDED not in VALID_TRANSITIONS[old.status]:
|
|
327
|
+
raise InvalidTransitionError(str(old_id), old.status.value, MemoryStatus.SUPERSEDED.value)
|
|
328
|
+
if successor.status != MemoryStatus.ACTIVE or successor.supersedes not in (None, old_id):
|
|
329
|
+
raise ValueError("Successor must be active and refer to the old memory")
|
|
330
|
+
successor = successor.model_copy(update={"supersedes": old_id})
|
|
331
|
+
row = _memory_to_row(successor)
|
|
332
|
+
columns = ", ".join(row)
|
|
333
|
+
placeholders = ", ".join("?" for _ in row)
|
|
334
|
+
try:
|
|
335
|
+
await self._db.execute(
|
|
336
|
+
f"INSERT INTO memories ({columns}) VALUES ({placeholders})", list(row.values())
|
|
337
|
+
)
|
|
338
|
+
except aiosqlite.IntegrityError as exc:
|
|
339
|
+
if await self.get(successor.id) is not None:
|
|
340
|
+
raise DuplicateMemoryError(str(successor.id)) from exc
|
|
341
|
+
raise
|
|
342
|
+
await self._db.execute(
|
|
343
|
+
"UPDATE memories SET status = ?, superseded_by = ?, updated_at = ?, "
|
|
344
|
+
"version = version + 1 WHERE id = ? AND version = ?",
|
|
345
|
+
(MemoryStatus.SUPERSEDED.value, str(successor.id), _utcnow_iso(),
|
|
346
|
+
str(old_id), expected_version),
|
|
347
|
+
)
|
|
348
|
+
return successor
|
|
349
|
+
|
|
350
|
+
async def update_access(self, memory_id: UUID) -> None:
|
|
351
|
+
now = _utcnow_iso()
|
|
352
|
+
async with self._transaction():
|
|
353
|
+
await self._db.execute(
|
|
354
|
+
"UPDATE memories SET access_count = access_count + 1, "
|
|
355
|
+
"last_accessed_at = ? WHERE id = ?",
|
|
356
|
+
(now, str(memory_id)),
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
async def delete(self, memory_id: UUID) -> None:
|
|
360
|
+
async with self._transaction():
|
|
361
|
+
await self._db.execute("DELETE FROM memories WHERE id = ?", (str(memory_id),))
|
|
362
|
+
logger.debug("Hard-deleted memory %s from database", memory_id)
|
|
363
|
+
|
|
364
|
+
async def count(self, filters: MemoryFilters | None = None) -> int:
|
|
365
|
+
if filters is None:
|
|
366
|
+
cursor = await self._db.execute("SELECT COUNT(*) FROM memories")
|
|
367
|
+
else:
|
|
368
|
+
query = "SELECT COUNT(*) FROM memories WHERE 1=1"
|
|
369
|
+
params: list = []
|
|
370
|
+
|
|
371
|
+
if filters.status is not None:
|
|
372
|
+
query += " AND status = ?"
|
|
373
|
+
params.append(filters.status.value)
|
|
374
|
+
if filters.type is not None:
|
|
375
|
+
query += " AND type = ?"
|
|
376
|
+
params.append(filters.type.value)
|
|
377
|
+
|
|
378
|
+
cursor = await self._db.execute(query, params)
|
|
379
|
+
|
|
380
|
+
row = await cursor.fetchone()
|
|
381
|
+
return row[0] if row else 0
|
|
382
|
+
|
|
383
|
+
async def get_by_hash(self, content_hash: str) -> Memory | None:
|
|
384
|
+
cursor = await self._db.execute(
|
|
385
|
+
"SELECT * FROM memories WHERE content_hash = ? AND status != ? LIMIT 1",
|
|
386
|
+
(content_hash, MemoryStatus.DELETED.value),
|
|
387
|
+
)
|
|
388
|
+
row = await cursor.fetchone()
|
|
389
|
+
if row is None:
|
|
390
|
+
return None
|
|
391
|
+
return _row_to_memory(row)
|
|
392
|
+
|
|
393
|
+
async def list_by_slot(self, slot_key: str) -> list[Memory]:
|
|
394
|
+
cursor = await self._db.execute(
|
|
395
|
+
"SELECT * FROM memories WHERE slot_key = ? ORDER BY "
|
|
396
|
+
"COALESCE(valid_from, observed_at, created_at), created_at, id",
|
|
397
|
+
(slot_key,),
|
|
398
|
+
)
|
|
399
|
+
return [_row_to_memory(row) for row in await cursor.fetchall()]
|
|
400
|
+
|
|
401
|
+
async def list_temporal(self, *, limit: int = 500) -> list[Memory]:
|
|
402
|
+
cursor = await self._db.execute(
|
|
403
|
+
"SELECT * FROM memories WHERE slot_key IS NOT NULL "
|
|
404
|
+
"ORDER BY observed_at, created_at, id LIMIT ?",
|
|
405
|
+
(limit,),
|
|
406
|
+
)
|
|
407
|
+
return [_row_to_memory(row) for row in await cursor.fetchall()]
|
|
408
|
+
|
|
409
|
+
async def latest_active_peer(self, slot: MemorySlot) -> Memory | None:
|
|
410
|
+
"""Find the latest active same-property memory in another scope.
|
|
411
|
+
|
|
412
|
+
The old global 500-row temporal scan could silently miss a peer and
|
|
413
|
+
materialized unrelated private content on every acceptance.
|
|
414
|
+
"""
|
|
415
|
+
cursor = await self._db.execute(
|
|
416
|
+
"SELECT * FROM memories WHERE status = ? AND slot_key IS NOT NULL "
|
|
417
|
+
"AND slot_key != ? AND json_extract(slot_json, '$.subject') = ? "
|
|
418
|
+
"AND json_extract(slot_json, '$.property') = ? "
|
|
419
|
+
"ORDER BY COALESCE(valid_from, observed_at, created_at) DESC, "
|
|
420
|
+
"observed_at DESC, id DESC LIMIT 1",
|
|
421
|
+
(MemoryStatus.ACTIVE.value, slot.key, slot.subject, slot.property),
|
|
422
|
+
)
|
|
423
|
+
row = await cursor.fetchone()
|
|
424
|
+
return _row_to_memory(row) if row else None
|
|
425
|
+
|
|
426
|
+
async def apply_temporal_decision(
|
|
427
|
+
self,
|
|
428
|
+
candidate: Memory,
|
|
429
|
+
decision: TemporalDecision,
|
|
430
|
+
) -> TemporalResolutionResult:
|
|
431
|
+
"""Apply one resolver plan atomically, including lifecycle relations."""
|
|
432
|
+
if candidate.id != decision.candidate_id:
|
|
433
|
+
raise ValueError("Temporal decision does not belong to candidate")
|
|
434
|
+
if decision.outcome in {TemporalOutcome.DUPLICATE, TemporalOutcome.NO_CHANGE}:
|
|
435
|
+
if decision.related_memory_id is None:
|
|
436
|
+
raise ValueError("No-change decisions require an existing memory")
|
|
437
|
+
existing = await self.get(decision.related_memory_id)
|
|
438
|
+
if existing is None:
|
|
439
|
+
raise MemoryNotFoundError(str(decision.related_memory_id))
|
|
440
|
+
return TemporalResolutionResult(decision=decision, memory=existing)
|
|
441
|
+
|
|
442
|
+
related = (
|
|
443
|
+
await self.get(decision.related_memory_id)
|
|
444
|
+
if decision.related_memory_id is not None else None
|
|
445
|
+
)
|
|
446
|
+
if decision.related_memory_id is not None and related is None:
|
|
447
|
+
raise MemoryNotFoundError(str(decision.related_memory_id))
|
|
448
|
+
if related and related.status in {MemoryStatus.DELETED, MemoryStatus.PURGED}:
|
|
449
|
+
raise InvalidTransitionError(
|
|
450
|
+
str(related.id), related.status.value, MemoryStatus.ACTIVE.value
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
final_status = (
|
|
454
|
+
MemoryStatus.HISTORICAL
|
|
455
|
+
if candidate.temporal_status == CandidateTemporalStatus.HISTORICAL
|
|
456
|
+
else MemoryStatus.ACTIVE
|
|
457
|
+
)
|
|
458
|
+
successor_of: UUID | None = None
|
|
459
|
+
if decision.outcome in {TemporalOutcome.SUPERSEDE, TemporalOutcome.CORRECT}:
|
|
460
|
+
if related is None or related.status != MemoryStatus.ACTIVE:
|
|
461
|
+
raise InvalidTransitionError(
|
|
462
|
+
str(decision.related_memory_id),
|
|
463
|
+
related.status.value if related else "missing",
|
|
464
|
+
MemoryStatus.SUPERSEDED.value,
|
|
465
|
+
)
|
|
466
|
+
successor_of = related.id
|
|
467
|
+
if decision.outcome == TemporalOutcome.CONTRADICT:
|
|
468
|
+
if related is None:
|
|
469
|
+
raise ValueError("Contradiction requires a related memory")
|
|
470
|
+
final_status = MemoryStatus.CONTRADICTED
|
|
471
|
+
|
|
472
|
+
stored = candidate.model_copy(update={
|
|
473
|
+
"status": final_status,
|
|
474
|
+
"supersedes": successor_of,
|
|
475
|
+
"resolution_reason": decision.outcome.value,
|
|
476
|
+
"resolution_confidence": decision.confidence,
|
|
477
|
+
})
|
|
478
|
+
relations: list[MemoryRelation] = []
|
|
479
|
+
affected_ids: list[UUID] = []
|
|
480
|
+
async with self._transaction():
|
|
481
|
+
row = _memory_to_row(stored)
|
|
482
|
+
columns = ", ".join(row)
|
|
483
|
+
placeholders = ", ".join("?" for _ in row)
|
|
484
|
+
await self._db.execute(
|
|
485
|
+
f"INSERT INTO memories ({columns}) VALUES ({placeholders})",
|
|
486
|
+
list(row.values()),
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if related and decision.outcome in {
|
|
490
|
+
TemporalOutcome.SUPERSEDE, TemporalOutcome.CORRECT,
|
|
491
|
+
}:
|
|
492
|
+
boundary = stored.valid_from or stored.observed_at
|
|
493
|
+
cursor = await self._db.execute(
|
|
494
|
+
"UPDATE memories SET status = ?, superseded_by = ?, valid_to = ?, "
|
|
495
|
+
"resolution_reason = ?, resolution_confidence = ?, updated_at = ?, "
|
|
496
|
+
"version = version + 1 WHERE id = ? AND version = ?",
|
|
497
|
+
(
|
|
498
|
+
MemoryStatus.SUPERSEDED.value,
|
|
499
|
+
str(stored.id),
|
|
500
|
+
boundary.astimezone(timezone.utc).isoformat(),
|
|
501
|
+
decision.outcome.value,
|
|
502
|
+
decision.confidence,
|
|
503
|
+
_utcnow_iso(),
|
|
504
|
+
str(related.id),
|
|
505
|
+
related.version,
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
if cursor.rowcount != 1:
|
|
509
|
+
raise ConcurrencyError(str(related.id), related.version, -1)
|
|
510
|
+
relation_type = (
|
|
511
|
+
RelationType.CORRECTS
|
|
512
|
+
if decision.outcome == TemporalOutcome.CORRECT
|
|
513
|
+
else RelationType.SUPERSEDES
|
|
514
|
+
)
|
|
515
|
+
relations.append(MemoryRelation(
|
|
516
|
+
source_memory_id=stored.id,
|
|
517
|
+
target_memory_id=related.id,
|
|
518
|
+
relation_type=relation_type,
|
|
519
|
+
confidence=decision.confidence,
|
|
520
|
+
metadata={"reason": decision.outcome.value},
|
|
521
|
+
))
|
|
522
|
+
affected_ids.append(related.id)
|
|
523
|
+
|
|
524
|
+
elif related and decision.outcome == TemporalOutcome.CONTRADICT:
|
|
525
|
+
cursor = await self._db.execute(
|
|
526
|
+
"UPDATE memories SET status = ?, resolution_reason = ?, "
|
|
527
|
+
"resolution_confidence = ?, updated_at = ?, version = version + 1 "
|
|
528
|
+
"WHERE id = ? AND version = ?",
|
|
529
|
+
(
|
|
530
|
+
MemoryStatus.CONTRADICTED.value,
|
|
531
|
+
decision.outcome.value,
|
|
532
|
+
decision.confidence,
|
|
533
|
+
_utcnow_iso(),
|
|
534
|
+
str(related.id),
|
|
535
|
+
related.version,
|
|
536
|
+
),
|
|
537
|
+
)
|
|
538
|
+
if cursor.rowcount != 1:
|
|
539
|
+
raise ConcurrencyError(str(related.id), related.version, -1)
|
|
540
|
+
relations.extend([
|
|
541
|
+
MemoryRelation(
|
|
542
|
+
source_memory_id=stored.id,
|
|
543
|
+
target_memory_id=related.id,
|
|
544
|
+
relation_type=RelationType.CONTRADICTS,
|
|
545
|
+
confidence=decision.confidence,
|
|
546
|
+
),
|
|
547
|
+
MemoryRelation(
|
|
548
|
+
source_memory_id=related.id,
|
|
549
|
+
target_memory_id=stored.id,
|
|
550
|
+
relation_type=RelationType.CONTRADICTS,
|
|
551
|
+
confidence=decision.confidence,
|
|
552
|
+
),
|
|
553
|
+
])
|
|
554
|
+
affected_ids.append(related.id)
|
|
555
|
+
|
|
556
|
+
elif related and decision.outcome == TemporalOutcome.COEXIST:
|
|
557
|
+
relations.append(MemoryRelation(
|
|
558
|
+
source_memory_id=stored.id,
|
|
559
|
+
target_memory_id=related.id,
|
|
560
|
+
relation_type=RelationType.COEXISTS_WITH,
|
|
561
|
+
confidence=decision.confidence,
|
|
562
|
+
))
|
|
563
|
+
|
|
564
|
+
for relation in relations:
|
|
565
|
+
await self._db.execute(
|
|
566
|
+
"INSERT INTO memory_relations "
|
|
567
|
+
"(id, source_memory_id, target_memory_id, relation_type, confidence, "
|
|
568
|
+
"created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
569
|
+
(
|
|
570
|
+
str(relation.id), str(relation.source_memory_id),
|
|
571
|
+
str(relation.target_memory_id), relation.relation_type.value,
|
|
572
|
+
relation.confidence,
|
|
573
|
+
relation.created_at.astimezone(timezone.utc).isoformat(),
|
|
574
|
+
json.dumps(relation.metadata),
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
persisted = await self.get(stored.id)
|
|
579
|
+
assert persisted is not None
|
|
580
|
+
affected = [
|
|
581
|
+
memory for memory_id in affected_ids
|
|
582
|
+
if (memory := await self.get(memory_id)) is not None
|
|
583
|
+
]
|
|
584
|
+
return TemporalResolutionResult(
|
|
585
|
+
decision=decision,
|
|
586
|
+
memory=persisted,
|
|
587
|
+
affected_memories=affected,
|
|
588
|
+
relations=relations,
|
|
589
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""SQLite persistence for typed memory relationships."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
import aiosqlite
|
|
10
|
+
|
|
11
|
+
from contextos.core.enums import RelationType
|
|
12
|
+
from contextos.core.models import MemoryRelation
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _relation_from_row(row: aiosqlite.Row) -> MemoryRelation:
|
|
16
|
+
data = dict(row)
|
|
17
|
+
data["id"] = UUID(data["id"])
|
|
18
|
+
data["source_memory_id"] = UUID(data["source_memory_id"])
|
|
19
|
+
data["target_memory_id"] = UUID(data["target_memory_id"])
|
|
20
|
+
data["relation_type"] = RelationType(data["relation_type"])
|
|
21
|
+
data["metadata"] = json.loads(data["metadata"])
|
|
22
|
+
created_at = datetime.fromisoformat(data["created_at"])
|
|
23
|
+
data["created_at"] = (
|
|
24
|
+
created_at.replace(tzinfo=timezone.utc)
|
|
25
|
+
if created_at.tzinfo is None else created_at
|
|
26
|
+
)
|
|
27
|
+
return MemoryRelation.model_validate(data)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SqliteRelationRepository:
|
|
31
|
+
"""Small relation repository sharing the main SQLite transaction domain."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, db: aiosqlite.Connection) -> None:
|
|
34
|
+
self._db = db
|
|
35
|
+
|
|
36
|
+
async def create(self, relation: MemoryRelation) -> MemoryRelation:
|
|
37
|
+
await self._db.execute(
|
|
38
|
+
"INSERT INTO memory_relations "
|
|
39
|
+
"(id, source_memory_id, target_memory_id, relation_type, confidence, "
|
|
40
|
+
"created_at, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
41
|
+
(
|
|
42
|
+
str(relation.id), str(relation.source_memory_id),
|
|
43
|
+
str(relation.target_memory_id), relation.relation_type.value,
|
|
44
|
+
relation.confidence,
|
|
45
|
+
relation.created_at.astimezone(timezone.utc).isoformat(),
|
|
46
|
+
json.dumps(relation.metadata),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
await self._db.commit()
|
|
50
|
+
return relation
|
|
51
|
+
|
|
52
|
+
async def get_relations(
|
|
53
|
+
self, memory_id: UUID, direction: str = "both"
|
|
54
|
+
) -> list[MemoryRelation]:
|
|
55
|
+
if direction == "outgoing":
|
|
56
|
+
clause = "source_memory_id = ?"
|
|
57
|
+
params = (str(memory_id),)
|
|
58
|
+
elif direction == "incoming":
|
|
59
|
+
clause = "target_memory_id = ?"
|
|
60
|
+
params = (str(memory_id),)
|
|
61
|
+
elif direction == "both":
|
|
62
|
+
clause = "source_memory_id = ? OR target_memory_id = ?"
|
|
63
|
+
params = (str(memory_id), str(memory_id))
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError("direction must be outgoing, incoming, or both")
|
|
66
|
+
cursor = await self._db.execute(
|
|
67
|
+
f"SELECT * FROM memory_relations WHERE {clause} "
|
|
68
|
+
"ORDER BY created_at, id",
|
|
69
|
+
params,
|
|
70
|
+
)
|
|
71
|
+
return [_relation_from_row(row) for row in await cursor.fetchall()]
|
|
72
|
+
|
|
73
|
+
async def delete_for_memory(self, memory_id: UUID) -> int:
|
|
74
|
+
cursor = await self._db.execute(
|
|
75
|
+
"DELETE FROM memory_relations "
|
|
76
|
+
"WHERE source_memory_id = ? OR target_memory_id = ?",
|
|
77
|
+
(str(memory_id), str(memory_id)),
|
|
78
|
+
)
|
|
79
|
+
await self._db.commit()
|
|
80
|
+
return cursor.rowcount
|