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,143 @@
1
+ """Two-stage privacy-gated candidate ingestion for ContextOS Phase 3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextos.core.enums import EventType, PrivacyDecision, SecretDetectionMode
6
+ from contextos.core.exceptions import IngestionError, SecretDetectedError
7
+ from contextos.core.models import IngestRequest, IngestResult, RawEvent
8
+ from contextos.core.protocols import (
9
+ EmbeddingService,
10
+ EventRepository,
11
+ LexicalIndex,
12
+ MemoryExtractor,
13
+ MemoryRepository,
14
+ SecretScanner,
15
+ TokenCounter,
16
+ VectorStore,
17
+ )
18
+ from contextos.services.privacy import PrivacyGate
19
+
20
+
21
+ class IngestionPipeline:
22
+ """Sanitize raw input, extract candidates, and gate candidates again.
23
+
24
+ Raw input exists only in the caller/request and transient local variables.
25
+ Only sanitized event content and value-free findings can reach persistence.
26
+ Later-stage constructor dependencies remain for wiring compatibility and are
27
+ deliberately not invoked during candidate extraction.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ *,
33
+ secret_scanner: SecretScanner,
34
+ memory_extractor: MemoryExtractor,
35
+ memory_repo: MemoryRepository,
36
+ event_repo: EventRepository,
37
+ embedding_service: EmbeddingService,
38
+ vector_store: VectorStore,
39
+ lexical_index: LexicalIndex,
40
+ token_counter: TokenCounter,
41
+ secret_detection_mode: SecretDetectionMode = SecretDetectionMode.STRICT,
42
+ ) -> None:
43
+ self._extractor = memory_extractor
44
+ self._event_repo = event_repo
45
+ self._secret_mode = secret_detection_mode
46
+ self._privacy_gate = PrivacyGate(secret_scanner)
47
+
48
+ async def ingest(self, request: IngestRequest) -> IngestResult:
49
+ warnings: list[str] = []
50
+
51
+ # skip_secret_scan is retained in the request model for compatibility,
52
+ # but it cannot bypass the Phase 3 persistence boundary.
53
+ gated_input = self._privacy_gate.gate_input(
54
+ request.content,
55
+ source_type=request.source_type,
56
+ source_uri=request.source_uri,
57
+ tags=request.tags,
58
+ source_role=request.source_role,
59
+ mode=self._secret_mode,
60
+ )
61
+ assessment = gated_input.assessment
62
+ secrets_detected = assessment.has_findings
63
+ secrets_redacted = assessment.decision in {
64
+ PrivacyDecision.REDACT,
65
+ PrivacyDecision.QUARANTINE,
66
+ }
67
+ safe_assessment = assessment.model_dump(exclude={"sanitized_text"})
68
+
69
+ if assessment.decision == PrivacyDecision.REJECT:
70
+ await self._event_repo.append(RawEvent(
71
+ event_type=EventType.SECRET_DETECTED,
72
+ source_type=gated_input.source_type,
73
+ source_uri=gated_input.source_uri,
74
+ content=None,
75
+ metadata={"privacy_decision": assessment.decision.value},
76
+ privacy_scan_result=safe_assessment,
77
+ ))
78
+ raise SecretDetectedError(
79
+ sorted({finding.category.value for finding in assessment.findings}),
80
+ "Input rejected by the pre-ingest privacy gate.",
81
+ )
82
+
83
+ if len(gated_input.content) > 100_000:
84
+ raise IngestionError("Input exceeds maximum length of 100000 characters")
85
+
86
+ sanitized_content = gated_input.content
87
+ event = RawEvent(
88
+ event_type=EventType.INGEST,
89
+ source_type=gated_input.source_type,
90
+ source_uri=gated_input.source_uri,
91
+ content=sanitized_content,
92
+ metadata={
93
+ "original_length": len(request.content),
94
+ "processed_length": len(sanitized_content),
95
+ "privacy_decision": assessment.decision.value,
96
+ "scan_bypass_requested": request.skip_secret_scan,
97
+ "source_role": request.source_role.value,
98
+ "source_trust": assessment.source_trust.value,
99
+ },
100
+ privacy_scan_result=safe_assessment,
101
+ )
102
+ await self._event_repo.append(event)
103
+
104
+ if assessment.decision == PrivacyDecision.QUARANTINE:
105
+ warnings.append("Input quarantined by the privacy gate; no candidates extracted.")
106
+ return IngestResult(
107
+ event_id=event.id,
108
+ candidates=[],
109
+ privacy_assessment=assessment,
110
+ secrets_detected=secrets_detected,
111
+ secrets_redacted=True,
112
+ warnings=warnings,
113
+ )
114
+
115
+ extracted = await self._extractor.extract(
116
+ text=sanitized_content,
117
+ source_type=gated_input.source_type,
118
+ source_uri=gated_input.source_uri,
119
+ suggested_type=request.memory_type,
120
+ tags=gated_input.tags,
121
+ source_role=request.source_role,
122
+ confirmed_user_information=request.confirmed_user_information,
123
+ )
124
+ candidates, blocked = self._privacy_gate.gate_candidates(
125
+ extracted, source_trust=assessment.source_trust
126
+ )
127
+ if secrets_redacted:
128
+ warnings.append("Sensitive values were redacted before extraction.")
129
+ if blocked:
130
+ warnings.append(f"Privacy gate blocked {len(blocked)} candidate(s).")
131
+ if not candidates:
132
+ warnings.append("No safe memory candidates could be extracted from the input.")
133
+
134
+ # Acceptance, long-term memory persistence, embedding, and indexing are later phases.
135
+ return IngestResult(
136
+ event_id=event.id,
137
+ candidates=candidates,
138
+ privacy_assessment=assessment,
139
+ blocked_candidate_assessments=blocked,
140
+ secrets_detected=secrets_detected,
141
+ secrets_redacted=secrets_redacted,
142
+ warnings=warnings,
143
+ )
@@ -0,0 +1,174 @@
1
+ """Bounded RAG inspection built from one Phase 13 explanation execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from contextos.core.enums import RetrievalMode
12
+ from contextos.core.models import RetrievalQuery
13
+ from contextos.services.explainability import (
14
+ ExplainabilityService,
15
+ ExplanationRequest,
16
+ ExplanationTrace,
17
+ )
18
+ from contextos.services.token_counter import get_token_counter_for_model
19
+
20
+
21
+ class InspectionRequest(ExplanationRequest):
22
+ graph: bool = False
23
+ compare: bool = False
24
+ target_model: str | None = Field(default=None, max_length=200)
25
+
26
+
27
+ class RAGInspection(BaseModel):
28
+ inspection_id: str
29
+ explanation_trace_id: str
30
+ query: dict[str, Any]
31
+ configuration: dict[str, Any]
32
+ stages: list[dict[str, Any]]
33
+ candidates: list[dict[str, Any]]
34
+ context_diff: dict[str, Any]
35
+ final_context: dict[str, Any]
36
+ requested_memory: dict[str, Any] | None = None
37
+ comparison: dict[str, Any] | None = None
38
+ provider_dispatch: dict[str, Any]
39
+ timing: dict[str, float]
40
+ content: str | None = None
41
+
42
+
43
+ class RAGInspector:
44
+ def __init__(self, services: dict[str, Any]) -> None:
45
+ self._services = services
46
+ self._explain: ExplainabilityService = services["explainability"]
47
+
48
+ async def inspect(self, request: InspectionRequest) -> RAGInspection:
49
+ started = time.perf_counter()
50
+ trace, retrieved, selection, compiled = await self._explain.execute(request)
51
+ counter = (
52
+ get_token_counter_for_model(request.target_model)
53
+ if request.target_model
54
+ else self._explain.token_counter
55
+ )
56
+ if counter is None:
57
+ raise RuntimeError("Inspection token counter is unavailable")
58
+ tokenizer_name = counter.encoding_name
59
+ if request.target_model and tokenizer_name.startswith("profile-"):
60
+ tokenizer_name = (
61
+ "qwen-profile" if "qwen" in request.target_model.lower() else "claude-profile"
62
+ )
63
+ candidate_tokens = sum(counter.count(row.memory.content) for row in retrieved.memories)
64
+ optimized_tokens = sum(
65
+ counter.count(row.memory.content) for row in selection.selected_memories
66
+ )
67
+ compiled_tokens = counter.count(compiled.context_text)
68
+ avoided = max(0, candidate_tokens - compiled_tokens)
69
+ stages = [
70
+ {
71
+ **stage,
72
+ "removed_count": max(0, stage["input_count"] - stage["output_count"]),
73
+ }
74
+ for stage in trace.stages[:20]
75
+ ]
76
+ duplicate_count = sum(fact.reason.value == "duplicate" for fact in compiled.excluded_facts)
77
+ compiled_provenance = sum(bool(fact.provenance_event_ids) for fact in compiled.facts)
78
+ comparison = await self._compare(request, trace) if request.compare else None
79
+ return RAGInspection(
80
+ inspection_id=str(uuid4()),
81
+ explanation_trace_id=trace.trace_id,
82
+ query={"characters": len(request.query), "content_redacted": True},
83
+ configuration={
84
+ "mode": trace.strategy,
85
+ "graph": trace.strategy
86
+ in {RetrievalMode.GRAPH.value, RetrievalMode.HYBRID_GRAPH.value},
87
+ "budget": request.budget,
88
+ "limit": request.limit,
89
+ "temporal_scope": request.temporal_scope.value,
90
+ "target_model_requested": request.target_model is not None,
91
+ },
92
+ stages=stages,
93
+ candidates=trace.candidates,
94
+ context_diff={
95
+ "candidate_tokens": candidate_tokens,
96
+ "optimized_tokens": optimized_tokens,
97
+ "compiled_tokens": compiled_tokens,
98
+ "tokens_removed": avoided,
99
+ "net_token_change": compiled_tokens - candidate_tokens,
100
+ "reduction_ratio": avoided / candidate_tokens if candidate_tokens else 0.0,
101
+ "selected_memories": len(selection.selected_memories),
102
+ "rejected_memories": max(
103
+ 0, len(retrieved.memories) - len(selection.selected_memories)
104
+ ),
105
+ "facts_emitted": len(compiled.facts),
106
+ "facts_excluded": len(compiled.excluded_facts),
107
+ "duplicate_facts_excluded": duplicate_count,
108
+ "provenance_coverage": (
109
+ compiled_provenance / len(compiled.facts) if compiled.facts else None
110
+ ),
111
+ "token_measurement_source": counter.measurement_source.value,
112
+ "tokenizer": tokenizer_name,
113
+ "token_basis": "target_model_recount"
114
+ if request.target_model
115
+ else "pipeline_counter_recount",
116
+ "text_exposed": request.include_content,
117
+ },
118
+ final_context=trace.final_context,
119
+ requested_memory=trace.requested_memory,
120
+ comparison=comparison,
121
+ provider_dispatch=trace.provider_dispatch,
122
+ timing={
123
+ "pipeline_ms": trace.measured_pipeline_ms,
124
+ "inspection_ms": (time.perf_counter() - started) * 1000,
125
+ },
126
+ content=trace.content,
127
+ )
128
+
129
+ async def _compare(self, request: InspectionRequest, trace: ExplanationTrace) -> dict[str, Any]:
130
+ """Explicit extra retrievals; no optimizer/compiler replay or quality claims."""
131
+ modes = (
132
+ RetrievalMode.LEXICAL,
133
+ RetrievalMode.DENSE,
134
+ RetrievalMode.HYBRID,
135
+ RetrievalMode.HYBRID_GRAPH,
136
+ )
137
+ primary_ids = [row["memory_id"] for row in trace.candidates]
138
+ rank_primary = {memory_id: rank for rank, memory_id in enumerate(primary_ids, 1)}
139
+ rows: list[dict[str, Any]] = []
140
+ for mode in modes:
141
+ started = time.perf_counter()
142
+ result = await self._services["retrieval"].retrieve(
143
+ RetrievalQuery(
144
+ text=request.query,
145
+ mode=mode,
146
+ k=request.limit,
147
+ temporal_scope=request.temporal_scope,
148
+ )
149
+ )
150
+ ids = [str(item.memory.id) for item in result.memories]
151
+ rows.append(
152
+ {
153
+ "mode": mode.value,
154
+ "candidate_ids": ids,
155
+ "candidate_count": len(ids),
156
+ "overlap_with_inspection": len(set(ids) & set(primary_ids)),
157
+ "rank_movement": [
158
+ {
159
+ "memory_id": memory_id,
160
+ "inspection_rank": rank_primary[memory_id],
161
+ "comparison_rank": rank,
162
+ }
163
+ for rank, memory_id in enumerate(ids, 1)
164
+ if memory_id in rank_primary
165
+ ],
166
+ "latency_ms": (time.perf_counter() - started) * 1000,
167
+ }
168
+ )
169
+ return {
170
+ "runs": rows,
171
+ "contextos_final_memory_ids": trace.final_context["memories_contributing"],
172
+ "ground_truth_metrics": None,
173
+ "quality_status": "NOT_AVAILABLE: no ground truth supplied",
174
+ }
@@ -0,0 +1,291 @@
1
+ """Memory management service for ContextOS.
2
+
3
+ Business logic layer for memory CRUD, lifecycle transitions, and search.
4
+ Enforces state machine rules from core.enums.VALID_TRANSITIONS.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from uuid import UUID
11
+
12
+ from contextos.core.enums import VALID_TRANSITIONS, EventType, MemoryStatus
13
+ from contextos.core.exceptions import (
14
+ InvalidTransitionError,
15
+ MemoryNotFoundError,
16
+ )
17
+ from contextos.core.models import (
18
+ Memory,
19
+ MemoryFilters,
20
+ MemoryUpdate,
21
+ RawEvent,
22
+ ScoredMemory,
23
+ )
24
+ from contextos.core.protocols import (
25
+ EmbeddingService,
26
+ EventRepository,
27
+ LexicalIndex,
28
+ MemoryRepository,
29
+ VectorStore,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class CoreMemoryService:
36
+ """Phase 1 memory CRUD and lifecycle without retrieval or indexing dependencies."""
37
+
38
+ def __init__(self, repository: MemoryRepository) -> None:
39
+ self._repository = repository
40
+
41
+ async def create(self, memory: Memory) -> Memory:
42
+ return await self._repository.create(memory)
43
+
44
+ async def get(self, memory_id: UUID) -> Memory | None:
45
+ return await self._repository.get(memory_id)
46
+
47
+ async def list(self, filters: MemoryFilters) -> list[Memory]:
48
+ return await self._repository.list(filters)
49
+
50
+ async def update(self, memory_id: UUID, update: MemoryUpdate) -> Memory:
51
+ current = await self._repository.get(memory_id)
52
+ if current is None:
53
+ raise MemoryNotFoundError(str(memory_id))
54
+ return await self._repository.update(memory_id, update, current.version)
55
+
56
+ async def transition(self, memory_id: UUID, status: MemoryStatus) -> Memory:
57
+ current = await self._repository.get(memory_id)
58
+ if current is None:
59
+ raise MemoryNotFoundError(str(memory_id))
60
+ return await self._repository.update_status(memory_id, status, current.version)
61
+
62
+ async def supersede(self, old_id: UUID, successor: Memory) -> Memory:
63
+ current = await self._repository.get(old_id)
64
+ if current is None:
65
+ raise MemoryNotFoundError(str(old_id))
66
+ return await self._repository.supersede(old_id, successor, current.version)
67
+
68
+ async def delete(self, memory_id: UUID) -> Memory:
69
+ return await self.transition(memory_id, MemoryStatus.DELETED)
70
+
71
+
72
+ class MemoryManager:
73
+ """Business logic for memory management.
74
+
75
+ Implements the MemoryService protocol.
76
+ """
77
+
78
+ def __init__(
79
+ self,
80
+ *,
81
+ memory_repo: MemoryRepository,
82
+ event_repo: EventRepository,
83
+ vector_store: VectorStore,
84
+ lexical_index: LexicalIndex,
85
+ embedding_service: EmbeddingService,
86
+ ) -> None:
87
+ self._memory_repo = memory_repo
88
+ self._event_repo = event_repo
89
+ self._vector_store = vector_store
90
+ self._lexical_index = lexical_index
91
+ self._embedding_service = embedding_service
92
+
93
+ async def get(self, memory_id: UUID) -> Memory | None:
94
+ return await self._memory_repo.get(memory_id)
95
+
96
+ async def list(self, filters: MemoryFilters) -> list[Memory]:
97
+ return await self._memory_repo.list(filters)
98
+
99
+ async def search(self, query: str, limit: int = 50) -> list[ScoredMemory]:
100
+ """Quick search across memories using vector similarity."""
101
+ try:
102
+ query_vec = await self._embedding_service.embed_query(query)
103
+ results = await self._vector_store.search(vector=query_vec, top_k=limit)
104
+
105
+ scored: list[ScoredMemory] = []
106
+ for r in results:
107
+ memory = await self._memory_repo.get(UUID(r.id))
108
+ if memory and memory.status == MemoryStatus.ACTIVE:
109
+ scored.append(ScoredMemory(
110
+ memory=memory,
111
+ final_score=r.score,
112
+ vector_score=r.score,
113
+ ))
114
+ await self._memory_repo.update_access(memory.id)
115
+
116
+ return scored
117
+ except Exception:
118
+ logger.warning("Vector search failed in memory manager", exc_info=True)
119
+ return []
120
+
121
+ async def update(self, memory_id: UUID, update: MemoryUpdate) -> Memory:
122
+ memory = await self._memory_repo.get(memory_id)
123
+ if memory is None:
124
+ raise MemoryNotFoundError(str(memory_id))
125
+
126
+ # Record the update event (before/after snapshot)
127
+ before_content = memory.content
128
+
129
+ updated = await self._memory_repo.update(
130
+ memory_id, update, expected_version=memory.version
131
+ )
132
+
133
+ # If content changed, re-embed and re-index
134
+ if update.content is not None and update.content != before_content:
135
+ try:
136
+ embeddings = await self._embedding_service.embed([updated.content])
137
+ await self._vector_store.delete([str(memory_id)])
138
+ await self._vector_store.add(
139
+ ids=[str(memory_id)],
140
+ vectors=embeddings,
141
+ metadata=[{"type": updated.type.value, "status": updated.status.value}],
142
+ )
143
+ except Exception:
144
+ logger.warning("Failed to re-embed memory %s", memory_id, exc_info=True)
145
+
146
+ try:
147
+ await self._lexical_index.delete(str(memory_id))
148
+ await self._lexical_index.index(
149
+ doc_id=str(memory_id),
150
+ text=updated.content,
151
+ metadata={"type": updated.type.value},
152
+ )
153
+ except Exception:
154
+ logger.warning("Failed to re-index memory %s", memory_id, exc_info=True)
155
+
156
+ # Record event
157
+ await self._event_repo.append(RawEvent(
158
+ event_type=EventType.MEMORY_UPDATED,
159
+ source_type="system",
160
+ metadata={
161
+ "memory_id": str(memory_id),
162
+ "before_content": before_content,
163
+ "after_content": updated.content,
164
+ "fields_changed": [
165
+ k for k, v in update.model_dump(exclude_none=True).items()
166
+ ],
167
+ },
168
+ memory_ids=[memory_id],
169
+ ))
170
+
171
+ return updated
172
+
173
+ async def transition(
174
+ self, memory_id: UUID, new_status: MemoryStatus, reason: str = ""
175
+ ) -> Memory:
176
+ """Transition a memory to a new lifecycle state."""
177
+ memory = await self._memory_repo.get(memory_id)
178
+ if memory is None:
179
+ raise MemoryNotFoundError(str(memory_id))
180
+
181
+ # Validate transition
182
+ allowed = VALID_TRANSITIONS.get(memory.status, set())
183
+ if new_status not in allowed:
184
+ raise InvalidTransitionError(
185
+ str(memory_id), memory.status.value, new_status.value
186
+ )
187
+
188
+ updated = await self._memory_repo.update_status(
189
+ memory_id, new_status, expected_version=memory.version
190
+ )
191
+
192
+ # Record transition event
193
+ await self._event_repo.append(RawEvent(
194
+ event_type=EventType.MEMORY_TRANSITION,
195
+ source_type="system",
196
+ metadata={
197
+ "memory_id": str(memory_id),
198
+ "from_status": memory.status.value,
199
+ "to_status": new_status.value,
200
+ "reason": reason,
201
+ },
202
+ memory_ids=[memory_id],
203
+ ))
204
+
205
+ logger.info(
206
+ "Memory %s transitioned: %s → %s (reason: %s)",
207
+ memory_id, memory.status.value, new_status.value, reason,
208
+ )
209
+
210
+ return updated
211
+
212
+ async def delete(self, memory_id: UUID) -> None:
213
+ """Soft delete: transition to DELETED, remove from indices."""
214
+ memory = await self._memory_repo.get(memory_id)
215
+ if memory is None:
216
+ raise MemoryNotFoundError(str(memory_id))
217
+
218
+ # Transition to DELETED (validates allowed transitions)
219
+ if memory.status != MemoryStatus.DELETED:
220
+ # Find the path to DELETED
221
+ allowed = VALID_TRANSITIONS.get(memory.status, set())
222
+ if MemoryStatus.DELETED not in allowed:
223
+ raise InvalidTransitionError(
224
+ str(memory_id), memory.status.value, MemoryStatus.DELETED.value
225
+ )
226
+ await self._memory_repo.update_status(
227
+ memory_id, MemoryStatus.DELETED, expected_version=memory.version
228
+ )
229
+
230
+ # Remove from indices
231
+ try:
232
+ await self._vector_store.delete([str(memory_id)])
233
+ except Exception:
234
+ logger.warning("Failed to remove memory %s from vector store", memory_id)
235
+ try:
236
+ await self._lexical_index.delete(str(memory_id))
237
+ except Exception:
238
+ logger.warning("Failed to remove memory %s from BM25 index", memory_id)
239
+
240
+ # Record event
241
+ await self._event_repo.append(RawEvent(
242
+ event_type=EventType.MEMORY_DELETED,
243
+ source_type="system",
244
+ metadata={"memory_id": str(memory_id), "type": "soft_delete"},
245
+ memory_ids=[memory_id],
246
+ ))
247
+
248
+ async def purge(self, memory_id: UUID) -> None:
249
+ """Hard delete: destroy from all stores."""
250
+ memory = await self._memory_repo.get(memory_id)
251
+ if memory is None:
252
+ raise MemoryNotFoundError(str(memory_id))
253
+
254
+ # Remove from indices
255
+ try:
256
+ await self._vector_store.delete([str(memory_id)])
257
+ except Exception:
258
+ logger.warning("Failed to purge memory %s from vector store", memory_id)
259
+ try:
260
+ await self._lexical_index.delete(str(memory_id))
261
+ except Exception:
262
+ logger.warning("Failed to purge memory %s from BM25 index", memory_id)
263
+
264
+ # Remove from database
265
+ await self._memory_repo.delete(memory_id)
266
+
267
+ # Record purge event (no content — it's destroyed)
268
+ await self._event_repo.append(RawEvent(
269
+ event_type=EventType.MEMORY_PURGED,
270
+ source_type="system",
271
+ metadata={"memory_id": str(memory_id), "type": "hard_delete"},
272
+ ))
273
+
274
+ logger.info("Memory %s purged from all stores", memory_id)
275
+
276
+ async def history(self, topic: str) -> list[Memory]:
277
+ """Get temporal evolution of memories related to a topic.
278
+
279
+ Returns memories (including historical/superseded) related to the topic,
280
+ ordered by creation time.
281
+ """
282
+ # Search across all statuses
283
+ scored = await self.search(topic, limit=100)
284
+
285
+ # Also include non-active memories that match
286
+ all_memories = [sm.memory for sm in scored]
287
+
288
+ # Sort by creation time ascending (oldest first)
289
+ all_memories.sort(key=lambda m: m.created_at)
290
+
291
+ return all_memories