gabriel / muse public
plugin.py python
564 lines 20.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Mist domain plugin — content-addressed, signed, agent-native artifact hosting.
2
3 A *mist* is a single versioned artifact stored under a content-derived filename.
4 The filename **is** the identity: the first 12 characters of the base-58 encoding
5 of its SHA-256 digest, optionally suffixed with a human-readable extension.
6
7 Design goals
8 ------------
9 - **Content-addressed** — same bytes always produce the same mist ID; no
10 collision with any other content is possible under SHA-256.
11 - **Domain-agnostic** — MIDI files, Solidity ABIs, JSON Schemas, prose, code,
12 images, and arbitrary binary blobs are all first-class citizens.
13 - **Signed** — every mist carries an MSign Ed25519 author signature; AI-produced
14 mists also embed ``agent_id`` + ``model_id`` for provenance.
15 - **VCS-native** — because MistPlugin satisfies ``MuseDomainPlugin``, all 14
16 ``muse`` CLI commands (status, diff, merge, log, …) work on mist repos without
17 any core engine changes.
18
19 Phase 1 scope
20 -------------
21 Pure domain layer only. No CLI sub-commands, no MuseHub API routes, no UI.
22 Those land in Phases 2–4.
23
24 See ``docs/mists.md`` for the full architecture document.
25 """
26
27 from __future__ import annotations
28
29 import hashlib
30 import os
31 import pathlib
32 import stat as _stat
33 from typing import TYPE_CHECKING
34
35 from muse._version import __version__
36 from muse.core.diff_algorithms import snapshot_diff
37 from muse.core.schema import (
38 DimensionSpec,
39 DomainSchema,
40 SetSchema,
41 )
42 from muse.core.stat_cache import load_cache
43 from muse.core._types import Manifest
44
45 type _ArtifactInfo = dict[str, str]
46 from muse.domain import (
47 DriftReport,
48 LiveState,
49 MergeResult,
50 SnapshotManifest,
51 StateDelta,
52 StateSnapshot,
53 )
54
55 if TYPE_CHECKING:
56 pass
57
58 # ---------------------------------------------------------------------------
59 # Module-level constants
60 # ---------------------------------------------------------------------------
61
62 _DOMAIN_NAME = "mist"
63
64 # Bitcoin base-58 alphabet — omits visually ambiguous characters: 0, O, I, l.
65 # Same bytes always produce the same base-58 string, making mist IDs
66 # deterministic and URL-safe.
67 _BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
68
69 # Length of the mist ID prefix taken from the full base-58 encoding.
70 # 12 characters from SHA-256 → ~10^21 distinct values; collision probability is
71 # negligible for any realistic corpus.
72 _MIST_ID_LENGTH = 12
73
74 # Known artifact type → (category, language/subtype) pairs.
75 # Checked after magic-byte and JSON-key inspection — extension is the last resort.
76 _EXT_MAP: dict[str, tuple[str, str]] = {
77 # Code
78 ".py": ("code", "python"),
79 ".js": ("code", "javascript"),
80 ".ts": ("code", "typescript"),
81 ".tsx": ("code", "typescript"),
82 ".jsx": ("code", "javascript"),
83 ".rs": ("code", "rust"),
84 ".go": ("code", "go"),
85 ".java": ("code", "java"),
86 ".c": ("code", "c"),
87 ".cpp": ("code", "cpp"),
88 ".h": ("code", "c"),
89 ".hpp": ("code", "cpp"),
90 ".rb": ("code", "ruby"),
91 ".swift": ("code", "swift"),
92 ".kt": ("code", "kotlin"),
93 ".sol": ("code", "solidity"),
94 ".vy": ("code", "vyper"),
95 # Prose
96 ".md": ("prose", "markdown"),
97 ".txt": ("prose", "text"),
98 ".rst": ("prose", "restructuredtext"),
99 # Data
100 ".csv": ("data", "csv"),
101 ".toml": ("data", "toml"),
102 ".yaml": ("data", "yaml"),
103 ".yml": ("data", "yaml"),
104 ".xml": ("data", "xml"),
105 # MIDI — also detected by magic bytes (MThd)
106 ".mid": ("midi", "midi"),
107 ".midi": ("midi", "midi"),
108 }
109
110
111 # ---------------------------------------------------------------------------
112 # Pure functions — the domain's intelligence layer
113 # ---------------------------------------------------------------------------
114
115
116 def compute_mist_id(content: bytes) -> str:
117 """Derive the globally unique mist ID from raw artifact bytes.
118
119 The ID is the first :data:`_MIST_ID_LENGTH` characters of the base-58
120 encoding of the artifact's SHA-256 digest. The same bytes always produce
121 the same ID.
122
123 Args:
124 content: Raw bytes of the artifact.
125
126 Returns:
127 A 12-character URL-safe base-58 string, e.g. ``"aB3xQ9fWmK2r"``.
128
129 Examples:
130 >>> compute_mist_id(b"hello")
131 'GJGvdqT2tQ5j'
132 >>> compute_mist_id(b"") != compute_mist_id(b"x")
133 True
134 """
135 digest = hashlib.sha256(content).digest()
136 # Encode the 32-byte digest as a big-endian integer then convert to base-58.
137 n = int.from_bytes(digest, "big")
138 chars: list[str] = []
139 while n:
140 n, remainder = divmod(n, 58)
141 chars.append(_BASE58_ALPHABET[remainder])
142 # Preserve leading zero bytes as base-58 '1' characters.
143 for byte in digest:
144 if byte == 0:
145 chars.append(_BASE58_ALPHABET[0])
146 else:
147 break
148 encoded = "".join(reversed(chars))
149 return encoded[:_MIST_ID_LENGTH]
150
151
152 def detect_artifact_type(filename: str, content: bytes) -> _ArtifactInfo:
153 """Infer the artifact type and language from filename and raw content.
154
155 Detection order (most-to-least reliable):
156 1. Magic bytes — MIDI ``MThd`` header; catches mis-named files.
157 2. JSON key inspection — ABI arrays, JSON Schema ``$schema`` key.
158 3. Extension map — falls back gracefully to ``("unknown", "binary")``.
159
160 Args:
161 filename: Bare filename (no path separators), e.g. ``"contract.abi.json"``.
162 content: Raw bytes of the artifact.
163
164 Returns:
165 A dict with keys ``"artifact_type"`` and ``"language"``, e.g.
166 ``{"artifact_type": "midi", "language": "midi"}`` or
167 ``{"artifact_type": "code", "language": "python"}``.
168
169 Examples:
170 >>> detect_artifact_type("track.mid", b"MThd\\x00\\x00\\x00\\x06")
171 {'artifact_type': 'midi', 'language': 'midi'}
172 >>> detect_artifact_type("schema.json", b'{"$schema":"http://..."}')
173 {'artifact_type': 'json_schema', 'language': 'json'}
174 """
175 # 1. Magic bytes — MIDI
176 if content[:4] == b"MThd":
177 return {"artifact_type": "midi", "language": "midi"}
178
179 # 2. JSON key inspection
180 if filename.endswith(".json") or filename.endswith(".abi"):
181 try:
182 import json as _json
183
184 parsed = _json.loads(content.decode("utf-8", errors="replace"))
185 if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict):
186 # Ethereum ABI is an array of objects each with "type" and "name"
187 if "type" in parsed[0] and "name" in parsed[0]:
188 return {"artifact_type": "abi", "language": "json"}
189 if isinstance(parsed, dict):
190 if "$schema" in parsed:
191 return {"artifact_type": "json_schema", "language": "json"}
192 except Exception:
193 pass
194
195 # 3. Extension map
196 ext = pathlib.PurePosixPath(filename).suffix.lower()
197 if ext in _EXT_MAP:
198 artifact_type, language = _EXT_MAP[ext]
199 return {"artifact_type": artifact_type, "language": language}
200
201 return {"artifact_type": "unknown", "language": "binary"}
202
203
204 def _validate_mist_filename(filename: str) -> None:
205 """Validate a proposed mist filename for safety and correctness.
206
207 This is the security gate for all user-supplied filenames. It rejects
208 any name that could be used to escape the mist store or inject control
209 sequences into terminals.
210
211 Enforced rules
212 --------------
213 - No null bytes (``\\x00``).
214 - No path separators (``/`` or ``\\``).
215 - No directory traversal sequences (``..``).
216 - No control characters (``\\x01``–``\\x1f``, ``\\x7f``).
217 - No ANSI escape sequences (``\\x1b[``).
218 - Length ≤ 255 characters.
219
220 Args:
221 filename: The filename to validate (must be a bare name, no path).
222
223 Raises:
224 ValueError: Describing exactly which rule was violated.
225
226 Examples:
227 >>> _validate_mist_filename("aB3xQ9fWmK2r.py") # valid — no error
228 >>> _validate_mist_filename("../evil")
229 Traceback (most recent call last):
230 ...
231 ValueError: Mist filename must not contain path traversal sequences: '../evil'
232 """
233 if len(filename) > 255:
234 raise ValueError(
235 f"Mist filename exceeds 255-character limit: {len(filename)} chars"
236 )
237 if "\x00" in filename:
238 raise ValueError(f"Mist filename must not contain null bytes: {filename!r}")
239 if ".." in filename:
240 raise ValueError(
241 f"Mist filename must not contain path traversal sequences: {filename!r}"
242 )
243 if "/" in filename or "\\" in filename:
244 raise ValueError(
245 f"Mist filename must not contain path separators: {filename!r}"
246 )
247 if "\x1b[" in filename:
248 raise ValueError(
249 f"Mist filename must not contain ANSI escape sequences: {filename!r}"
250 )
251 for ch in filename:
252 cp = ord(ch)
253 if 0x01 <= cp <= 0x1F or cp == 0x7F:
254 raise ValueError(
255 f"Mist filename must not contain control characters: {filename!r}"
256 )
257
258
259 def extract_mist_symbol_anchors(filename: str, content: bytes) -> list[str]:
260 """Extract symbol anchors for code and structured mist artifacts.
261
262 Delegates to the ``muse.plugins.code.ast_parser`` layer, which supports
263 Python, TypeScript, Solidity, Markdown headings, TOML sections, and more.
264 Returns an empty list for binary or unrecognised file types — these are
265 still valid mists; they just have no intra-file anchor points.
266
267 Args:
268 filename: Bare filename (no path separators), e.g. ``"utils.py"``.
269 content: Raw bytes of the artifact.
270
271 Returns:
272 A list of symbol address strings in ``"filename::SymbolName"`` format,
273 e.g. ``["utils.py::compute_checksum", "utils.py::BaseHandler"]``.
274
275 Examples:
276 >>> anchors = extract_mist_symbol_anchors("add.py", b"def add(a, b): return a + b")
277 >>> "add.py::add" in anchors
278 True
279 """
280 try:
281 from muse.plugins.code.ast_parser import parse_symbols
282
283 tree = parse_symbols(content, filename)
284 # SymbolTree keys are full addresses: "filename.py::SymbolName"
285 # Filter out import pseudo-symbols (kind == "import") by checking address.
286 return [addr for addr in tree if "::import::" not in addr]
287 except Exception:
288 return []
289
290
291 # ---------------------------------------------------------------------------
292 # MistPlugin — MuseDomainPlugin implementation
293 # ---------------------------------------------------------------------------
294
295
296 class MistPlugin:
297 """Domain plugin for mist repositories.
298
299 Satisfies the full :class:`~muse.domain.MuseDomainPlugin` protocol plus the
300 optional OT merge extension (:class:`~muse.domain.StructuredMergePlugin`).
301 No explicit inheritance needed — structural duck-typing applies.
302
303 All 14 ``muse`` CLI commands work immediately on any mist repo once this
304 plugin is registered. The mist-specific behaviour is:
305
306 - Every tracked file is a *mist*: a single content-addressed artifact.
307 - The snapshot manifest maps mist IDs (filenames) to their SHA-256 hashes.
308 - Merges are set-algebraic at file granularity — a mist either exists or not.
309 - Symbol anchors are extracted for code/structured mists; binary mists have none.
310 """
311
312 # ------------------------------------------------------------------
313 # MuseDomainPlugin — required core protocol
314 # ------------------------------------------------------------------
315
316 def snapshot(self, live_state: LiveState) -> StateSnapshot:
317 """Capture the current mist store as a content-addressed manifest.
318
319 Walks every file under ``live_state`` (respecting ``.museignore``),
320 hashing raw bytes with SHA-256. Returns a ``SnapshotManifest`` whose
321 ``files`` dict maps workspace-relative POSIX paths to their digests.
322
323 Args:
324 live_state: Either a ``pathlib.Path`` pointing to the mist store
325 directory, or a ``SnapshotManifest`` dict for in-memory use.
326
327 Returns:
328 A ``SnapshotManifest`` mapping mist filenames to SHA-256 digests.
329 """
330 if isinstance(live_state, pathlib.Path):
331 from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns
332
333 workdir = live_state
334 patterns = resolve_patterns(load_ignore_config(workdir), _DOMAIN_NAME)
335 cache = load_cache(workdir)
336 files: Manifest = {}
337 root_str = str(workdir)
338 prefix_len = len(root_str) + 1
339
340 for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False):
341 dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
342 for fname in sorted(filenames):
343 if fname.startswith("."):
344 continue
345 abs_str = os.path.join(dirpath, fname)
346 try:
347 st = os.lstat(abs_str)
348 except OSError:
349 continue
350 if not _stat.S_ISREG(st.st_mode):
351 continue
352 rel = abs_str[prefix_len:]
353 if os.sep != "/":
354 rel = rel.replace(os.sep, "/")
355 if is_ignored(rel, patterns):
356 continue
357 files[rel] = cache.get_cached(
358 rel, abs_str, st.st_mtime, st.st_size, st.st_ino
359 )
360
361 cache.prune(set(files))
362 cache.save()
363 return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=[])
364
365 # SnapshotManifest dict path — used by merge / diff in memory
366 return live_state
367
368 def diff(
369 self,
370 base: StateSnapshot,
371 target: StateSnapshot,
372 *,
373 repo_root: pathlib.Path | None = None,
374 ) -> StateDelta:
375 """Compute the typed operation list between two mist snapshots.
376
377 Delegates to ``snapshot_diff`` which performs set algebra on the
378 ``files`` dicts: new mists → InsertOp, removed mists → DeleteOp,
379 replaced mists → ReplaceOp.
380
381 Args:
382 base: Snapshot of the earlier state (e.g. HEAD).
383 target: Snapshot of the later state (e.g. working tree).
384
385 Returns:
386 A ``StructuredDelta`` whose ``ops`` list describes every change.
387 """
388 return snapshot_diff(self.schema(), base, target)
389
390 def merge(
391 self,
392 base: StateSnapshot,
393 left: StateSnapshot,
394 right: StateSnapshot,
395 *,
396 repo_root: pathlib.Path | None = None,
397 ) -> MergeResult:
398 """Three-way merge of two mist snapshots against a common ancestor.
399
400 Mists are content-addressed, so set-algebraic merge is correct by
401 construction: if both branches added the same bytes, they added the same
402 mist and there is no conflict.
403
404 Conflict rules
405 ~~~~~~~~~~~~~~
406 - Both sides agree → consensus wins.
407 - Only one side changed → take that side.
408 - Both sides changed differently → conflict (same path, different content).
409
410 Args:
411 base: Common ancestor snapshot.
412 left: Snapshot from the current branch (ours).
413 right: Snapshot from the incoming branch (theirs).
414
415 Returns:
416 A ``MergeResult`` with ``merged`` snapshot and ``conflicts`` list.
417 """
418 base_files = base["files"]
419 left_files = left["files"]
420 right_files = right["files"]
421
422 merged: Manifest = dict(base_files)
423 conflicts: list[str] = []
424
425 all_paths = set(base_files) | set(left_files) | set(right_files)
426 for path in sorted(all_paths):
427 b_val = base_files.get(path)
428 l_val = left_files.get(path)
429 r_val = right_files.get(path)
430
431 if l_val == r_val:
432 # Both sides agree — consensus wins (including both deleted)
433 if l_val is None:
434 merged.pop(path, None)
435 else:
436 merged[path] = l_val
437 elif b_val == l_val:
438 # Only right changed
439 if r_val is None:
440 merged.pop(path, None)
441 else:
442 merged[path] = r_val
443 elif b_val == r_val:
444 # Only left changed
445 if l_val is None:
446 merged.pop(path, None)
447 else:
448 merged[path] = l_val
449 else:
450 # Both changed differently — conflict; keep left as placeholder
451 conflicts.append(path)
452 merged[path] = l_val or r_val or b_val or ""
453
454 return MergeResult(
455 merged=SnapshotManifest(files=merged, domain=_DOMAIN_NAME, directories=[]),
456 conflicts=conflicts,
457 )
458
459 def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
460 """Report how much the mist store has drifted from the last commit.
461
462 Called by ``muse status``. Snapshots the current working tree, diffs
463 it against the committed state, and returns a ``DriftReport``.
464
465 Args:
466 committed: The last committed snapshot.
467 live: Current live state (path or snapshot manifest).
468
469 Returns:
470 A ``DriftReport`` with ``has_drift``, ``summary``, and ``delta``.
471 """
472 current = self.snapshot(live)
473 delta = self.diff(committed, current)
474 has_drift = len(delta["ops"]) > 0
475 return DriftReport(
476 has_drift=has_drift,
477 summary=delta["summary"],
478 delta=delta,
479 )
480
481 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
482 """Apply a delta to the mist store.
483
484 Mists are atomic blobs — the core engine already handles file-level
485 object restoration during ``muse checkout``. No domain-level
486 post-processing is needed.
487
488 Args:
489 delta: The typed operation list to apply.
490 live_state: Current live state.
491
492 Returns:
493 The unchanged live state.
494 """
495 return live_state
496
497 # ------------------------------------------------------------------
498 # Domain schema — required
499 # ------------------------------------------------------------------
500
501 def schema(self) -> DomainSchema:
502 """Declare the structural shape of the mist domain.
503
504 Mists are a **set** of content-addressed artifacts identified by
505 content (same bytes = same mist). The schema drives diff algorithm
506 selection and merge routing.
507
508 Dimensions
509 ----------
510 ``artifacts``
511 The primary dimension: the set of mist files. Identity is
512 ``"by_content"`` — the mist ID *is* the content hash prefix.
513
514 ``metadata``
515 A set of key-value annotation pairs (tags, descriptions, provenance
516 fields). Added in Phase 3 when MuseHub-side metadata is versioned.
517
518 Returns:
519 A ``DomainSchema`` describing the mist domain's structure.
520 """
521 return DomainSchema(
522 domain=_DOMAIN_NAME,
523 description=(
524 "Mist domain — content-addressed, signed, agent-native artifact hosting. "
525 "A mist is a single versioned artifact (code, MIDI, ABI, prose, or any "
526 "binary blob) identified by the first 12 characters of its SHA-256 "
527 "base-58 digest. Same bytes = same mist ID, always."
528 ),
529 top_level=SetSchema(
530 kind="set",
531 element_type="artifact",
532 identity="by_content",
533 ),
534 dimensions=[
535 DimensionSpec(
536 name="artifacts",
537 description=(
538 "The set of mist artifacts in this store. "
539 "Identity is by content — the mist ID is the hash prefix."
540 ),
541 schema=SetSchema(
542 kind="set",
543 element_type="artifact",
544 identity="by_content",
545 ),
546 independent_merge=True,
547 ),
548 DimensionSpec(
549 name="metadata",
550 description=(
551 "Annotation metadata for mists: tags, descriptions, "
552 "provenance fields (agent_id, model_id, signature)."
553 ),
554 schema=SetSchema(
555 kind="set",
556 element_type="annotation",
557 identity="by_content",
558 ),
559 independent_merge=True,
560 ),
561 ],
562 merge_mode="three_way",
563 schema_version=__version__,
564 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago