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,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
|