generated from coulomb/repo-seed
Implement local runtime persistence and policy gates
This commit is contained in:
@@ -2,9 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import MemoryEdge, MemoryEvent, MemoryNode, PolicyDecision, ProfileIntent
|
||||
from .models import Diagnostic, MemoryEdge, MemoryEvent, MemoryGraph, MemoryNode, MemoryPath, PolicyDecision, ProfileIntent
|
||||
|
||||
LOCAL_STORE_SCHEMA = "phase_memory.local_store.v1"
|
||||
|
||||
|
||||
class InMemoryMemoryGraphStore:
|
||||
@@ -63,6 +67,160 @@ class InMemoryMemoryEventLog:
|
||||
return events
|
||||
|
||||
|
||||
class FileBackedMemoryGraphStore:
|
||||
"""Versioned JSON-backed local graph store.
|
||||
|
||||
Layout:
|
||||
- `phase-memory.json`
|
||||
- `profiles/<profile-id>.json`
|
||||
- `nodes/<node-id>.json`
|
||||
- `edges/<edge-id>.json`
|
||||
- `paths/<path-id>.json`
|
||||
- `activations/`
|
||||
- `audit.jsonl`
|
||||
"""
|
||||
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
self.root = Path(root)
|
||||
self.profiles_dir = self.root / "profiles"
|
||||
self.nodes_dir = self.root / "nodes"
|
||||
self.edges_dir = self.root / "edges"
|
||||
self.paths_dir = self.root / "paths"
|
||||
self.activations_dir = self.root / "activations"
|
||||
self.audit_path = self.root / "audit.jsonl"
|
||||
self._ensure_layout()
|
||||
|
||||
def save_profile(self, profile: ProfileIntent) -> ProfileIntent:
|
||||
_write_json(self.profiles_dir / f"{_safe_name(profile.profile_id)}.json", profile.to_dict())
|
||||
return profile
|
||||
|
||||
def get_profile(self, profile_id: str) -> ProfileIntent:
|
||||
return ProfileIntent.from_mapping(_read_json(self.profiles_dir / f"{_safe_name(profile_id)}.json"))
|
||||
|
||||
def save_node(self, node: MemoryNode) -> MemoryNode:
|
||||
_write_json(self.nodes_dir / f"{_safe_name(node.node_id)}.json", node.to_dict())
|
||||
return node
|
||||
|
||||
def get_node(self, node_id: str) -> MemoryNode:
|
||||
return MemoryNode.from_mapping(_read_json(self.nodes_dir / f"{_safe_name(node_id)}.json"))
|
||||
|
||||
def list_nodes(self, *, kind: str | None = None) -> list[MemoryNode]:
|
||||
nodes = [MemoryNode.from_mapping(_read_json(path)) for path in sorted(self.nodes_dir.glob("*.json"))]
|
||||
if kind:
|
||||
nodes = [node for node in nodes if node.kind == kind]
|
||||
return sorted(nodes, key=lambda node: node.node_id)
|
||||
|
||||
def save_edge(self, edge: MemoryEdge) -> MemoryEdge:
|
||||
_write_json(self.edges_dir / f"{_safe_name(edge.edge_id)}.json", edge.to_dict())
|
||||
return edge
|
||||
|
||||
def list_edges(self, *, source: str | None = None, target: str | None = None) -> list[MemoryEdge]:
|
||||
edges = [MemoryEdge.from_mapping(_read_json(path)) for path in sorted(self.edges_dir.glob("*.json"))]
|
||||
if source:
|
||||
edges = [edge for edge in edges if edge.source == source]
|
||||
if target:
|
||||
edges = [edge for edge in edges if edge.target == target]
|
||||
return sorted(edges, key=lambda edge: edge.edge_id)
|
||||
|
||||
def save_path(self, path: MemoryPath) -> MemoryPath:
|
||||
_write_json(self.paths_dir / f"{_safe_name(path.path_id)}.json", path.to_dict())
|
||||
return path
|
||||
|
||||
def get_path(self, path_id: str) -> MemoryPath:
|
||||
return MemoryPath.from_mapping(_read_json(self.paths_dir / f"{_safe_name(path_id)}.json"))
|
||||
|
||||
def list_paths(self) -> list[MemoryPath]:
|
||||
return [MemoryPath.from_mapping(_read_json(path)) for path in sorted(self.paths_dir.glob("*.json"))]
|
||||
|
||||
def export_graph(self, *, graph_id: str = "local", events: list[MemoryEvent] | None = None) -> MemoryGraph:
|
||||
return MemoryGraph(
|
||||
graph_id=graph_id,
|
||||
nodes=tuple(self.list_nodes()),
|
||||
edges=tuple(self.list_edges()),
|
||||
events=tuple(events or ()),
|
||||
metadata={"store_schema_version": LOCAL_STORE_SCHEMA, "store_path": str(self.root)},
|
||||
)
|
||||
|
||||
def repair_diagnostics(self, *, events: list[MemoryEvent] | None = None) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
node_ids = {node.node_id for node in self.list_nodes()}
|
||||
event_ids = {event.event_id for event in events or ()}
|
||||
for edge in self.list_edges():
|
||||
if edge.source not in node_ids:
|
||||
diagnostics.append(Diagnostic("error", "missing_edge_source", "Edge source does not reference a node.", edge.edge_id, {"source": edge.source}))
|
||||
if edge.target not in node_ids:
|
||||
diagnostics.append(Diagnostic("error", "missing_edge_target", "Edge target does not reference a node.", edge.edge_id, {"target": edge.target}))
|
||||
for path in self.list_paths():
|
||||
for event_id in path.event_ids:
|
||||
if event_id not in event_ids:
|
||||
diagnostics.append(Diagnostic("warn", "orphaned_path_event", "Path references an event not present in the event log.", path.path_id, {"event_id": event_id}))
|
||||
return tuple(diagnostics)
|
||||
|
||||
def _ensure_layout(self) -> None:
|
||||
for directory in (self.root, self.profiles_dir, self.nodes_dir, self.edges_dir, self.paths_dir, self.activations_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
metadata_path = self.root / "phase-memory.json"
|
||||
if not metadata_path.exists():
|
||||
_write_json(metadata_path, {"schema_version": LOCAL_STORE_SCHEMA})
|
||||
|
||||
|
||||
class JsonlMemoryEventLog:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.touch(exist_ok=True)
|
||||
|
||||
def append(self, event: MemoryEvent) -> MemoryEvent:
|
||||
if any(existing.event_id == event.event_id for existing in self.list_events()):
|
||||
raise ValueError(f"Duplicate memory event id: {event.event_id}")
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event.to_dict(), sort_keys=True, separators=(",", ":")) + "\n")
|
||||
return event
|
||||
|
||||
def list_events(self, *, kind: str | None = None) -> list[MemoryEvent]:
|
||||
events: list[MemoryEvent] = []
|
||||
for data in self._iter_valid_event_dicts():
|
||||
event = MemoryEvent.from_mapping(data)
|
||||
if kind is None or event.kind == kind:
|
||||
events.append(event)
|
||||
return sorted(events, key=lambda event: (event.timestamp, event.event_id))
|
||||
|
||||
def replay_graph(self, store: FileBackedMemoryGraphStore, *, graph_id: str = "local") -> MemoryGraph:
|
||||
return store.export_graph(graph_id=graph_id, events=self.list_events())
|
||||
|
||||
def diagnostics(self) -> tuple[Diagnostic, ...]:
|
||||
diagnostics: list[Diagnostic] = []
|
||||
seen: set[str] = set()
|
||||
for line_number, raw in self._iter_lines():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
diagnostics.append(Diagnostic("error", "malformed_event_log_line", "Event log line is not valid JSON.", f"line:{line_number}", {"error": str(exc)}))
|
||||
continue
|
||||
schema = data.get("schema_version")
|
||||
if schema and schema != "markitect.memory.event.v1":
|
||||
diagnostics.append(Diagnostic("warn", "unknown_event_schema", "Event declares an unknown schema version.", f"line:{line_number}", {"schema_version": schema}))
|
||||
event_id = str(data.get("id") or data.get("event_id") or "")
|
||||
if event_id in seen:
|
||||
diagnostics.append(Diagnostic("error", "duplicate_event_id", "Event log contains a duplicate event id.", f"line:{line_number}", {"event_id": event_id}))
|
||||
if event_id:
|
||||
seen.add(event_id)
|
||||
return tuple(diagnostics)
|
||||
|
||||
def _iter_valid_event_dicts(self):
|
||||
for _, raw in self._iter_lines():
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
|
||||
def _iter_lines(self):
|
||||
for line_number, raw in enumerate(self.path.read_text(encoding="utf-8").splitlines(), start=1):
|
||||
if raw.strip():
|
||||
yield line_number, raw
|
||||
|
||||
|
||||
class NoopContextPackageCompiler:
|
||||
def compile_selection(self, selection: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -85,3 +243,32 @@ class RecordingAuditSink:
|
||||
stored = dict(event)
|
||||
self.events.append(stored)
|
||||
return {"recorded": True, "index": len(self.events) - 1, "event": stored}
|
||||
|
||||
|
||||
class JsonlAuditSink:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.touch(exist_ok=True)
|
||||
|
||||
def record(self, event: dict[str, Any]) -> dict[str, Any]:
|
||||
stored = dict(event)
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(stored, sort_keys=True, separators=(",", ":")) + "\n")
|
||||
with self.path.open(encoding="utf-8") as handle:
|
||||
index = max(sum(1 for _ in handle) - 1, 0)
|
||||
return {"recorded": True, "index": index, "event": stored}
|
||||
|
||||
|
||||
def _safe_name(identifier: str) -> str:
|
||||
safe = "".join(char if char.isalnum() or char in ("-", "_", ".") else "_" for char in identifier)
|
||||
return safe or "anonymous"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user