""" Console Engine Receipt Emitter Appends receipts to the Console scroll and maintains the per-engine Merkle root. """ import json import os from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Literal, Optional, TypedDict try: import blake3 # type: ignore except ImportError: blake3 = None # fallback to hashlib ENGINE_ID = "engine:console" # Paths relative to VaultMesh repo root DEFAULT_EVENTS_PATH = "receipts/console/console_events.jsonl" DEFAULT_ROOT_PATH = "receipts/console/ROOT.console.txt" ReceiptType = Literal[ "console_genesis", "console_session_start", "console_session_end", "console_command", "console_file_edit", "console_tool_call", "console_approval_request", # Request for approval (pending) "console_approval", # Decision on approval request "console_git_commit", "console_agent_spawn", ] class ConsoleReceipt(TypedDict): """Schema for Console receipts.""" ts: str engine_id: str type: ReceiptType session_id: Optional[str] payload: Dict[str, Any] @dataclass class ConsoleReceiptEmitter: """ Local filesystem emitter for Console receipts. Appends receipts to console_events.jsonl and updates ROOT.console.txt with the computed Merkle root. NOTE: This uses a simple O(n) recompute for the Merkle root. TODO: Replace with shared receipts frontier or Rust FFI for O(log n) updates. """ events_path: str = DEFAULT_EVENTS_PATH root_path: str = DEFAULT_ROOT_PATH vaultmesh_root: Optional[str] = None def __post_init__(self): """Resolve paths relative to VaultMesh root.""" if self.vaultmesh_root is None: # Try to auto-detect from environment or use current directory self.vaultmesh_root = os.environ.get( "VAULTMESH_ROOT", os.getcwd() ) # Make paths absolute root = Path(self.vaultmesh_root) self.events_path = str(root / self.events_path) self.root_path = str(root / self.root_path) def _ensure_dirs(self) -> None: """Ensure parent directories exist.""" os.makedirs(os.path.dirname(self.events_path), exist_ok=True) os.makedirs(os.path.dirname(self.root_path), exist_ok=True) def _now_iso(self) -> str: """Return current UTC timestamp in ISO format.""" return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _vmhash(self, data: bytes) -> str: """VaultMesh hash with algorithm prefix (blake3 preferred).""" if blake3 is not None: return f"blake3:{blake3.blake3(data).hexdigest()}" # Fallback for environments without blake3 import hashlib return f"sha256:{hashlib.sha256(data).hexdigest()}" def _hex_part(self, value: str) -> str: """Return hash hex component (strip optional algorithm prefix).""" return value.split(":", 1)[-1] def _compute_merkle_root(self, hashes: List[str]) -> str: """ Compute Merkle root over a list of VaultMesh hashes. Simple in-process implementation. O(n log n) time. TODO: Replace with incremental frontier for O(log n) updates. """ if not hashes: return self._vmhash(b"empty") if len(hashes) == 1: return hashes[0] level = hashes[:] while len(level) > 1: next_level: List[str] = [] for i in range(0, len(level), 2): left = level[i] right = level[i + 1] if i + 1 < len(level) else left combined = (self._hex_part(left) + self._hex_part(right)).encode("utf-8") next_level.append(self._vmhash(combined)) level = next_level return level[0] def _recompute_root(self) -> None: """ Re-scan the JSONL file, hash each line, and update ROOT.console.txt. This is O(n) but simple and correct for Phase 1 (Nigredo). TODO: Replace with shared receipts frontier for O(log n) updates. """ hashes: List[str] = [] count = 0 if os.path.exists(self.events_path): with open(self.events_path, "rb") as f: for raw_line in f: line = raw_line.rstrip(b"\n") if not line: continue hashes.append(self._vmhash(line)) count += 1 root = self._compute_merkle_root(hashes) # Write updated root file self._ensure_dirs() with open(self.root_path, "w", encoding="utf-8") as f: f.write(f"# VaultMesh Console Root\n") f.write(f"engine_id={ENGINE_ID}\n") f.write(f"merkle_root={root}\n") f.write(f"events={count}\n") f.write(f"updated_at={self._now_iso()}\n") def emit( self, receipt_type: ReceiptType, payload: Dict[str, Any], *, session_id: Optional[str] = None, ts: Optional[str] = None, ) -> ConsoleReceipt: """ Emit a single Console receipt and update the engine root. Args: receipt_type: One of the defined ReceiptType values payload: Domain-specific receipt data session_id: Session identifier (required for non-genesis receipts) ts: Optional timestamp override (ISO format) Returns: The emitted receipt record Example: emitter.emit( "console_session_start", { "agent_type": "opencode", "model_id": "claude-opus-4-5", "caller": "did:vm:human:karol", "project_path": "/root/work/vaultmesh" }, session_id="session-1765123456", ) """ self._ensure_dirs() record: ConsoleReceipt = { "ts": ts or self._now_iso(), "engine_id": ENGINE_ID, "type": receipt_type, "session_id": session_id, "payload": payload, } # Append to scroll (compact JSON, one line) line = json.dumps(record, separators=(",", ":")) with open(self.events_path, "a", encoding="utf-8") as f: f.write(line + "\n") # Update Merkle root self._recompute_root() return record def get_root_info(self) -> Dict[str, Any]: """Read and parse the current ROOT.console.txt file.""" if not os.path.exists(self.root_path): return { "engine_id": ENGINE_ID, "merkle_root": self._vmhash(b"empty"), "events": 0, "updated_at": None, } info = {} with open(self.root_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line.startswith("#") or not line: continue if "=" in line: key, value = line.split("=", 1) if key == "events": info[key] = int(value) else: info[key] = value return info # Convenience singleton for simple use _default_emitter: Optional[ConsoleReceiptEmitter] = None def get_emitter(vaultmesh_root: Optional[str] = None) -> ConsoleReceiptEmitter: """Get or create the default emitter singleton.""" global _default_emitter if _default_emitter is None: _default_emitter = ConsoleReceiptEmitter(vaultmesh_root=vaultmesh_root) return _default_emitter def emit_console_receipt( receipt_type: ReceiptType, payload: Dict[str, Any], *, session_id: Optional[str] = None, ts: Optional[str] = None, vaultmesh_root: Optional[str] = None, ) -> ConsoleReceipt: """ Emit a Console receipt using the default emitter. Convenience function that uses a singleton emitter instance. Args: receipt_type: One of the defined ReceiptType values payload: Domain-specific receipt data session_id: Session identifier (required for non-genesis receipts) ts: Optional timestamp override (ISO format) vaultmesh_root: Optional VaultMesh repo root path Returns: The emitted receipt record """ emitter = get_emitter(vaultmesh_root) return emitter.emit( receipt_type=receipt_type, payload=payload, session_id=session_id, ts=ts, )