diff --git a/docs/memory-graph-runtime.md b/docs/memory-graph-runtime.md index 8fcbd2b..2365d0d 100644 --- a/docs/memory-graph-runtime.md +++ b/docs/memory-graph-runtime.md @@ -26,6 +26,13 @@ with `markitect-tool`. policy check plus per-node policy checks, returns source-grounded context items, preserves safe denied diagnostics, and emits an audit event in the result envelope. +- `MemoryRuntimeService.apply_retention()` marks stale memories for review or + transitions old memories to `delete_requested` without physical deletion. +- `MemoryRuntimeService.refresh_memory()` clears stale markers and records a + refresh event. +- `MemoryRuntimeService.compact_memory()` creates a deterministic summary node, + preserves source spans/provenance, optionally retires source nodes, and appends + a compaction event. ## Boundary diff --git a/src/kontextual_engine/__init__.py b/src/kontextual_engine/__init__.py index ac418ac..0953c11 100644 --- a/src/kontextual_engine/__init__.py +++ b/src/kontextual_engine/__init__.py @@ -135,10 +135,15 @@ from .services import ( ContextEntityQueryRequest, ContextEntityQueryResult, LexicalIndexRefreshResult, + MemoryCompactionRequest, MemoryGraphImportSummary, + MemoryLifecycleNodeUpdate, + MemoryLifecycleResult, MemoryQueryRequest, + MemoryRefreshRequest, MemoryRetrievalItem, MemoryRetrievalResult, + MemoryRetentionRequest, MemoryRuntimeService, RelationshipChangeResult, RelationshipQueryItem, @@ -257,16 +262,21 @@ __all__ = [ "MetadataSchemaAssignment", "MetadataValidationIssue", "MetadataValueType", + "MemoryCompactionRequest", "MemoryEdgeRecord", "MemoryEventRecord", "MemoryGraphImportResult", "MemoryGraphImportSummary", "MemoryGraphRepository", + "MemoryLifecycleNodeUpdate", + "MemoryLifecycleResult", "MemoryNodeRecord", "MemoryProfileRecord", "MemoryQueryRequest", + "MemoryRefreshRequest", "MemoryRetrievalItem", "MemoryRetrievalResult", + "MemoryRetentionRequest", "MemoryRuntimeService", "MemorySourceSpan", "NormalizedDocument", diff --git a/src/kontextual_engine/services/__init__.py b/src/kontextual_engine/services/__init__.py index 1910678..63980fc 100644 --- a/src/kontextual_engine/services/__init__.py +++ b/src/kontextual_engine/services/__init__.py @@ -8,10 +8,15 @@ from .asset_service import ( from .content_service import RepresentationContentResult, RepresentationContentStream, RepresentationContentService from .ingestion_service import AssetIngestionResult, AssetIngestionService from .memory_service import ( + MemoryCompactionRequest, MemoryGraphImportSummary, + MemoryLifecycleNodeUpdate, + MemoryLifecycleResult, MemoryQueryRequest, + MemoryRefreshRequest, MemoryRetrievalItem, MemoryRetrievalResult, + MemoryRetentionRequest, MemoryRuntimeService, ) from .retrieval_service import ( @@ -60,10 +65,15 @@ __all__ = [ "ContextEntityQueryRequest", "ContextEntityQueryResult", "LexicalIndexRefreshResult", + "MemoryCompactionRequest", "MemoryGraphImportSummary", + "MemoryLifecycleNodeUpdate", + "MemoryLifecycleResult", "MemoryQueryRequest", + "MemoryRefreshRequest", "MemoryRetrievalItem", "MemoryRetrievalResult", + "MemoryRetentionRequest", "MemoryRuntimeService", "RelationshipChangeResult", "RepresentationContentResult", diff --git a/src/kontextual_engine/services/memory_service.py b/src/kontextual_engine/services/memory_service.py index dec4228..b87c9fd 100644 --- a/src/kontextual_engine/services/memory_service.py +++ b/src/kontextual_engine/services/memory_service.py @@ -2,17 +2,23 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace +from datetime import datetime, timezone from typing import Any from kontextual_engine.core import ( AuditEvent, AuditOutcome, + LifecycleState, MemoryGraphImportResult, MemoryEdgeRecord, + MemoryEventRecord, MemoryNodeRecord, + MemorySourceSpan, OperationContext, PolicyDecision, + new_id, + utc_now, ) from kontextual_engine.errors import Diagnostic, ValidationError from kontextual_engine.ports import AllowAllPolicyGateway, MemoryGraphRepository, PolicyGateway @@ -147,6 +153,129 @@ class MemoryRetrievalResult: } +@dataclass(frozen=True) +class MemoryRetentionRequest: + graph_id: str + stale_after_days: int | None = None + delete_after_days: int | None = None + kinds: tuple[str, ...] = () + dry_run: bool = False + + def __post_init__(self) -> None: + object.__setattr__(self, "kinds", tuple(self.kinds)) + + def to_dict(self) -> dict[str, Any]: + return { + "graph_id": self.graph_id, + "stale_after_days": self.stale_after_days, + "delete_after_days": self.delete_after_days, + "kinds": list(self.kinds), + "dry_run": self.dry_run, + } + + +@dataclass(frozen=True) +class MemoryRefreshRequest: + graph_id: str + node_ids: tuple[str, ...] = () + kinds: tuple[str, ...] = () + dry_run: bool = False + + def __post_init__(self) -> None: + object.__setattr__(self, "node_ids", tuple(self.node_ids)) + object.__setattr__(self, "kinds", tuple(self.kinds)) + + def to_dict(self) -> dict[str, Any]: + return { + "graph_id": self.graph_id, + "node_ids": list(self.node_ids), + "kinds": list(self.kinds), + "dry_run": self.dry_run, + } + + +@dataclass(frozen=True) +class MemoryCompactionRequest: + graph_id: str + node_ids: tuple[str, ...] = () + kinds: tuple[str, ...] = () + summary_contract_node_id: str | None = None + summary_text: str | None = None + retire_source_nodes: bool = True + dry_run: bool = False + + def __post_init__(self) -> None: + object.__setattr__(self, "node_ids", tuple(self.node_ids)) + object.__setattr__(self, "kinds", tuple(self.kinds)) + + def to_dict(self) -> dict[str, Any]: + return { + "graph_id": self.graph_id, + "node_ids": list(self.node_ids), + "kinds": list(self.kinds), + "summary_contract_node_id": self.summary_contract_node_id, + "summary_text": self.summary_text, + "retire_source_nodes": self.retire_source_nodes, + "dry_run": self.dry_run, + } + + +@dataclass(frozen=True) +class MemoryLifecycleNodeUpdate: + node_id: str + contract_node_id: str + action: str + before_lifecycle: str + after_lifecycle: str + details: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "node_id": self.node_id, + "contract_node_id": self.contract_node_id, + "action": self.action, + "before_lifecycle": self.before_lifecycle, + "after_lifecycle": self.after_lifecycle, + "details": dict(self.details), + } + + +@dataclass(frozen=True) +class MemoryLifecycleResult: + operation: str + correlation_id: str + graph_id: str + dry_run: bool + updated_nodes: tuple[MemoryLifecycleNodeUpdate, ...] = () + created_nodes: tuple[MemoryNodeRecord, ...] = () + appended_events: tuple[MemoryEventRecord, ...] = () + diagnostics: tuple[Diagnostic, ...] = () + audit_event: AuditEvent | None = None + metadata: dict[str, Any] = field(default_factory=dict) + success: bool = True + + def __post_init__(self) -> None: + object.__setattr__(self, "updated_nodes", tuple(self.updated_nodes)) + object.__setattr__(self, "created_nodes", tuple(self.created_nodes)) + object.__setattr__(self, "appended_events", tuple(self.appended_events)) + object.__setattr__(self, "diagnostics", tuple(self.diagnostics)) + + def to_dict(self) -> dict[str, Any]: + return { + "operation": self.operation, + "correlation_id": self.correlation_id, + "graph_id": self.graph_id, + "dry_run": self.dry_run, + "success": self.success, + "updated_nodes": [update.to_dict() for update in self.updated_nodes], + "created_nodes": [node.to_dict() for node in self.created_nodes], + "appended_events": [event.to_dict() for event in self.appended_events], + "diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics], + "audit_event": self.audit_event.to_dict() if self.audit_event else None, + "metadata": dict(self.metadata), + } + + class MemoryRuntimeService: def __init__( self, @@ -327,6 +456,240 @@ class MemoryRuntimeService: }, ) + def apply_retention( + self, + request: MemoryRetentionRequest, + context: OperationContext, + ) -> MemoryLifecycleResult: + diagnostics = _validate_retention_request(request) + if diagnostics: + return MemoryLifecycleResult( + operation="memory.retention.apply", + correlation_id=context.correlation_id, + graph_id=request.graph_id, + dry_run=request.dry_run, + diagnostics=tuple(diagnostics), + success=False, + ) + now = utc_now() + nodes = _select_nodes( + self.repository.list_memory_nodes(graph_id=request.graph_id), + node_ids=(), + kinds=request.kinds, + ) + updates: list[MemoryLifecycleNodeUpdate] = [] + updated_nodes: list[MemoryNodeRecord] = [] + for node in nodes: + age_days = _node_age_days(node, now) + if request.delete_after_days is not None and age_days >= request.delete_after_days: + updated = _node_with_lifecycle_marker( + node, + action="delete_requested", + lifecycle=LifecycleState.DELETE_REQUESTED, + now=now, + details={"age_days": age_days, "threshold_days": request.delete_after_days}, + ) + updates.append(_node_update(node, updated, "delete_requested", age_days=age_days)) + updated_nodes.append(updated) + elif request.stale_after_days is not None and age_days >= request.stale_after_days: + updated = _node_with_lifecycle_marker( + node, + action="stale_review_required", + lifecycle=node.lifecycle, + now=now, + details={"age_days": age_days, "threshold_days": request.stale_after_days}, + ) + updates.append(_node_update(node, updated, "stale_review_required", age_days=age_days)) + updated_nodes.append(updated) + event = None + if updated_nodes and not request.dry_run: + for node in updated_nodes: + self.repository.save_memory_node(node) + event = self._append_lifecycle_event( + "retention", + request.graph_id, + context, + node_updates=[update.to_dict() for update in updates], + metadata={"request": request.to_dict()}, + ) + audit_event = AuditEvent.from_context( + "memory.retention.apply", + f"memory-graph:{request.graph_id}", + AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS, + context, + details={ + "request": request.to_dict(), + "updated_nodes": len(updates), + "event_id": event.event_id if event else None, + }, + ) + return MemoryLifecycleResult( + operation="memory.retention.apply", + correlation_id=context.correlation_id, + graph_id=request.graph_id, + dry_run=request.dry_run, + updated_nodes=tuple(updates), + appended_events=(event,) if event else (), + audit_event=audit_event, + metadata={"matched_nodes": len(nodes), "updated_nodes": len(updates)}, + ) + + def refresh_memory( + self, + request: MemoryRefreshRequest, + context: OperationContext, + ) -> MemoryLifecycleResult: + diagnostics = _validate_node_selection_request(request.graph_id, request.node_ids) + if diagnostics: + return MemoryLifecycleResult( + operation="memory.refresh", + correlation_id=context.correlation_id, + graph_id=request.graph_id, + dry_run=request.dry_run, + diagnostics=tuple(diagnostics), + success=False, + ) + now = utc_now() + nodes = _select_nodes( + self.repository.list_memory_nodes(graph_id=request.graph_id), + node_ids=request.node_ids, + kinds=request.kinds, + ) + updates: list[MemoryLifecycleNodeUpdate] = [] + refreshed_nodes: list[MemoryNodeRecord] = [] + for node in nodes: + updated = _refreshed_node(node, now) + updates.append(_node_update(node, updated, "refreshed")) + refreshed_nodes.append(updated) + event = None + if refreshed_nodes and not request.dry_run: + for node in refreshed_nodes: + self.repository.save_memory_node(node) + event = self._append_lifecycle_event( + "refreshed", + request.graph_id, + context, + node_updates=[update.to_dict() for update in updates], + metadata={"request": request.to_dict()}, + ) + audit_event = AuditEvent.from_context( + "memory.refresh", + f"memory-graph:{request.graph_id}", + AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS, + context, + details={ + "request": request.to_dict(), + "updated_nodes": len(updates), + "event_id": event.event_id if event else None, + }, + ) + return MemoryLifecycleResult( + operation="memory.refresh", + correlation_id=context.correlation_id, + graph_id=request.graph_id, + dry_run=request.dry_run, + updated_nodes=tuple(updates), + appended_events=(event,) if event else (), + audit_event=audit_event, + metadata={"matched_nodes": len(nodes), "updated_nodes": len(updates)}, + ) + + def compact_memory( + self, + request: MemoryCompactionRequest, + context: OperationContext, + ) -> MemoryLifecycleResult: + diagnostics = _validate_node_selection_request(request.graph_id, request.node_ids) + if diagnostics: + return MemoryLifecycleResult( + operation="memory.compact", + correlation_id=context.correlation_id, + graph_id=request.graph_id, + dry_run=request.dry_run, + diagnostics=tuple(diagnostics), + success=False, + ) + now = utc_now() + nodes = _select_nodes( + self.repository.list_memory_nodes(graph_id=request.graph_id), + node_ids=request.node_ids, + kinds=request.kinds, + ) + if not nodes: + return MemoryLifecycleResult( + operation="memory.compact", + correlation_id=context.correlation_id, + graph_id=request.graph_id, + dry_run=request.dry_run, + diagnostics=( + Diagnostic("warning", "memory.compaction.empty", "No memory nodes matched compaction request."), + ), + metadata={"request": request.to_dict()}, + ) + operation_id = new_id("memcompact") + summary = _summary_node_for_compaction(request, nodes, operation_id, now) + updates: list[MemoryLifecycleNodeUpdate] = [ + MemoryLifecycleNodeUpdate( + node_id=str(summary.node_id), + contract_node_id=summary.contract_node_id, + action="created_compaction_summary", + before_lifecycle="missing", + after_lifecycle=summary.lifecycle.value, + details={"source_node_ids": [node.node_id for node in nodes]}, + ) + ] + retired_nodes: list[MemoryNodeRecord] = [] + if request.retire_source_nodes: + for node in nodes: + retired = _node_with_lifecycle_marker( + node, + action="compacted_retired", + lifecycle=LifecycleState.RETIRED, + now=now, + details={"summary_node_id": summary.node_id, "operation_id": operation_id}, + ) + updates.append(_node_update(node, retired, "compacted_retired")) + retired_nodes.append(retired) + event = None + if not request.dry_run: + self.repository.save_memory_node(summary) + for node in retired_nodes: + self.repository.save_memory_node(node) + event = self._append_lifecycle_event( + "compacted", + request.graph_id, + context, + node_updates=[update.to_dict() for update in updates], + metadata={ + "request": request.to_dict(), + "summary_node_id": summary.node_id, + "operation_id": operation_id, + }, + ) + audit_event = AuditEvent.from_context( + "memory.compact", + f"memory-graph:{request.graph_id}", + AuditOutcome.DRY_RUN if request.dry_run else AuditOutcome.SUCCESS, + context, + details={ + "request": request.to_dict(), + "summary_node_id": summary.node_id, + "source_nodes": len(nodes), + "event_id": event.event_id if event else None, + }, + ) + return MemoryLifecycleResult( + operation="memory.compact", + correlation_id=context.correlation_id, + graph_id=request.graph_id, + dry_run=request.dry_run, + updated_nodes=tuple(updates), + created_nodes=(summary,), + appended_events=(event,) if event else (), + audit_event=audit_event, + metadata={"source_nodes": len(nodes), "retired_source_nodes": len(retired_nodes)}, + ) + def _authorize( self, context: OperationContext, @@ -354,6 +717,32 @@ class MemoryRuntimeService: }, ) + def _append_lifecycle_event( + self, + event_kind: str, + graph_id: str, + context: OperationContext, + *, + node_updates: list[dict[str, Any]], + metadata: dict[str, Any], + ) -> MemoryEventRecord: + nodes = self.repository.list_memory_nodes(graph_id=graph_id) + contract_graph_id = nodes[0].contract_graph_id if nodes else graph_id + event = MemoryEventRecord( + graph_id=graph_id, + contract_graph_id=contract_graph_id, + contract_event_id=f"runtime.{event_kind}.{new_id('event')}", + kind=event_kind, + timestamp=utc_now().isoformat(), + actor_id=context.actor.id, + thread=_optional_str(context.request_scope.get("thread")), + task=_optional_str(context.request_scope.get("task")), + node_updates=tuple(node_updates), + policy={"correlation_id": context.correlation_id}, + metadata=metadata, + ) + return self.repository.append_memory_event(event) + def _validate_query(request: MemoryQueryRequest) -> list[Diagnostic]: diagnostics: list[Diagnostic] = [] @@ -397,3 +786,225 @@ def _permission_denied_diagnostic( decision.reason or "Memory retrieval denied by policy.", details=details, ) + + +def _validate_retention_request(request: MemoryRetentionRequest) -> list[Diagnostic]: + diagnostics = _validate_node_selection_request(request.graph_id, ()) + if request.stale_after_days is None and request.delete_after_days is None: + diagnostics.append( + Diagnostic( + "error", + "memory.retention.threshold_missing", + "retention requires stale_after_days or delete_after_days.", + ) + ) + if request.stale_after_days is not None and request.stale_after_days < 0: + diagnostics.append(Diagnostic("error", "memory.retention.stale_invalid", "stale_after_days must be >= 0.")) + if request.delete_after_days is not None and request.delete_after_days < 0: + diagnostics.append(Diagnostic("error", "memory.retention.delete_invalid", "delete_after_days must be >= 0.")) + return diagnostics + + +def _validate_node_selection_request(graph_id: str, node_ids: tuple[str, ...]) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + if not graph_id: + diagnostics.append(Diagnostic("error", "memory.graph_id_missing", "graph_id is required.")) + if any(not node_id for node_id in node_ids): + diagnostics.append(Diagnostic("error", "memory.node_id_invalid", "node_ids must not contain empty ids.")) + return diagnostics + + +def _select_nodes( + nodes: list[MemoryNodeRecord], + *, + node_ids: tuple[str, ...], + kinds: tuple[str, ...], +) -> list[MemoryNodeRecord]: + selected = nodes + if node_ids: + wanted = set(node_ids) + selected = [node for node in selected if node.node_id in wanted or node.contract_node_id in wanted] + if kinds: + wanted_kinds = set(kinds) + selected = [node for node in selected if node.kind in wanted_kinds] + return selected + + +def _node_age_days(node: MemoryNodeRecord, now: datetime) -> int: + timestamp = _node_reference_time(node) + if timestamp is None: + return 0 + return max(0, (now - timestamp).days) + + +def _node_reference_time(node: MemoryNodeRecord) -> datetime | None: + for key in ( + "last_verified_at", + "refreshed_at", + "observed_at", + "updated_at", + "created_at", + "compiled_at", + ): + if key in node.freshness: + parsed = _parse_datetime(node.freshness[key]) + if parsed: + return parsed + return _parse_datetime(node.updated_at) or _parse_datetime(node.created_at) + + +def _parse_datetime(value: Any) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + parsed = value + else: + text = str(value) + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _node_with_lifecycle_marker( + node: MemoryNodeRecord, + *, + action: str, + lifecycle: LifecycleState, + now: datetime, + details: dict[str, Any], +) -> MemoryNodeRecord: + at = now.isoformat() + freshness = dict(node.freshness) + freshness["last_lifecycle_at"] = at + if action == "stale_review_required": + freshness["stale"] = True + freshness["stale_at"] = at + metadata = dict(node.metadata) + metadata["review_state"] = _review_state_for_action(action) + lifecycle_meta = dict(metadata.get("lifecycle") or {}) + lifecycle_meta.update({"action": action, "at": at, "details": dict(details)}) + metadata["lifecycle"] = lifecycle_meta + return replace( + node, + lifecycle=lifecycle, + freshness=freshness, + metadata=metadata, + updated_at=at, + ) + + +def _review_state_for_action(action: str) -> str: + if action == "delete_requested": + return "delete_requested" + if action == "stale_review_required": + return "review_required" + if action == "compacted_retired": + return "compacted" + return action + + +def _refreshed_node(node: MemoryNodeRecord, now: datetime) -> MemoryNodeRecord: + at = now.isoformat() + freshness = dict(node.freshness) + freshness["refreshed_at"] = at + freshness["stale"] = False + freshness.pop("stale_at", None) + metadata = dict(node.metadata) + metadata["review_state"] = "current" + lifecycle_meta = dict(metadata.get("lifecycle") or {}) + lifecycle_meta.update({"action": "refreshed", "at": at}) + metadata["lifecycle"] = lifecycle_meta + return replace(node, freshness=freshness, metadata=metadata, updated_at=at) + + +def _node_update( + before: MemoryNodeRecord, + after: MemoryNodeRecord, + action: str, + **details: Any, +) -> MemoryLifecycleNodeUpdate: + return MemoryLifecycleNodeUpdate( + node_id=str(after.node_id), + contract_node_id=after.contract_node_id, + action=action, + before_lifecycle=before.lifecycle.value, + after_lifecycle=after.lifecycle.value, + details=details, + ) + + +def _summary_node_for_compaction( + request: MemoryCompactionRequest, + nodes: list[MemoryNodeRecord], + operation_id: str, + now: datetime, +) -> MemoryNodeRecord: + first = nodes[0] + at = now.isoformat() + source_node_ids = [node.node_id for node in nodes] + source_contract_node_ids = [node.contract_node_id for node in nodes] + return MemoryNodeRecord( + graph_id=request.graph_id, + contract_graph_id=first.contract_graph_id, + contract_node_id=request.summary_contract_node_id or f"compaction.{operation_id}", + kind="memory", + text=request.summary_text or _deterministic_compaction_summary(nodes), + namespace=dict(first.namespace), + source_spans=tuple(_unique_source_spans(nodes)), + provenance=( + { + "kind": "memory-compaction", + "operation_id": operation_id, + "source_node_ids": source_node_ids, + "source_contract_node_ids": source_contract_node_ids, + }, + ), + freshness={"compacted_at": at, "stale": False}, + metadata={ + "title": "Compacted memory summary", + "review_state": "current", + "compaction": { + "operation_id": operation_id, + "source_node_ids": source_node_ids, + "source_contract_node_ids": source_contract_node_ids, + }, + }, + created_at=at, + updated_at=at, + ) + + +def _unique_source_spans(nodes: list[MemoryNodeRecord]) -> list[MemorySourceSpan]: + spans: list[MemorySourceSpan] = [] + seen: set[tuple] = set() + for node in nodes: + for span in node.source_spans: + key = tuple(sorted(span.to_dict().items())) + if key in seen: + continue + seen.add(key) + spans.append(span) + return spans + + +def _deterministic_compaction_summary(nodes: list[MemoryNodeRecord]) -> str: + lines = ["Compacted memory summary:"] + for node in nodes: + text = " ".join(node.text.split()) + if len(text) > 180: + text = text[:177].rstrip() + "..." + lines.append(f"- {node.kind} {node.contract_node_id}: {text}") + return "\n".join(lines) + + +def _optional_str(value: Any) -> str | None: + if value is None: + return None + text = str(value) + return text if text else None diff --git a/tests/test_memory_graph_runtime.py b/tests/test_memory_graph_runtime.py index 96f27c7..2e6ba92 100644 --- a/tests/test_memory_graph_runtime.py +++ b/tests/test_memory_graph_runtime.py @@ -5,7 +5,11 @@ from kontextual_engine import ( ActorType, DuplicateResourceError, InMemoryMemoryGraphRepository, + LifecycleState, + MemoryCompactionRequest, MemoryGraphImportResult, + MemoryRefreshRequest, + MemoryRetentionRequest, MemoryQueryRequest, MemoryRuntimeService, OperationContext, @@ -134,6 +138,65 @@ def test_memory_query_scope_policy_fail_closed_returns_empty_result() -> None: assert result.audit_event.outcome.value == "denied" +def test_memory_retention_marks_stale_refresh_clears_and_delete_requests() -> None: + repo = InMemoryMemoryGraphRepository() + service = MemoryRuntimeService(repo) + summary = service.import_markitect_graph(_graph_contract()) + context = operation_context() + + stale = service.apply_retention( + MemoryRetentionRequest(graph_id=summary.graph_id, stale_after_days=0), + context, + ) + stale_node = repo.get_memory_node(stale.updated_nodes[0].node_id) + refreshed = service.refresh_memory( + MemoryRefreshRequest(graph_id=summary.graph_id, node_ids=(stale_node.contract_node_id,)), + context, + ) + delete_requested = service.apply_retention( + MemoryRetentionRequest(graph_id=summary.graph_id, delete_after_days=0), + context, + ) + + assert stale.operation == "memory.retention.apply" + assert stale.appended_events[0].kind == "retention" + assert stale_node.metadata["review_state"] == "review_required" + assert stale_node.freshness["stale"] is True + assert refreshed.appended_events[0].kind == "refreshed" + assert repo.get_memory_node(stale_node.node_id).freshness["stale"] is False + assert all(update.after_lifecycle == LifecycleState.DELETE_REQUESTED.value for update in delete_requested.updated_nodes) + assert delete_requested.appended_events[0].kind == "retention" + + +def test_memory_compaction_creates_summary_preserves_spans_and_retires_sources() -> None: + repo = InMemoryMemoryGraphRepository() + service = MemoryRuntimeService(repo) + summary = service.import_markitect_graph(_graph_contract()) + + compacted = service.compact_memory( + MemoryCompactionRequest( + graph_id=summary.graph_id, + node_ids=("decision.contract-boundary", "constraint.no-runtime-services"), + summary_contract_node_id="compaction.boundary-summary", + ), + operation_context(), + ) + summary_node = compacted.created_nodes[0] + + assert compacted.operation == "memory.compact" + assert summary_node.contract_node_id == "compaction.boundary-summary" + assert summary_node.kind == "memory" + assert summary_node.source_spans[0].path == "workplans/MKTT-WP-0016.md" + assert "decision.contract-boundary" in summary_node.metadata["compaction"]["source_contract_node_ids"] + assert compacted.appended_events[0].kind == "compacted" + source_nodes = [ + repo.get_memory_node(update.node_id) + for update in compacted.updated_nodes + if update.action == "compacted_retired" + ] + assert {node.lifecycle for node in source_nodes} == {LifecycleState.RETIRED} + + def test_memory_runtime_service_rejects_invalid_edge_contracts() -> None: repo = InMemoryMemoryGraphRepository() service = MemoryRuntimeService(repo) diff --git a/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md b/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md index 7514cfe..cd877b9 100644 --- a/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md +++ b/workplans/KONT-WP-0017-agentic-memory-graph-runtime.md @@ -86,6 +86,13 @@ per-node policy checks, returns source-grounded context items, preserves denied diagnostics without leaking denied node text, and emits an audit event in the result envelope. +The lifecycle slice is implemented with deterministic local operations: +`apply_retention()` marks stale memories for review or transitions old memories +to `delete_requested`; `refresh_memory()` clears stale markers; and +`compact_memory()` creates a provenance/source-span preserving summary node, +optionally retiring the source nodes. Each mutating operation appends a memory +event and returns audit metadata. + ## P17.1 - Import and map Markitect memory contracts ```task @@ -158,7 +165,7 @@ Output: service/domain API, permission tests, and denied-access diagnostics. ```task id: KONT-WP-0017-T004 -status: todo +status: done priority: high state_hub_task_id: "853807fe-53ac-440e-823f-8d9b0b7ce4b7" ```