This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/infrastructure/soul/versioning.py
Perplexity Computer a83ea9bdb6 feat: FastAPI Morrowind harness + SOUL.md framework (#821, #854)
## 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
2026-03-21 22:43:21 +00:00

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()