forked from Rockachopa/Timmy-time-dashboard
## FastAPI Morrowind Harness (#821) - GET /api/v1/morrowind/perception — reads perception.json, validates against PerceptionOutput schema - POST /api/v1/morrowind/command — validates CommandInput, logs via CommandLogger, stubs bridge forwarding - GET /api/v1/morrowind/status — connection state, last perception, queue depth, agent vitals - Router registered in dashboard app alongside existing routers - 12 tests with FastAPI TestClient ## SOUL.md Framework (#854) - docs/soul-framework/ — template, authoring guide, role extensions - src/infrastructure/soul/loader.py — parse SOUL.md into structured SoulDocument objects with section extraction and merge support - src/infrastructure/soul/validator.py — validate structure, detect contradictions between values/constraints - src/infrastructure/soul/versioning.py — hash-based version tracking with JSON persistence - 27 tests covering loader, validator, versioning, and Timmy's soul Builds on PR #864 (protocol spec + command log). Closes #821 Closes #854
144 lines
4.4 KiB
Python
144 lines
4.4 KiB
Python
"""Track SOUL.md version history using content hashing.
|
|
|
|
Each time a SOUL.md is loaded or modified, a content hash is computed.
|
|
The version log records when the hash changed, providing an audit trail
|
|
of identity evolution without relying on git history.
|
|
|
|
Usage::
|
|
|
|
from infrastructure.soul.versioning import SoulVersionTracker
|
|
|
|
tracker = SoulVersionTracker("data/soul_versions.json")
|
|
tracker.record("memory/self/soul.md")
|
|
history = tracker.get_history("memory/self/soul.md")
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_VERSION_LOG = Path("data/soul_versions.json")
|
|
|
|
|
|
@dataclass
|
|
class VersionEntry:
|
|
"""A single version record."""
|
|
|
|
content_hash: str
|
|
recorded_at: str
|
|
source_path: str
|
|
soul_version: str = ""
|
|
note: str = ""
|
|
|
|
|
|
@dataclass
|
|
class VersionLog:
|
|
"""Persistent log of SOUL.md version changes."""
|
|
|
|
entries: list[VersionEntry] = field(default_factory=list)
|
|
|
|
def to_json(self) -> str:
|
|
return json.dumps([asdict(e) for e in self.entries], indent=2)
|
|
|
|
@classmethod
|
|
def from_json(cls, text: str) -> VersionLog:
|
|
data = json.loads(text)
|
|
entries = [VersionEntry(**e) for e in data]
|
|
return cls(entries=entries)
|
|
|
|
|
|
class SoulVersionTracker:
|
|
"""Hash-based version tracker for SOUL.md files.
|
|
|
|
Args:
|
|
log_path: Where to persist the version log (JSON file).
|
|
"""
|
|
|
|
def __init__(self, log_path: str | Path = DEFAULT_VERSION_LOG) -> None:
|
|
self._log_path = Path(log_path)
|
|
self._log = self._load()
|
|
|
|
# -- Public API ----------------------------------------------------------
|
|
|
|
def record(
|
|
self,
|
|
soul_path: str | Path,
|
|
*,
|
|
note: str = "",
|
|
soul_version: str = "",
|
|
) -> VersionEntry | None:
|
|
"""Record the current state of a SOUL.md file.
|
|
|
|
Returns the new :class:`VersionEntry` if the content has changed
|
|
since the last recording, or ``None`` if unchanged.
|
|
"""
|
|
soul_path = Path(soul_path)
|
|
content = soul_path.read_text(encoding="utf-8")
|
|
content_hash = self._hash(content)
|
|
|
|
# Check if already recorded.
|
|
existing = self.get_history(str(soul_path))
|
|
if existing and existing[-1].content_hash == content_hash:
|
|
return None
|
|
|
|
entry = VersionEntry(
|
|
content_hash=content_hash,
|
|
recorded_at=datetime.now(UTC).isoformat(),
|
|
source_path=str(soul_path),
|
|
soul_version=soul_version,
|
|
note=note,
|
|
)
|
|
self._log.entries.append(entry)
|
|
self._save()
|
|
|
|
logger.info(
|
|
"SOUL version recorded: %s (hash=%s…)",
|
|
soul_path,
|
|
content_hash[:12],
|
|
)
|
|
return entry
|
|
|
|
def get_history(self, source_path: str) -> list[VersionEntry]:
|
|
"""Return version entries for a specific SOUL.md path."""
|
|
return [e for e in self._log.entries if e.source_path == source_path]
|
|
|
|
def get_current_hash(self, source_path: str) -> str | None:
|
|
"""Return the most recent content hash, or ``None`` if untracked."""
|
|
history = self.get_history(source_path)
|
|
return history[-1].content_hash if history else None
|
|
|
|
def has_changed(self, soul_path: str | Path) -> bool:
|
|
"""Check whether a SOUL.md file has changed since last recording."""
|
|
soul_path = Path(soul_path)
|
|
if not soul_path.exists():
|
|
return False
|
|
content = soul_path.read_text(encoding="utf-8")
|
|
current_hash = self._hash(content)
|
|
last_hash = self.get_current_hash(str(soul_path))
|
|
return last_hash != current_hash
|
|
|
|
# -- Persistence ---------------------------------------------------------
|
|
|
|
def _load(self) -> VersionLog:
|
|
if self._log_path.exists():
|
|
try:
|
|
return VersionLog.from_json(self._log_path.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, KeyError) as exc:
|
|
logger.warning("Corrupt version log, starting fresh: %s", exc)
|
|
return VersionLog()
|
|
|
|
def _save(self) -> None:
|
|
self._log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._log_path.write_text(self._log.to_json(), encoding="utf-8")
|
|
|
|
@staticmethod
|
|
def _hash(content: str) -> str:
|
|
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|