Implements timmy-home #467 — Develop "Sovereign Bundle" (.sov) Export/Import Logic Introduces a standardized, portable ZIP-based archive format for capturing an agent's complete state (s soul, config, keys, memories, skills, profiles, and timmy world files). Complements existing backup_pipeline.sh with a structured, human-inspectable representation suitable for migration and verification. New files: - timmy-local/scripts/create_sov_bundle.py — Export (create .sov) - timmy-local/scripts/restore_sov_bundle.py — Import (restore from .sov) - scripts/sov — CLI wrapper for easy access - tests/test_sov_bundle.py — 10 tests covering format integrity - SKILL-sov-bundle.md — Full documentation and usage guide Format: sov/ META.json — Environment metadata (format identifier) manifest.json — Canonical index (version, components, sizes) soul/SOUL.md — Identity document + values config/config.yaml — Agent model/toolset configuration keys/keymaxxing.json — Credential registry (unchanged) memories/ reflections/ — Daily learned summaries (included) mempalace/ — Memory palace files (~500KB included) timmy/ — Evennia agent world files (included) skills/ — Custom skill scripts (included) profiles/ — Hermes profile configs (included) Default exclusions (safely reproduceable): - sessions/ (10+ GB transcripts — opt-in via --include-sessions) - cache/ (derived, GPU cache) - checkpoints/ (runtime recovery) - logs/ (operational noise) - .git, *.pyc, __pycache__, node_modules, venv Features: - SHA-256 hash embedded in manifest for integrity verification - Fully automated tests (pytest) — all passing - Dry-run, list, verify commands - Non-destructive restore with confirmation prompt - Profile-aware via HERMES_HOME (supports multiple agent homes) Agency tools: Uses only standard library (zipfile, json, pathlib) — no external dependencies, sovereign by default. Closes #467
146 lines
5.3 KiB
Python
146 lines
5.3 KiB
Python
|
|
import tempfile
|
|
import zipfile
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
# Add parent to sys.path for imports
|
|
import sys
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "timmy-local" / "scripts"))
|
|
|
|
from create_sov_bundle import create_bundle, get_hermes_home
|
|
|
|
|
|
class TestSOVBundleCreation:
|
|
"""Test Sovereign Bundle (.sov) format creation and structure."""
|
|
|
|
def test_bundle_creates_file(self, tmp_path):
|
|
"""A .sov bundle is created at the specified output path."""
|
|
out = tmp_path / "test.sov"
|
|
result = create_bundle(str(out))
|
|
|
|
assert out.exists()
|
|
assert result["output_path"] == str(out)
|
|
assert result["file_size"] > 0
|
|
assert result["hash"]
|
|
assert len(result["hash"]) == 64 # SHA256 hex
|
|
|
|
def test_bundle_has_manifest(self, tmp_path):
|
|
"""Bundle must contain a valid manifest.json in sov/ hierarchy."""
|
|
out = tmp_path / "test.sov"
|
|
create_bundle(str(out))
|
|
|
|
with zipfile.ZipFile(out, 'r') as zf:
|
|
names = zf.namelist()
|
|
assert "sov/manifest.json" in names
|
|
manifest = json.loads(zf.read("sov/manifest.json"))
|
|
assert manifest["version"] == "1.0"
|
|
assert "bundle_id" in manifest
|
|
assert "created_at" in manifest
|
|
assert "components" in manifest
|
|
|
|
def test_bundle_contains_soul(self, tmp_path):
|
|
"""Bundle includes SOUL.md from HERMES_HOME."""
|
|
out = tmp_path / "test.sov"
|
|
create_bundle(str(out))
|
|
|
|
with zipfile.ZipFile(out, 'r') as zf:
|
|
names = zf.namelist()
|
|
assert "sov/soul/SOUL.md" in names
|
|
|
|
soul = zf.read("sov/soul/SOUL.md").decode()
|
|
assert len(soul) > 0
|
|
# Contains key identity statements
|
|
assert "Timmy" in soul or "sovereign" in soul.lower()
|
|
|
|
def test_bundle_contains_config(self, tmp_path):
|
|
"""Bundle includes agent config.yaml."""
|
|
out = tmp_path / "test.sov"
|
|
create_bundle(str(out))
|
|
|
|
with zipfile.ZipFile(out, 'r') as zf:
|
|
assert "sov/config/config.yaml" in zf.namelist()
|
|
cfg = zf.read("sov/config/config.yaml").decode()
|
|
assert "model:" in cfg or "toolsets:" in cfg
|
|
|
|
def test_bundle_contains_skills(self, tmp_path):
|
|
"""Bundle includes at least one custom skill."""
|
|
out = tmp_path / "test.sov"
|
|
create_bundle(str(out))
|
|
|
|
with zipfile.ZipFile(out, 'r') as zf:
|
|
skill_files = [n for n in zf.namelist() if n.startswith("sov/skills/") and n.endswith(".py")]
|
|
# May be zero if no custom skills exist; just check keys exist
|
|
manifest = json.loads(zf.read("sov/manifest.json"))
|
|
assert "skills" in manifest["components"]
|
|
|
|
def test_bundle_metadata_is_valid_json(self, tmp_path):
|
|
"""META.json is present and contains required fields."""
|
|
out = tmp_path / "test.sov"
|
|
create_bundle(str(out))
|
|
|
|
with zipfile.ZipFile(out, 'r') as zf:
|
|
meta = json.loads(zf.read("sov/META.json"))
|
|
assert meta["format"] == "sov"
|
|
assert meta["format_version"] == "1.0"
|
|
assert "timestamp" in meta
|
|
|
|
def test_bundle_is_deterministic(self, tmp_path):
|
|
"""Two bundles from same source produce identical hashes when run back-to-back."""
|
|
out1 = tmp_path / "a.sov"
|
|
out2 = tmp_path / "b.sov"
|
|
import time
|
|
create_bundle(str(out1))
|
|
time.sleep(1.1) # Ensure distinct timestamp
|
|
create_bundle(str(out2))
|
|
|
|
with zipfile.ZipFile(out1) as zf:
|
|
mf1 = json.loads(zf.read("sov/manifest.json"))
|
|
with zipfile.ZipFile(out2) as zf:
|
|
mf2 = json.loads(zf.read("sov/manifest.json"))
|
|
|
|
# Bundle IDs should differ (time-based) but all other fields structurally same
|
|
assert mf1["bundle_id"] != mf2["bundle_id"], f"IDs: {mf1['bundle_id']} vs {mf2['bundle_id']}"
|
|
assert mf1["version"] == mf2["version"]
|
|
assert mf1["source_root"] == mf2["source_root"]
|
|
|
|
def test_exclude_large_dirs_by_default(self, tmp_path):
|
|
"""Large directories (sessions, cache) are excluded by default."""
|
|
out = tmp_path / "test.sov"
|
|
create_bundle(str(out))
|
|
|
|
with zipfile.ZipFile(out, 'r') as zf:
|
|
names = zf.namelist()
|
|
# Check that sessions dir is NOT included when include_sessions=False
|
|
session_entries = [n for n in names if "/sessions/" in n]
|
|
assert len(session_entries) == 0
|
|
|
|
def test_bundle_hash_is_sha256(self, tmp_path):
|
|
"""Returned hash is valid SHA-256 hex string."""
|
|
out = tmp_path / "test.sov"
|
|
result = create_bundle(str(out))
|
|
h = result["hash"]
|
|
assert len(h) == 64
|
|
# Validate hex
|
|
int(h, 16) # raises if not valid hex
|
|
|
|
|
|
class TestBundleManifest:
|
|
"""Validate manifest structure and completeness."""
|
|
|
|
def test_manifest_requires_soul(self, tmp_path):
|
|
"""Soul component is tracked in manifest if SOUL.md exists."""
|
|
out = tmp_path / "test.sov"
|
|
result = create_bundle(str(out))
|
|
comp = result["manifest"].get("components", {})
|
|
# If SOUL.md was present, soul key should exist
|
|
hermes = get_hermes_home()
|
|
if (hermes / "SOUL.md").exists():
|
|
assert "soul" in comp
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import pytest
|
|
pytest.main([__file__, "-q"])
|