generated from coulomb/repo-seed
feat(memory): add lifecycle operations
This commit is contained in:
@@ -26,6 +26,13 @@ with `markitect-tool`.
|
|||||||
policy check plus per-node policy checks, returns source-grounded context
|
policy check plus per-node policy checks, returns source-grounded context
|
||||||
items, preserves safe denied diagnostics, and emits an audit event in the
|
items, preserves safe denied diagnostics, and emits an audit event in the
|
||||||
result envelope.
|
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
|
## Boundary
|
||||||
|
|
||||||
|
|||||||
@@ -135,10 +135,15 @@ from .services import (
|
|||||||
ContextEntityQueryRequest,
|
ContextEntityQueryRequest,
|
||||||
ContextEntityQueryResult,
|
ContextEntityQueryResult,
|
||||||
LexicalIndexRefreshResult,
|
LexicalIndexRefreshResult,
|
||||||
|
MemoryCompactionRequest,
|
||||||
MemoryGraphImportSummary,
|
MemoryGraphImportSummary,
|
||||||
|
MemoryLifecycleNodeUpdate,
|
||||||
|
MemoryLifecycleResult,
|
||||||
MemoryQueryRequest,
|
MemoryQueryRequest,
|
||||||
|
MemoryRefreshRequest,
|
||||||
MemoryRetrievalItem,
|
MemoryRetrievalItem,
|
||||||
MemoryRetrievalResult,
|
MemoryRetrievalResult,
|
||||||
|
MemoryRetentionRequest,
|
||||||
MemoryRuntimeService,
|
MemoryRuntimeService,
|
||||||
RelationshipChangeResult,
|
RelationshipChangeResult,
|
||||||
RelationshipQueryItem,
|
RelationshipQueryItem,
|
||||||
@@ -257,16 +262,21 @@ __all__ = [
|
|||||||
"MetadataSchemaAssignment",
|
"MetadataSchemaAssignment",
|
||||||
"MetadataValidationIssue",
|
"MetadataValidationIssue",
|
||||||
"MetadataValueType",
|
"MetadataValueType",
|
||||||
|
"MemoryCompactionRequest",
|
||||||
"MemoryEdgeRecord",
|
"MemoryEdgeRecord",
|
||||||
"MemoryEventRecord",
|
"MemoryEventRecord",
|
||||||
"MemoryGraphImportResult",
|
"MemoryGraphImportResult",
|
||||||
"MemoryGraphImportSummary",
|
"MemoryGraphImportSummary",
|
||||||
"MemoryGraphRepository",
|
"MemoryGraphRepository",
|
||||||
|
"MemoryLifecycleNodeUpdate",
|
||||||
|
"MemoryLifecycleResult",
|
||||||
"MemoryNodeRecord",
|
"MemoryNodeRecord",
|
||||||
"MemoryProfileRecord",
|
"MemoryProfileRecord",
|
||||||
"MemoryQueryRequest",
|
"MemoryQueryRequest",
|
||||||
|
"MemoryRefreshRequest",
|
||||||
"MemoryRetrievalItem",
|
"MemoryRetrievalItem",
|
||||||
"MemoryRetrievalResult",
|
"MemoryRetrievalResult",
|
||||||
|
"MemoryRetentionRequest",
|
||||||
"MemoryRuntimeService",
|
"MemoryRuntimeService",
|
||||||
"MemorySourceSpan",
|
"MemorySourceSpan",
|
||||||
"NormalizedDocument",
|
"NormalizedDocument",
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ from .asset_service import (
|
|||||||
from .content_service import RepresentationContentResult, RepresentationContentStream, RepresentationContentService
|
from .content_service import RepresentationContentResult, RepresentationContentStream, RepresentationContentService
|
||||||
from .ingestion_service import AssetIngestionResult, AssetIngestionService
|
from .ingestion_service import AssetIngestionResult, AssetIngestionService
|
||||||
from .memory_service import (
|
from .memory_service import (
|
||||||
|
MemoryCompactionRequest,
|
||||||
MemoryGraphImportSummary,
|
MemoryGraphImportSummary,
|
||||||
|
MemoryLifecycleNodeUpdate,
|
||||||
|
MemoryLifecycleResult,
|
||||||
MemoryQueryRequest,
|
MemoryQueryRequest,
|
||||||
|
MemoryRefreshRequest,
|
||||||
MemoryRetrievalItem,
|
MemoryRetrievalItem,
|
||||||
MemoryRetrievalResult,
|
MemoryRetrievalResult,
|
||||||
|
MemoryRetentionRequest,
|
||||||
MemoryRuntimeService,
|
MemoryRuntimeService,
|
||||||
)
|
)
|
||||||
from .retrieval_service import (
|
from .retrieval_service import (
|
||||||
@@ -60,10 +65,15 @@ __all__ = [
|
|||||||
"ContextEntityQueryRequest",
|
"ContextEntityQueryRequest",
|
||||||
"ContextEntityQueryResult",
|
"ContextEntityQueryResult",
|
||||||
"LexicalIndexRefreshResult",
|
"LexicalIndexRefreshResult",
|
||||||
|
"MemoryCompactionRequest",
|
||||||
"MemoryGraphImportSummary",
|
"MemoryGraphImportSummary",
|
||||||
|
"MemoryLifecycleNodeUpdate",
|
||||||
|
"MemoryLifecycleResult",
|
||||||
"MemoryQueryRequest",
|
"MemoryQueryRequest",
|
||||||
|
"MemoryRefreshRequest",
|
||||||
"MemoryRetrievalItem",
|
"MemoryRetrievalItem",
|
||||||
"MemoryRetrievalResult",
|
"MemoryRetrievalResult",
|
||||||
|
"MemoryRetentionRequest",
|
||||||
"MemoryRuntimeService",
|
"MemoryRuntimeService",
|
||||||
"RelationshipChangeResult",
|
"RelationshipChangeResult",
|
||||||
"RepresentationContentResult",
|
"RepresentationContentResult",
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
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 typing import Any
|
||||||
|
|
||||||
from kontextual_engine.core import (
|
from kontextual_engine.core import (
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
AuditOutcome,
|
AuditOutcome,
|
||||||
|
LifecycleState,
|
||||||
MemoryGraphImportResult,
|
MemoryGraphImportResult,
|
||||||
MemoryEdgeRecord,
|
MemoryEdgeRecord,
|
||||||
|
MemoryEventRecord,
|
||||||
MemoryNodeRecord,
|
MemoryNodeRecord,
|
||||||
|
MemorySourceSpan,
|
||||||
OperationContext,
|
OperationContext,
|
||||||
PolicyDecision,
|
PolicyDecision,
|
||||||
|
new_id,
|
||||||
|
utc_now,
|
||||||
)
|
)
|
||||||
from kontextual_engine.errors import Diagnostic, ValidationError
|
from kontextual_engine.errors import Diagnostic, ValidationError
|
||||||
from kontextual_engine.ports import AllowAllPolicyGateway, MemoryGraphRepository, PolicyGateway
|
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:
|
class MemoryRuntimeService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
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(
|
def _authorize(
|
||||||
self,
|
self,
|
||||||
context: OperationContext,
|
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]:
|
def _validate_query(request: MemoryQueryRequest) -> list[Diagnostic]:
|
||||||
diagnostics: list[Diagnostic] = []
|
diagnostics: list[Diagnostic] = []
|
||||||
@@ -397,3 +786,225 @@ def _permission_denied_diagnostic(
|
|||||||
decision.reason or "Memory retrieval denied by policy.",
|
decision.reason or "Memory retrieval denied by policy.",
|
||||||
details=details,
|
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
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ from kontextual_engine import (
|
|||||||
ActorType,
|
ActorType,
|
||||||
DuplicateResourceError,
|
DuplicateResourceError,
|
||||||
InMemoryMemoryGraphRepository,
|
InMemoryMemoryGraphRepository,
|
||||||
|
LifecycleState,
|
||||||
|
MemoryCompactionRequest,
|
||||||
MemoryGraphImportResult,
|
MemoryGraphImportResult,
|
||||||
|
MemoryRefreshRequest,
|
||||||
|
MemoryRetentionRequest,
|
||||||
MemoryQueryRequest,
|
MemoryQueryRequest,
|
||||||
MemoryRuntimeService,
|
MemoryRuntimeService,
|
||||||
OperationContext,
|
OperationContext,
|
||||||
@@ -134,6 +138,65 @@ def test_memory_query_scope_policy_fail_closed_returns_empty_result() -> None:
|
|||||||
assert result.audit_event.outcome.value == "denied"
|
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:
|
def test_memory_runtime_service_rejects_invalid_edge_contracts() -> None:
|
||||||
repo = InMemoryMemoryGraphRepository()
|
repo = InMemoryMemoryGraphRepository()
|
||||||
service = MemoryRuntimeService(repo)
|
service = MemoryRuntimeService(repo)
|
||||||
|
|||||||
@@ -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
|
diagnostics without leaking denied node text, and emits an audit event in the
|
||||||
result envelope.
|
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
|
## P17.1 - Import and map Markitect memory contracts
|
||||||
|
|
||||||
```task
|
```task
|
||||||
@@ -158,7 +165,7 @@ Output: service/domain API, permission tests, and denied-access diagnostics.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: KONT-WP-0017-T004
|
id: KONT-WP-0017-T004
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "853807fe-53ac-440e-823f-8d9b0b7ce4b7"
|
state_hub_task_id: "853807fe-53ac-440e-823f-8d9b0b7ce4b7"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user