125 lines
4.2 KiB
Python
125 lines
4.2 KiB
Python
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),
|
|
}
|