"""Social domain plugin — cryptographically-grounded, algo-free social network on Muse. A social repo's working tree is a directory of JSON files organised into four namespaces that map directly onto Muse primitives: posts/.json — individual signed posts (content-addressed) reactions/.json — emoji reactions, optional MPay tip attachment graph/following.json — who this identity follows profile.json — display name, bio, avatar ref, AVAX address The snapshot is a standard ``SnapshotManifest`` over these files — same content-addressing as every other Muse domain. Your timeline is a live ``muse merge --all-remotes`` of every feed you follow. No algorithm. No suppression. Pure chronological state, owned by you, signed by your Ed25519 key. Merge rules ----------- ``posts/`` and ``reactions/`` Set-algebraic: both sides can independently add posts and reactions. Same content hash ⇒ same ID ⇒ no conflict (reposts deduplicate naturally). ``graph/`` Set-algebraic: following list is a content-addressed file; independent changes on each branch merge without conflicts. ``profile.json`` Last-writer-wins when only one side changed from base; conflict when both sides changed the profile independently. This is Phase 00: core plugin with full protocol compliance. CLI commands, wire streaming, and MuseHub UI integration follow in subsequent phases. """ import hashlib import os import pathlib import _stat from muse._version import __version__ from muse.core.diff_algorithms import snapshot_diff from muse.core.schema import ( DimensionSpec, DomainSchema, SetSchema, ) from muse.core.stat_cache import load_cache from muse.core.types import Manifest from muse.domain import ( DriftReport, LiveState, MergeResult, MuseDomainPlugin, SnapshotManifest, StateDelta, StateSnapshot, ) _DOMAIN_NAME = "social" class SocialPlugin: """Domain plugin for social repositories. Satisfies the full :class:`~muse.domain.MuseDomainPlugin` protocol. No explicit inheritance needed — structural duck-typing applies. All ``muse`` CLI commands work immediately on any social repo once this plugin is registered. The social-specific behaviour is: - Posts and reactions are content-addressed objects — same bytes, same ID. - Merge is set-algebraic for posts, reactions, and the follow graph. - Profile edits conflict when both branches change independently. - Every commit carries full Ed25519 provenance via ``--sign``. """ # ------------------------------------------------------------------ # MuseDomainPlugin — required core protocol # ------------------------------------------------------------------ def snapshot(self, live_state: LiveState) -> StateSnapshot: """Capture the current social state as a content-addressed manifest. Walks every JSON file under ``live_state`` (respecting ``.museignore``), hashing raw bytes with SHA-256. Returns a ``SnapshotManifest`` whose ``files`` dict maps workspace-relative POSIX paths to their digests. Args: live_state: Either a ``pathlib.Path`` pointing to the social state directory, or a ``SnapshotManifest`` dict for in-memory use. Returns: A ``SnapshotManifest`` mapping social file paths to SHA-256 digests. """ if isinstance(live_state, pathlib.Path): from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns workdir = live_state patterns = resolve_patterns(load_ignore_config(workdir), _DOMAIN_NAME) cache = load_cache(workdir) files: Manifest = {} root_str = str(workdir) prefix_len = len(root_str) + 1 for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): dirnames[:] = sorted(d for d in dirnames if not d.startswith(".")) for fname in sorted(filenames): if fname.startswith("."): continue abs_str = os.path.join(dirpath, fname) try: st = os.lstat(abs_str) except OSError: continue if not _stat.S_ISREG(st.st_mode): continue rel = abs_str[prefix_len:] if os.sep != "/": rel = rel.replace(os.sep, "/") if is_ignored(rel, patterns): continue files[rel] = cache.get_cached( rel, abs_str, st.st_mtime, st.st_size, st.st_ino ) cache.prune(set(files)) cache.save() return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=[]) return live_state def diff( self, base: StateSnapshot, target: StateSnapshot, *, repo_root: pathlib.Path | None = None, ) -> StateDelta: """Compute the typed operation list between two social snapshots. Delegates to ``snapshot_diff`` which performs set algebra on the ``files`` dicts: new files → InsertOp, removed files → DeleteOp, replaced files → ReplaceOp. Args: base: Snapshot of the earlier state (e.g. HEAD). target: Snapshot of the later state (e.g. working tree). Returns: A ``StructuredDelta`` whose ``ops`` list describes every change. """ return snapshot_diff(self.schema(), base, target) def merge( self, base: StateSnapshot, left: StateSnapshot, right: StateSnapshot, *, repo_root: pathlib.Path | None = None, ) -> MergeResult: """Three-way merge of two social snapshots against a common ancestor. Merge rules by namespace: - ``posts/`` and ``reactions/`` — set-algebraic: both sides can independently add posts and reactions. Content-addressed IDs mean the same post from both sides has no conflict. - ``graph/`` — set-algebraic: independent follow-list changes merge without conflicts. - ``profile.json`` — last-writer-wins when only one side changed; conflict when both sides independently modified the profile. Args: base: Common ancestor snapshot. left: Snapshot from the current branch (ours). right: Snapshot from the incoming branch (theirs). Returns: A ``MergeResult`` with ``merged`` snapshot and ``conflicts`` list. """ base_files = base["files"] left_files = left["files"] right_files = right["files"] merged: Manifest = dict(base_files) conflicts: list[str] = [] all_paths = set(base_files) | set(left_files) | set(right_files) for path in sorted(all_paths): b_val = base_files.get(path) l_val = left_files.get(path) r_val = right_files.get(path) if l_val == r_val: # Both sides agree (including both deleted) if l_val is None: merged.pop(path, None) else: merged[path] = l_val elif b_val == l_val: # Only right changed if r_val is None: merged.pop(path, None) else: merged[path] = r_val elif b_val == r_val: # Only left changed if l_val is None: merged.pop(path, None) else: merged[path] = l_val else: # Both sides changed independently — conflict conflicts.append(path) merged[path] = l_val or r_val or b_val or "" return MergeResult( merged=SnapshotManifest(files=merged, domain=_DOMAIN_NAME, directories=[]), conflicts=conflicts, ) def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport: """Report how much the social state has drifted from the last commit. Called by ``muse status``. Snapshots the current working tree, diffs it against the committed state, and returns a ``DriftReport``. Args: committed: The last committed snapshot. live: Current live state (path or snapshot manifest). Returns: A ``DriftReport`` with ``has_drift``, ``summary``, and ``delta``. """ current = self.snapshot(live) delta = self.diff(committed, current) has_drift = len(delta["ops"]) > 0 return DriftReport( has_drift=has_drift, summary=delta["summary"], delta=delta, ) def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState: """Apply a delta to the social state. Social files are atomic JSON blobs — the core engine handles file-level object restoration during ``muse checkout``. No domain-level post-processing is needed. Args: delta: The typed operation list to apply. live_state: Current live state. Returns: The unchanged live state (core engine handles file writes). """ return live_state def schema(self) -> DomainSchema: """Declare the structural shape of the social domain. Four dimensions model the independent state namespaces of a social feed. Posts and reactions are content-addressed sets — independent merge with no conflict possible. The follow graph is also independently mergeable. Profile edits are non-independent: concurrent changes conflict. Returns: A ``DomainSchema`` describing the social domain's structure. """ return DomainSchema( domain=_DOMAIN_NAME, description=( "Social domain — cryptographically-grounded, algo-free real-time " "social network on Muse. Every post is a content-addressed object " "signed with Ed25519. Your timeline is a live DAG merge of every " "feed you follow. No algorithm. No suppression. Pure chronological " "state, owned by you." ), top_level=SetSchema( kind="set", element_type="social_object", identity="by_content", ), dimensions=[ DimensionSpec( name="posts", description=( "Content-addressed post objects. Same bytes = same post ID — " "reposts across followed feeds deduplicate automatically." ), schema=SetSchema( kind="set", element_type="post", identity="by_content", ), independent_merge=True, ), DimensionSpec( name="reactions", description=( "Emoji reactions with optional MPay tip attachments. " "Content-addressed; concurrent reactions from both branches " "always merge cleanly via set-union." ), schema=SetSchema( kind="set", element_type="reaction", identity="by_content", ), independent_merge=True, ), DimensionSpec( name="graph", description=( "Social graph state: following list, follower list. " "Independent — adding a follow on one branch and a different " "follow on another branch always merges without conflict." ), schema=SetSchema( kind="set", element_type="graph_entry", identity="by_content", ), independent_merge=True, ), DimensionSpec( name="profile", description=( "Identity profile: handle, bio, avatar ref, AVAX address, " "linked accounts. Non-independent — concurrent profile edits " "on both branches produce a conflict requiring human resolution." ), schema=SetSchema( kind="set", element_type="profile", identity="by_content", ), independent_merge=False, ), ], merge_mode="three_way", schema_version=__version__, ) plugin = SocialPlugin()