from __future__ import annotations import json from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any def utc_now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") @dataclass class MemoryItem: kind: str content: dict[str, Any] created_at: str = field(default_factory=utc_now) task_id: str | None = None step: int | None = None metadata: dict[str, Any] = field(default_factory=dict) def to_dict(self) -> dict[str, Any]: return { "kind": self.kind, "content": self.content, "created_at": self.created_at, "task_id": self.task_id, "step": self.step, "metadata": self.metadata, } @classmethod def from_dict(cls, payload: dict[str, Any]) -> "MemoryItem": return cls( kind=str(payload.get("kind") or "event"), content=payload.get("content") if isinstance(payload.get("content"), dict) else {}, created_at=str(payload.get("created_at") or utc_now()), task_id=str(payload.get("task_id")) if payload.get("task_id") is not None else None, step=int(payload["step"]) if isinstance(payload.get("step"), int) else None, metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}, ) class Blackboard: """ Lightweight file-backed memory shared by the local Host/App agent layers. It intentionally mirrors UFO's blackboard shape without pulling in the full dependency stack: requests, trajectories, observations, screenshots and facts. """ DEFAULT_KINDS = ("requests", "trajectories", "observations", "screenshots", "facts") def __init__(self, path: Path, max_items_per_kind: int = 200): self.path = path self.max_items_per_kind = max_items_per_kind self._content: dict[str, list[MemoryItem]] = {kind: [] for kind in self.DEFAULT_KINDS} self.load() def load(self) -> None: if not self.path.exists(): return try: raw = json.loads(self.path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return if not isinstance(raw, dict): return for kind in self.DEFAULT_KINDS: entries = raw.get(kind) if isinstance(entries, list): self._content[kind] = [ MemoryItem.from_dict(item) for item in entries if isinstance(item, dict) ][-self.max_items_per_kind :] def save(self) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) payload = { kind: [item.to_dict() for item in items[-self.max_items_per_kind :]] for kind, items in self._content.items() } self.path.write_text( json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8", ) def append( self, kind: str, content: dict[str, Any], *, task_id: str | None = None, step: int | None = None, metadata: dict[str, Any] | None = None, ) -> MemoryItem: if kind not in self._content: self._content[kind] = [] item = MemoryItem( kind=kind, content=content, task_id=task_id, step=step, metadata=metadata or {}, ) self._content[kind].append(item) self._content[kind] = self._content[kind][-self.max_items_per_kind :] self.save() return item def recent(self, kind: str, limit: int = 10) -> list[dict[str, Any]]: return [item.to_dict() for item in self._content.get(kind, [])[-limit:]] def snapshot(self, limit: int = 5) -> dict[str, list[dict[str, Any]]]: return {kind: self.recent(kind, limit) for kind in self._content} def summary(self) -> dict[str, Any]: return { "path": str(self.path), "counts": {kind: len(items) for kind, items in self._content.items()}, "latest_trajectory": self.recent("trajectories", 1), }