plugin.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
34 days ago
| 1 | """Knowtation domain plugin — vault-native version control for Markdown knowledge bases. |
| 2 | |
| 3 | This plugin implements :class:`~muse.domain.MuseDomainPlugin` for |
| 4 | Knowtation-style Markdown vaults. The unit of change is a *note* (its |
| 5 | frontmatter, heading sections, outbound links, entity references, and mist |
| 6 | attachment IDs) rather than a line of text. |
| 7 | |
| 8 | Philosophy |
| 9 | ---------- |
| 10 | A Knowtation vault is a graph of Markdown documents connected by |
| 11 | ``[[wikilinks]]``, typed entity references, and shared mist attachment IDs. |
| 12 | Two commits that only reformat whitespace in a note produce no semantic delta. |
| 13 | Frontmatter tag-set additions auto-merge without conflicts. Section-level |
| 14 | changes are detected at heading granularity rather than line granularity. |
| 15 | |
| 16 | Live State |
| 17 | ---------- |
| 18 | ``LiveState`` is either a ``pathlib.Path`` pointing at the vault root or a |
| 19 | ``SnapshotManifest`` dict. The path form is used by the CLI; the dict form |
| 20 | is used by in-memory merge and diff operations. |
| 21 | |
| 22 | Snapshot Format |
| 23 | --------------- |
| 24 | A knowtation snapshot is a ``SnapshotManifest``: |
| 25 | |
| 26 | .. code-block:: json |
| 27 | |
| 28 | { |
| 29 | "files": { |
| 30 | "notes/My Note.md": "<sha256-of-raw-bytes>", |
| 31 | "attachments/diagram.svg": "<sha256-of-raw-bytes>" |
| 32 | }, |
| 33 | "domain": "knowtation" |
| 34 | } |
| 35 | |
| 36 | The ``files`` values are **raw-bytes SHA-256 hashes** (not semantic hashes). |
| 37 | This guarantees the object store can restore files verbatim on |
| 38 | ``muse checkout``. Semantic identity (frontmatter + section hashing) is used |
| 39 | only inside ``diff()`` when constructing the structured delta. |
| 40 | |
| 41 | Binary attachments are *not* tracked by this plugin — they live as ``mist`` |
| 42 | artifacts and are referenced from note frontmatter via their 12-char mist IDs |
| 43 | (``attachments: [<mist-id>, ...]``). See Phase 1.7. |
| 44 | |
| 45 | Schema |
| 46 | ------ |
| 47 | The knowtation domain schema declares six dimensions: |
| 48 | |
| 49 | ``notes`` |
| 50 | The note / directory tree — ``TreeSchema`` with GumTree diff. |
| 51 | |
| 52 | ``frontmatter`` |
| 53 | The YAML frontmatter key → value pairs — ``MapSchema`` with set-valued |
| 54 | entries. |
| 55 | |
| 56 | ``sections`` |
| 57 | The ordered Markdown heading sections — ``SequenceSchema`` with Myers diff |
| 58 | for readable section-level diffs. |
| 59 | |
| 60 | ``links`` |
| 61 | The outbound link set (wikilinks + Markdown refs) — ``SetSchema``. |
| 62 | |
| 63 | ``entities`` |
| 64 | The entity reference set extracted from frontmatter ``entity`` / ``entities`` |
| 65 | keys — ``SetSchema`` with ``by_content`` identity. |
| 66 | |
| 67 | ``attachments`` |
| 68 | The mist attachment-ID set from frontmatter ``attachments`` key — |
| 69 | ``SetSchema`` with ``by_content`` identity. Added by Phase 1.7; present |
| 70 | in the schema skeleton from Phase 1.1 so downstream consumers can rely on |
| 71 | its existence. |
| 72 | """ |
| 73 | |
| 74 | from __future__ import annotations |
| 75 | |
| 76 | import hashlib |
| 77 | import logging |
| 78 | import os |
| 79 | import pathlib |
| 80 | import stat as _stat |
| 81 | |
| 82 | from muse._version import __version__ |
| 83 | from muse.core.attributes import load_attributes, resolve_strategy |
| 84 | from muse.core.diff_algorithms import snapshot_diff |
| 85 | from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns |
| 86 | from muse.core.snapshot import ( |
| 87 | _BUILTIN_SECRET_PATTERNS, |
| 88 | directories_from_manifest, |
| 89 | load_ignore_patterns, |
| 90 | ) |
| 91 | from muse.core.stat_cache import load_cache |
| 92 | from muse.core.schema import ( |
| 93 | DimensionSpec, |
| 94 | DomainSchema, |
| 95 | MapSchema, |
| 96 | SequenceSchema, |
| 97 | SetSchema, |
| 98 | TreeSchema, |
| 99 | ) |
| 100 | from muse.domain import ( |
| 101 | DeleteOp, |
| 102 | DomainOp, |
| 103 | DriftReport, |
| 104 | InsertOp, |
| 105 | LiveState, |
| 106 | MergeResult, |
| 107 | SnapshotManifest, |
| 108 | StateDelta, |
| 109 | StateSnapshot, |
| 110 | StructuredDelta, |
| 111 | ) |
| 112 | from muse.core.store import Manifest |
| 113 | |
| 114 | logger = logging.getLogger(__name__) |
| 115 | |
| 116 | type _AppliedStrategies = dict[str, str] |
| 117 | |
| 118 | _DOMAIN_NAME = "knowtation" |
| 119 | |
| 120 | # Vault directories that are never versioned regardless of .museignore. |
| 121 | # Includes standard tool-generated dirs (mirrored from code plugin) plus |
| 122 | # vault-native config and cache dirs that should never enter the commit graph. |
| 123 | _ALWAYS_IGNORE_DIRS: frozenset[str] = frozenset({ |
| 124 | # Version control and Muse internals |
| 125 | ".git", |
| 126 | ".muse", |
| 127 | # Python artefacts |
| 128 | "__pycache__", |
| 129 | ".mypy_cache", |
| 130 | ".pytest_cache", |
| 131 | ".ruff_cache", |
| 132 | # JS / Node artefacts |
| 133 | "node_modules", |
| 134 | # Virtual environments |
| 135 | ".venv", |
| 136 | "venv", |
| 137 | ".tox", |
| 138 | ".nox", |
| 139 | # Coverage and build outputs |
| 140 | ".coverage", |
| 141 | "htmlcov", |
| 142 | "dist", |
| 143 | "build", |
| 144 | ".eggs", |
| 145 | # macOS metadata |
| 146 | ".DS_Store", |
| 147 | # Vault-native tool directories that must not be versioned |
| 148 | ".obsidian", # Obsidian IDE workspace config + cache |
| 149 | ".logseq", # LogSeq config and cache |
| 150 | ".trash", # Obsidian soft-delete trash folder |
| 151 | ".knowtation", # Knowtation vector index and stat cache (runtime data) |
| 152 | }) |
| 153 | |
| 154 | |
| 155 | class KnowtationPlugin: |
| 156 | """Muse domain plugin for Knowtation Markdown vaults. |
| 157 | |
| 158 | Implements the six core ``MuseDomainPlugin`` protocol methods. Does not |
| 159 | yet implement ``StructuredMergePlugin`` (OT merge) or ``CRDTPlugin`` |
| 160 | (convergent join) — those are added in Phases 2 and beyond. |
| 161 | |
| 162 | The plugin is stateless. The module-level singleton :data:`plugin` is |
| 163 | the standard entry point registered in ``muse/plugins/registry.py``. |
| 164 | """ |
| 165 | |
| 166 | # ------------------------------------------------------------------ |
| 167 | # 1. snapshot |
| 168 | # ------------------------------------------------------------------ |
| 169 | |
| 170 | def snapshot(self, live_state: LiveState) -> StateSnapshot: |
| 171 | """Capture the current vault as a content-addressed manifest. |
| 172 | |
| 173 | Walks all regular files under *live_state*, hashing each one with |
| 174 | SHA-256 (raw bytes). Honours ``.museignore`` and always ignores |
| 175 | vault-native tool directories (``".obsidian"``, ``".knowtation"``, etc.) |
| 176 | and standard build artefacts. |
| 177 | |
| 178 | Uses ``os.walk`` with in-place ``dirnames`` pruning so that always- |
| 179 | ignored and hidden-from-versioning directories are never descended |
| 180 | into. The ``StatCache`` is consulted before hashing so unchanged |
| 181 | notes are not re-read from disk. |
| 182 | |
| 183 | Binary attachment blobs should be stored in mist and referenced via |
| 184 | frontmatter ``attachments`` keys. They are tracked here as |
| 185 | file-level blobs only if they happen to live inside the vault root; |
| 186 | the semantic attachment dimension is populated by the note parser in |
| 187 | Phase 1.4. |
| 188 | |
| 189 | Args: |
| 190 | live_state: A ``pathlib.Path`` pointing to the vault root, or an |
| 191 | existing ``SnapshotManifest`` dict (returned as-is). |
| 192 | |
| 193 | Returns: |
| 194 | A ``SnapshotManifest`` mapping workspace-relative POSIX paths to |
| 195 | their SHA-256 raw-bytes digests, with ``domain="knowtation"``. |
| 196 | """ |
| 197 | if not isinstance(live_state, pathlib.Path): |
| 198 | return live_state |
| 199 | |
| 200 | workdir = live_state |
| 201 | patterns = ( |
| 202 | _BUILTIN_SECRET_PATTERNS |
| 203 | + resolve_patterns(load_ignore_config(workdir), _DOMAIN_NAME) |
| 204 | ) |
| 205 | cache = load_cache(workdir) |
| 206 | files: Manifest = {} |
| 207 | musekeep_dirs: set[str] = set() |
| 208 | root_str = str(workdir) |
| 209 | prefix_len = len(root_str) + 1 |
| 210 | |
| 211 | for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False): |
| 212 | rel_dir = "" |
| 213 | if dirpath != root_str: |
| 214 | rel_dir = dirpath[prefix_len:] |
| 215 | if os.sep != "/": |
| 216 | rel_dir = rel_dir.replace(os.sep, "/") |
| 217 | |
| 218 | # Prune subdirectories: skip _ALWAYS_IGNORE_DIRS, directories |
| 219 | # whose contents are entirely ignored by .museignore, and nested |
| 220 | # Muse repos (treated as independent units). |
| 221 | dirnames[:] = sorted( |
| 222 | d for d in dirnames |
| 223 | if d not in _ALWAYS_IGNORE_DIRS |
| 224 | and not is_ignored( |
| 225 | f"{rel_dir}/{d}/_" if rel_dir else f"{d}/_", patterns |
| 226 | ) |
| 227 | and not os.path.isdir(os.path.join(dirpath, d, ".muse")) |
| 228 | ) |
| 229 | |
| 230 | for fname in sorted(filenames): |
| 231 | abs_str = os.path.join(dirpath, fname) |
| 232 | try: |
| 233 | st = os.lstat(abs_str) |
| 234 | except OSError: |
| 235 | continue |
| 236 | if not _stat.S_ISREG(st.st_mode): |
| 237 | continue |
| 238 | rel = abs_str[prefix_len:] |
| 239 | if os.sep != "/": |
| 240 | rel = rel.replace(os.sep, "/") |
| 241 | if is_ignored(rel, patterns): |
| 242 | continue |
| 243 | files[rel] = cache.get_cached( |
| 244 | rel, abs_str, st.st_mtime, st.st_size, st.st_ino |
| 245 | ) |
| 246 | if fname == ".musekeep" and rel_dir: |
| 247 | musekeep_dirs.add(rel_dir) |
| 248 | |
| 249 | cache.prune(set(files)) |
| 250 | cache.save() |
| 251 | |
| 252 | # Tracked directories: all directories that contain at least one |
| 253 | # versioned file, plus any directories containing a .musekeep marker. |
| 254 | dirs = sorted(set(directories_from_manifest(files)) | musekeep_dirs) |
| 255 | return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=dirs) |
| 256 | |
| 257 | # ------------------------------------------------------------------ |
| 258 | # 2. diff |
| 259 | # ------------------------------------------------------------------ |
| 260 | |
| 261 | def diff( |
| 262 | self, |
| 263 | base: StateSnapshot, |
| 264 | target: StateSnapshot, |
| 265 | *, |
| 266 | repo_root: pathlib.Path | None = None, |
| 267 | ) -> StateDelta: |
| 268 | """Compute the structured delta between two vault snapshots. |
| 269 | |
| 270 | Phase 1.1 skeleton: delegates to ``snapshot_diff`` for file-level |
| 271 | operations using the declared domain schema. Phase 1.4 will add |
| 272 | note-level (frontmatter + section) ``PatchOp`` entries when |
| 273 | ``repo_root`` is provided. |
| 274 | |
| 275 | Args: |
| 276 | base: Base snapshot (older state). |
| 277 | target: Target snapshot (newer state). |
| 278 | repo_root: Repository root for object-store access (unused in |
| 279 | Phase 1.1 but wired for the Phase 1.4 parser upgrade). |
| 280 | |
| 281 | Returns: |
| 282 | A ``StructuredDelta`` with ``domain="knowtation"``. |
| 283 | """ |
| 284 | # Phase 1.1: coarse file-level diff via schema-driven snapshot_diff. |
| 285 | # Phase 1.4 will upgrade this to note-level PatchOps with |
| 286 | # frontmatter + section child_ops when repo_root is provided. |
| 287 | delta = snapshot_diff(self.schema(), base, target) |
| 288 | return StructuredDelta( |
| 289 | domain=_DOMAIN_NAME, |
| 290 | ops=delta.get("ops", []), |
| 291 | summary=delta.get("summary", ""), |
| 292 | ) |
| 293 | |
| 294 | # ------------------------------------------------------------------ |
| 295 | # 3. merge |
| 296 | # ------------------------------------------------------------------ |
| 297 | |
| 298 | def merge( |
| 299 | self, |
| 300 | base: StateSnapshot, |
| 301 | left: StateSnapshot, |
| 302 | right: StateSnapshot, |
| 303 | *, |
| 304 | repo_root: pathlib.Path | None = None, |
| 305 | ) -> MergeResult: |
| 306 | """Three-way merge at file granularity, respecting ``.museattributes``. |
| 307 | |
| 308 | Standard three-way logic augmented by per-path strategy overrides |
| 309 | declared in ``.museattributes``. Phase 2.4 will upgrade this to |
| 310 | section-level OT merge via ``StructuredMergePlugin.merge_ops``. |
| 311 | |
| 312 | - Both sides agree → consensus wins (including both deleted). |
| 313 | - Only one side changed → take that side. |
| 314 | - Both sides changed differently → consult ``.museattributes``: |
| 315 | |
| 316 | - ``ours`` — take left; remove from conflict list. |
| 317 | - ``theirs`` — take right; remove from conflict list. |
| 318 | - ``base`` — revert to ancestor; remove from conflict list. |
| 319 | - ``union`` — keep all additions; prefer left for conflicts. |
| 320 | - ``manual`` — force into conflict list regardless. |
| 321 | - ``auto`` — default three-way conflict. |
| 322 | |
| 323 | Args: |
| 324 | base: Common ancestor snapshot. |
| 325 | left: Our branch snapshot. |
| 326 | right: Their branch snapshot. |
| 327 | repo_root: Repository root; when provided, ``.museattributes`` is |
| 328 | consulted for per-path strategy overrides. |
| 329 | |
| 330 | Returns: |
| 331 | A ``MergeResult`` with the reconciled snapshot, any file-level |
| 332 | conflicts, and ``applied_strategies`` recording which rules fired. |
| 333 | """ |
| 334 | attrs = load_attributes(repo_root, domain=_DOMAIN_NAME) if repo_root else [] |
| 335 | |
| 336 | base_files = base["files"] |
| 337 | left_files = left["files"] |
| 338 | right_files = right["files"] |
| 339 | |
| 340 | merged: Manifest = dict(base_files) |
| 341 | conflicts: list[str] = [] |
| 342 | applied_strategies: _AppliedStrategies = {} |
| 343 | |
| 344 | all_paths = set(base_files) | set(left_files) | set(right_files) |
| 345 | for path in sorted(all_paths): |
| 346 | b = base_files.get(path) |
| 347 | l = left_files.get(path) |
| 348 | r = right_files.get(path) |
| 349 | |
| 350 | if l == r: |
| 351 | if l is None: |
| 352 | merged.pop(path, None) |
| 353 | else: |
| 354 | merged[path] = l |
| 355 | if attrs and resolve_strategy(attrs, path) == "manual": |
| 356 | conflicts.append(path) |
| 357 | applied_strategies[path] = "manual" |
| 358 | elif b == l: |
| 359 | if r is None: |
| 360 | merged.pop(path, None) |
| 361 | else: |
| 362 | merged[path] = r |
| 363 | if attrs and resolve_strategy(attrs, path) == "manual": |
| 364 | conflicts.append(path) |
| 365 | applied_strategies[path] = "manual" |
| 366 | elif b == r: |
| 367 | if l is None: |
| 368 | merged.pop(path, None) |
| 369 | else: |
| 370 | merged[path] = l |
| 371 | if attrs and resolve_strategy(attrs, path) == "manual": |
| 372 | conflicts.append(path) |
| 373 | applied_strategies[path] = "manual" |
| 374 | else: |
| 375 | strategy = resolve_strategy(attrs, path) if attrs else "auto" |
| 376 | if strategy == "ours": |
| 377 | merged[path] = l or b or "" |
| 378 | applied_strategies[path] = "ours" |
| 379 | elif strategy == "theirs": |
| 380 | merged[path] = r or b or "" |
| 381 | applied_strategies[path] = "theirs" |
| 382 | elif strategy == "base": |
| 383 | if b is None: |
| 384 | merged.pop(path, None) |
| 385 | else: |
| 386 | merged[path] = b |
| 387 | applied_strategies[path] = "base" |
| 388 | elif strategy == "union": |
| 389 | merged[path] = l or r or b or "" |
| 390 | applied_strategies[path] = "union" |
| 391 | elif strategy == "manual": |
| 392 | conflicts.append(path) |
| 393 | merged[path] = l or r or b or "" |
| 394 | applied_strategies[path] = "manual" |
| 395 | else: |
| 396 | conflicts.append(path) |
| 397 | merged[path] = l or r or b or "" |
| 398 | |
| 399 | return MergeResult( |
| 400 | merged=SnapshotManifest( |
| 401 | files=merged, |
| 402 | domain=_DOMAIN_NAME, |
| 403 | directories=directories_from_manifest(merged), |
| 404 | ), |
| 405 | conflicts=conflicts, |
| 406 | applied_strategies=applied_strategies, |
| 407 | ) |
| 408 | |
| 409 | # ------------------------------------------------------------------ |
| 410 | # 4. drift |
| 411 | # ------------------------------------------------------------------ |
| 412 | |
| 413 | def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport: |
| 414 | """Report how much the vault has drifted from the last commit. |
| 415 | |
| 416 | Called by ``muse status``. Takes a snapshot of the current live |
| 417 | state and diffs it against the committed snapshot. |
| 418 | |
| 419 | Args: |
| 420 | committed: The last committed snapshot. |
| 421 | live: Current live state (vault path or snapshot manifest). |
| 422 | |
| 423 | Returns: |
| 424 | A ``DriftReport`` describing what has changed since the last commit. |
| 425 | """ |
| 426 | current = self.snapshot(live) |
| 427 | delta = self.diff(committed, current) |
| 428 | |
| 429 | # Filter delete ops for files now covered by .museignore that are |
| 430 | # still on disk — they were previously tracked but the user stopped |
| 431 | # tracking them. Surfacing them as deleted in status is misleading. |
| 432 | if isinstance(live, pathlib.Path) and delta.get("ops"): |
| 433 | ignore_patterns = load_ignore_patterns(live) |
| 434 | filtered = [ |
| 435 | op for op in delta["ops"] |
| 436 | if not ( |
| 437 | op.get("op") == "delete" |
| 438 | and "::" not in op.get("address", "") |
| 439 | and is_ignored(op["address"], ignore_patterns) |
| 440 | and (live / op["address"]).exists() |
| 441 | ) |
| 442 | ] |
| 443 | if len(filtered) != len(delta["ops"]): |
| 444 | delta = StructuredDelta( |
| 445 | domain=delta["domain"], |
| 446 | ops=filtered, |
| 447 | summary=delta.get("summary", ""), |
| 448 | ) |
| 449 | |
| 450 | return DriftReport( |
| 451 | has_drift=len(delta.get("ops", [])) > 0, |
| 452 | summary=delta.get("summary", ""), |
| 453 | delta=delta, |
| 454 | ) |
| 455 | |
| 456 | # ------------------------------------------------------------------ |
| 457 | # 5. apply |
| 458 | # ------------------------------------------------------------------ |
| 459 | |
| 460 | def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState: |
| 461 | """Apply a delta to the vault. |
| 462 | |
| 463 | Called by ``muse checkout`` after the core engine has already |
| 464 | restored file-level objects from the object store. The knowtation |
| 465 | plugin has no domain-specific post-processing in Phase 1.1 — index |
| 466 | rebuilding will be added in Phase 3.4. |
| 467 | |
| 468 | Args: |
| 469 | delta: The typed operation list (passed through unchanged). |
| 470 | live_state: Current live state (returned unchanged). |
| 471 | |
| 472 | Returns: |
| 473 | *live_state* unchanged. |
| 474 | """ |
| 475 | return live_state |
| 476 | |
| 477 | # ------------------------------------------------------------------ |
| 478 | # 6. schema |
| 479 | # ------------------------------------------------------------------ |
| 480 | |
| 481 | def schema(self) -> DomainSchema: |
| 482 | """Declare the structural schema of the knowtation domain. |
| 483 | |
| 484 | Returns: |
| 485 | A ``DomainSchema`` with six semantic dimensions: |
| 486 | ``notes``, ``frontmatter``, ``sections``, ``links``, |
| 487 | ``entities``, and ``attachments``. |
| 488 | |
| 489 | Note: |
| 490 | ``merge_mode`` is ``"three_way"`` for Phase 1.1. Phase 2 will |
| 491 | upgrade ``sections`` to OT-based merge by implementing |
| 492 | ``StructuredMergePlugin.merge_ops``. The schema_version tracks |
| 493 | the Muse release so downstream consumers can detect schema |
| 494 | migrations. |
| 495 | """ |
| 496 | return self._schema_cached() |
| 497 | |
| 498 | # ------------------------------------------------------------------ |
| 499 | # 7. conflict_fingerprint — RererePlugin sub-protocol (Phase 2.5) |
| 500 | # ------------------------------------------------------------------ |
| 501 | |
| 502 | def conflict_fingerprint( |
| 503 | self, |
| 504 | path: str, |
| 505 | ours_id: str, |
| 506 | theirs_id: str, |
| 507 | repo_root: pathlib.Path, |
| 508 | ) -> str: |
| 509 | """Return a knowtation-aware conflict fingerprint for the |
| 510 | :class:`muse.domain.RererePlugin` protocol. |
| 511 | |
| 512 | Implements the runtime-checkable |
| 513 | :class:`muse.domain.RererePlugin` sub-protocol so ``muse rerere`` |
| 514 | and ``muse merge`` recognise this plugin as domain-aware and use |
| 515 | the knowtation note-fingerprint instead of the default content |
| 516 | fingerprint. |
| 517 | |
| 518 | The protocol passes only the *object IDs* of the conflicting |
| 519 | blobs. This method loads the bytes from the local object store |
| 520 | and delegates to the bytes-based |
| 521 | :func:`muse.plugins.knowtation.rerere.conflict_fingerprint`. The |
| 522 | merge-base bytes are not available at the protocol layer (the |
| 523 | MERGE_STATE base commit is not threaded through the rerere call |
| 524 | sites) so an empty ``base`` is passed — the resulting |
| 525 | fingerprint is still stable across cosmetic rewrites because |
| 526 | section bodies are sourced from the canonicalised inputs. |
| 527 | |
| 528 | Loading errors (blob missing from the local store, integrity |
| 529 | check failure, oversize) propagate as exceptions; the core |
| 530 | engine catches those and falls back to the default content |
| 531 | fingerprint (see :func:`muse.core.rerere.compute_fingerprint`). |
| 532 | |
| 533 | Args: |
| 534 | path: Vault-relative POSIX path of the conflicting note |
| 535 | (used only for diagnostic logging). |
| 536 | ours_id: SHA-256 object ID of the ours blob. |
| 537 | theirs_id: SHA-256 object ID of the theirs blob. |
| 538 | repo_root: Repository root holding ``.muse/objects/``. |
| 539 | |
| 540 | Returns: |
| 541 | 64-character lowercase hex SHA-256 fingerprint suitable for |
| 542 | indexing into ``.muse/rr-cache/``. |
| 543 | |
| 544 | Raises: |
| 545 | FileNotFoundError: When either blob is absent from the |
| 546 | local object store. |
| 547 | ValueError: When either blob exceeds the 16 MiB cap |
| 548 | enforced by the differ. |
| 549 | """ |
| 550 | from muse.core.object_store import read_object |
| 551 | from muse.plugins.knowtation.rerere import conflict_fingerprint |
| 552 | |
| 553 | ours_bytes = read_object(repo_root, ours_id) |
| 554 | if ours_bytes is None: |
| 555 | raise FileNotFoundError( |
| 556 | f"knowtation rerere: ours blob {ours_id[:8]} for {path!r} " |
| 557 | "is not in the local object store" |
| 558 | ) |
| 559 | theirs_bytes = read_object(repo_root, theirs_id) |
| 560 | if theirs_bytes is None: |
| 561 | raise FileNotFoundError( |
| 562 | f"knowtation rerere: theirs blob {theirs_id[:8]} for {path!r} " |
| 563 | "is not in the local object store" |
| 564 | ) |
| 565 | return conflict_fingerprint(ours_bytes, theirs_bytes, b"") |
| 566 | |
| 567 | # ------------------------------------------------------------------ |
| 568 | # Internal: schema construction |
| 569 | # ------------------------------------------------------------------ |
| 570 | |
| 571 | def _schema_cached(self) -> DomainSchema: |
| 572 | """Return the domain schema (separated for readability).""" |
| 573 | return DomainSchema( |
| 574 | domain=_DOMAIN_NAME, |
| 575 | description=( |
| 576 | "Vault-native version control for Knowtation Markdown knowledge bases. " |
| 577 | "Treats the vault as a structured graph of notes with YAML frontmatter, " |
| 578 | "heading sections, wikilinks, entity references, and mist attachment IDs. " |
| 579 | "Two commits that only reformat whitespace produce no semantic delta. " |
| 580 | "Frontmatter tag additions and outbound link changes are detected at " |
| 581 | "key granularity rather than byte level." |
| 582 | ), |
| 583 | top_level=TreeSchema( |
| 584 | kind="tree", |
| 585 | node_type="vault", |
| 586 | diff_algorithm="gumtree", |
| 587 | ), |
| 588 | dimensions=[ |
| 589 | DimensionSpec( |
| 590 | name="notes", |
| 591 | description=( |
| 592 | "Note / directory tree. Tracks which Markdown files exist " |
| 593 | "and how they are organised within the vault folder structure." |
| 594 | ), |
| 595 | schema=TreeSchema( |
| 596 | kind="tree", |
| 597 | node_type="note", |
| 598 | diff_algorithm="gumtree", |
| 599 | ), |
| 600 | independent_merge=False, |
| 601 | ), |
| 602 | DimensionSpec( |
| 603 | name="frontmatter", |
| 604 | description=( |
| 605 | "YAML frontmatter key-value pairs. Tracks the structured " |
| 606 | "metadata at the top of each note: title, date, tags, " |
| 607 | "project, source_type, source_id, entities, attachments." |
| 608 | ), |
| 609 | schema=MapSchema( |
| 610 | kind="map", |
| 611 | key_type="frontmatter_key", |
| 612 | value_schema=SetSchema( |
| 613 | kind="set", |
| 614 | element_type="frontmatter_value", |
| 615 | identity="by_content", |
| 616 | ), |
| 617 | identity="by_key", |
| 618 | ), |
| 619 | independent_merge=True, |
| 620 | ), |
| 621 | DimensionSpec( |
| 622 | name="sections", |
| 623 | description=( |
| 624 | "Ordered Markdown heading sections within a note. " |
| 625 | "Each section is a (heading_level, heading_text, body_hash) " |
| 626 | "tuple. Myers diff produces readable section-level diffs. " |
| 627 | "Phase 2.4 upgrades this dimension to OT-based merge." |
| 628 | ), |
| 629 | schema=SequenceSchema( |
| 630 | kind="sequence", |
| 631 | element_type="section", |
| 632 | identity="by_id", |
| 633 | diff_algorithm="myers", |
| 634 | alphabet=None, |
| 635 | ), |
| 636 | independent_merge=True, |
| 637 | ), |
| 638 | DimensionSpec( |
| 639 | name="links", |
| 640 | description=( |
| 641 | "Outbound link set: wikilinks (``[[Target]]``), typed " |
| 642 | "wikilinks (``[[Type::Target]]``), and Markdown hrefs " |
| 643 | "(``[text](other-note.md)``). Order is semantically " |
| 644 | "irrelevant; tracked as an unordered set." |
| 645 | ), |
| 646 | schema=SetSchema( |
| 647 | kind="set", |
| 648 | element_type="link", |
| 649 | identity="by_content", |
| 650 | ), |
| 651 | independent_merge=True, |
| 652 | ), |
| 653 | DimensionSpec( |
| 654 | name="entities", |
| 655 | description=( |
| 656 | "Entity reference set from frontmatter ``entity`` / " |
| 657 | "``entities`` keys. Entity handles are the primary " |
| 658 | "hook for knowledge-graph queries and note clustering." |
| 659 | ), |
| 660 | schema=SetSchema( |
| 661 | kind="set", |
| 662 | element_type="entity_ref", |
| 663 | identity="by_content", |
| 664 | ), |
| 665 | independent_merge=True, |
| 666 | ), |
| 667 | DimensionSpec( |
| 668 | name="attachments", |
| 669 | description=( |
| 670 | "Mist attachment-ID set from frontmatter ``attachments`` " |
| 671 | "key. Each ID is the 12-char base58 SHA-256 prefix " |
| 672 | "assigned by the mist domain when the original binary " |
| 673 | "blob (PDF, audio, image) was pushed. Phase 1.7 wires " |
| 674 | "the import pipeline to populate this field automatically." |
| 675 | ), |
| 676 | schema=SetSchema( |
| 677 | kind="set", |
| 678 | element_type="mist_id", |
| 679 | identity="by_content", |
| 680 | ), |
| 681 | independent_merge=True, |
| 682 | ), |
| 683 | ], |
| 684 | merge_mode="three_way", |
| 685 | schema_version=__version__, |
| 686 | ) |
| 687 | |
| 688 | |
| 689 | # --------------------------------------------------------------------------- |
| 690 | # Module-level singleton |
| 691 | # --------------------------------------------------------------------------- |
| 692 | |
| 693 | #: The singleton plugin instance registered in ``muse/plugins/registry.py``. |
| 694 | plugin = KnowtationPlugin() |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
34 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
34 days ago