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.
Files changed (93) hide show
  1. contextos/__init__.py +3 -0
  2. contextos/__main__.py +6 -0
  3. contextos/api/__init__.py +1 -0
  4. contextos/api/routes/__init__.py +1 -0
  5. contextos/api/routes/desktop.py +322 -0
  6. contextos/api/routes/ingest.py +17 -0
  7. contextos/api/routes/memories.py +84 -0
  8. contextos/api/routes/models.py +81 -0
  9. contextos/api/routes/retrieval.py +89 -0
  10. contextos/api/routes/system.py +216 -0
  11. contextos/api/server.py +195 -0
  12. contextos/benchmarks/__init__.py +1 -0
  13. contextos/benchmarks/compilation.py +245 -0
  14. contextos/benchmarks/connectors.py +423 -0
  15. contextos/benchmarks/explainability.py +103 -0
  16. contextos/benchmarks/final.py +406 -0
  17. contextos/benchmarks/graph.py +310 -0
  18. contextos/benchmarks/graph_adversarial.py +525 -0
  19. contextos/benchmarks/mcp.py +324 -0
  20. contextos/benchmarks/model_routing.py +203 -0
  21. contextos/benchmarks/optimization.py +305 -0
  22. contextos/benchmarks/rescue_integration.py +127 -0
  23. contextos/benchmarks/retrieval.py +266 -0
  24. contextos/benchmarks/temporal.py +377 -0
  25. contextos/benchmarks/temporal_hotpath.py +76 -0
  26. contextos/benchmarks/terminal.py +62 -0
  27. contextos/cli/__init__.py +1 -0
  28. contextos/cli/app.py +932 -0
  29. contextos/cli/dashboard.py +174 -0
  30. contextos/cli/formatters.py +299 -0
  31. contextos/config/__init__.py +1 -0
  32. contextos/config/settings.py +160 -0
  33. contextos/connectors/__init__.py +6 -0
  34. contextos/connectors/fake.py +11 -0
  35. contextos/connectors/json_import.py +125 -0
  36. contextos/connectors/local_files.py +102 -0
  37. contextos/connectors/manager.py +293 -0
  38. contextos/connectors/models.py +62 -0
  39. contextos/connectors/protocols.py +11 -0
  40. contextos/core/__init__.py +103 -0
  41. contextos/core/enums.py +489 -0
  42. contextos/core/exceptions.py +293 -0
  43. contextos/core/models.py +1147 -0
  44. contextos/core/protocols.py +549 -0
  45. contextos/daemon/__init__.py +1 -0
  46. contextos/daemon/manager.py +510 -0
  47. contextos/daemon/state.py +127 -0
  48. contextos/daemon/wiring.py +296 -0
  49. contextos/demo.py +217 -0
  50. contextos/embedding/__init__.py +1 -0
  51. contextos/embedding/deterministic.py +76 -0
  52. contextos/embedding/sentence_transformers.py +80 -0
  53. contextos/mcp/__init__.py +5 -0
  54. contextos/mcp/server.py +269 -0
  55. contextos/providers/__init__.py +13 -0
  56. contextos/providers/fake.py +217 -0
  57. contextos/providers/ollama.py +297 -0
  58. contextos/providers/openai_compatible.py +337 -0
  59. contextos/services/__init__.py +1 -0
  60. contextos/services/compilation.py +535 -0
  61. contextos/services/explainability.py +553 -0
  62. contextos/services/extraction.py +311 -0
  63. contextos/services/graph.py +524 -0
  64. contextos/services/graph_retrieval.py +143 -0
  65. contextos/services/ingestion.py +143 -0
  66. contextos/services/inspection.py +174 -0
  67. contextos/services/memory.py +291 -0
  68. contextos/services/model_service.py +409 -0
  69. contextos/services/optimization.py +426 -0
  70. contextos/services/privacy.py +331 -0
  71. contextos/services/retrieval.py +302 -0
  72. contextos/services/retrieval_index.py +88 -0
  73. contextos/services/router.py +302 -0
  74. contextos/services/secret_scanner.py +207 -0
  75. contextos/services/telemetry_query.py +102 -0
  76. contextos/services/temporal.py +500 -0
  77. contextos/services/token_counter.py +222 -0
  78. contextos/storage/__init__.py +1 -0
  79. contextos/storage/connector_repo.py +67 -0
  80. contextos/storage/database.py +497 -0
  81. contextos/storage/event_repo.py +137 -0
  82. contextos/storage/graph_repo.py +228 -0
  83. contextos/storage/lexical/__init__.py +1 -0
  84. contextos/storage/lexical/bm25.py +134 -0
  85. contextos/storage/memory_repo.py +589 -0
  86. contextos/storage/relation_repo.py +80 -0
  87. contextos/storage/telemetry_repo.py +481 -0
  88. contextos/storage/vector/__init__.py +1 -0
  89. contextos/storage/vector/in_memory.py +162 -0
  90. contextos_memory_runtime-1.0.0rc2.dist-info/METADATA +143 -0
  91. contextos_memory_runtime-1.0.0rc2.dist-info/RECORD +93 -0
  92. contextos_memory_runtime-1.0.0rc2.dist-info/WHEEL +4 -0
  93. 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