plugin.py python
694 lines 26.9 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 14 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 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 14 days ago