272 lines
8.5 KiB
Python
272 lines
8.5 KiB
Python
"""
|
|
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,
|
|
)
|