#!/usr/bin/env python3 """ obsidian_sync_shim.py — Bidirectional sync between Obsidian vault and JSON-L lake. Modes: ingest : Obsidian notes → JSON-L lake (append to lake file) export : JSON-L lake → Obsidian notes (write to vault) sync : Two-way merge (default: Obsidian wins on conflict) Requires TOPOLOGICAL_ENGINE_URL and TOPOLOGICAL_ENGINE_TOKEN in .env. For local Obsidian installs, set OBSIDIAN_VAULT_PATH or use --vault. """ import sys import json import os import argparse import hashlib import re from pathlib import Path from datetime import datetime, timezone from typing import Dict, List, Any, Optional # Load .env before imports project_root = Path(__file__).parent.parent.parent try: from dotenv import load_dotenv if (project_root / ".env").exists(): load_dotenv(project_root / ".env") except ImportError: pass sys.path.insert(0, str(project_root)) sys.path.insert(0, str(project_root / "4-Infrastructure" / "infra")) from infra.topological_engine_client import TopologicalEngineClient DEFAULT_VAULT_PATH = project_root / "Obdisidan connector" LAKE_PATH = Path(os.getenv("OBSIDIAN_LAKE_PATH", str(project_root / "data" / "obsidian_lake.jsonl"))) LAKE_PATH.parent.mkdir(parents=True, exist_ok=True) WIKI_LINK_RE = re.compile(r"\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]") TAG_RE = re.compile(r"(? Path: """Resolve the local Obsidian vault path.""" raw = path or os.getenv("OBSIDIAN_VAULT_PATH") or str(DEFAULT_VAULT_PATH) return Path(raw).expanduser().resolve() def _content_hash(content: str) -> str: return hashlib.sha256(content.encode("utf-8")).hexdigest() def _note_pkg(path: str) -> str: return f"obsidian/note/{path}" def _extract_note_metadata(content: str) -> Dict[str, Any]: """Extract lightweight Obsidian metadata without changing note content.""" links = sorted(set(match.strip() for match in WIKI_LINK_RE.findall(content))) tags = sorted(set(match.strip() for match in TAG_RE.findall(content))) return {"links": links, "tags": tags, "content_hash": _content_hash(content)} def _iter_local_notes(vault_path: Path) -> List[Path]: """Return markdown notes in a local vault, excluding Obsidian internals.""" if not vault_path.exists(): return [] notes = [] for path in vault_path.rglob("*.md"): rel_parts = path.relative_to(vault_path).parts if rel_parts and rel_parts[0] == ".obsidian": continue notes.append(path) return sorted(notes) def _matches_query(path: str, title: str, content: str, query: str) -> bool: if query in ("", "*"): return True q = query.lower() return q in path.lower() or q in title.lower() or q in content.lower() def _format_jsonl_entry( pkg: str, data: Dict[str, Any], tier: str = "AUX", domain: str = "obsidian", archetype: str = "note", src: str = "obsidian_sync_shim" ) -> Dict[str, Any]: """Format a JSON-L entry for the Research Stack lake.""" now = datetime.now(timezone.utc) timestamp = now.isoformat() return { "t": now.timestamp(), "src": src, "id": f"ene:{pkg}:{timestamp}", "op": "upsert", "data": { "pkg": pkg, "version": timestamp, "tier": tier, "domain": domain, "archetype": archetype, **data }, "bind": { "lawful": True, "cost": 65536, "invariant": "noteConsistency", "class": "informational_bind" }, "provenance": { "node": "obsidian_sync_shim", "lake_seed": "obsidian_lake" } } # ═══════════════════════════════════════════════════════════════════════════ # Ingest: Obsidian → JSON-L # ═══════════════════════════════════════════════════════════════════════════ def ingest_obsidian( client: TopologicalEngineClient, query: str = "*", lake_path: Path = LAKE_PATH ) -> Dict[str, Any]: """Pull notes from Obsidian vault and append to JSON-L lake.""" print("[ingest] Searching Obsidian vault...") search_res = client.search_obsidian(query) if "error" in search_res: return {"error": search_res["error"]} notes = search_res.get("hits", search_res.get("results", search_res.get("notes", []))) print(f"[ingest] Found {len(notes)} notes") entries = [] for note in notes: path = note.get("path", note.get("file_path", "unknown")) title = note.get("title", Path(path).stem) content = note.get("content", "") entry = _format_jsonl_entry( pkg=f"obsidian/note/{path}", data={ "path": path, "title": title, "content_preview": content[:500], "content_length": len(content), "links": note.get("links", []), "tags": note.get("tags", []), "modified": note.get("modified", note.get("mtime")), } ) entries.append(entry) # Append to lake with open(lake_path, "a") as f: for entry in entries: f.write(json.dumps(entry) + "\n") print(f"[ingest] Appended {len(entries)} entries to {lake_path}") return {"ingested": len(entries), "lake": str(lake_path)} def ingest_local_obsidian( vault_path: Path, query: str = "*", lake_path: Path = LAKE_PATH ) -> Dict[str, Any]: """Pull notes from a local Obsidian vault and append to JSON-L lake.""" if not vault_path.exists(): return {"error": f"Obsidian vault not found: {vault_path}"} print(f"[ingest:local] Reading Obsidian vault: {vault_path}") entries = [] for note_path in _iter_local_notes(vault_path): rel_path = note_path.relative_to(vault_path).as_posix() content = note_path.read_text(encoding="utf-8", errors="replace") title = note_path.stem if not _matches_query(rel_path, title, content, query): continue stat = note_path.stat() metadata = _extract_note_metadata(content) entry = _format_jsonl_entry( pkg=_note_pkg(rel_path), data={ "path": rel_path, "title": title, "content": content, "content_preview": content[:500], "content_length": len(content), "links": metadata["links"], "tags": metadata["tags"], "content_hash": metadata["content_hash"], "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), "vault_path": str(vault_path), }, src="obsidian_sync_shim.local", ) entries.append(entry) lake_path.parent.mkdir(parents=True, exist_ok=True) with open(lake_path, "a", encoding="utf-8") as f: for entry in entries: f.write(json.dumps(entry, ensure_ascii=False) + "\n") print(f"[ingest:local] Appended {len(entries)} entries to {lake_path}") return {"ingested": len(entries), "lake": str(lake_path), "vault": str(vault_path)} # ═══════════════════════════════════════════════════════════════════════════ # Export: JSON-L → Obsidian # ═══════════════════════════════════════════════════════════════════════════ def export_to_obsidian( client: TopologicalEngineClient, lake_path: Path = LAKE_PATH, filter_domain: str = "obsidian" ) -> Dict[str, Any]: """Read JSON-L lake and write matching entries back to Obsidian vault.""" if not lake_path.exists(): return {"error": f"Lake not found: {lake_path}"} print(f"[export] Reading lake from {lake_path}...") entries = [] with open(lake_path) as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) except json.JSONDecodeError: continue data = entry.get("data", {}) if filter_domain and data.get("domain") != filter_domain: continue entries.append(entry) print(f"[export] {len(entries)} entries match domain='{filter_domain}'") written = 0 for entry in entries: data = entry.get("data", {}) path = data.get("path") title = data.get("title", "Untitled") content_preview = data.get("content_preview", "") if not path: continue # Reconstruct a simple markdown note md = f"# {title}\n\n" md += f"> Synced from JSON-L lake at {datetime.now(timezone.utc).isoformat()}\n\n" md += content_preview if len(content_preview) >= 500: md += "\n\n...(truncated)" # Write via topological engine res = client.write_obsidian_note(path=path, body=md, metadata={ "source": "research_stack_lake", "entry_id": entry.get("id"), "synced_at": datetime.now(timezone.utc).isoformat() }) if "error" not in res: written += 1 print(f"[export] Wrote {written} notes to Obsidian vault") return {"exported": written} def export_to_local_obsidian( vault_path: Path, lake_path: Path = LAKE_PATH, filter_domain: str = "obsidian", overwrite: bool = False ) -> Dict[str, Any]: """Read JSON-L lake and write matching entries into a local Obsidian vault.""" if not lake_path.exists(): return {"error": f"Lake not found: {lake_path}"} vault_path.mkdir(parents=True, exist_ok=True) print(f"[export:local] Reading lake from {lake_path}") written = 0 skipped = 0 with open(lake_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: entry = json.loads(line) except json.JSONDecodeError: continue data = entry.get("data", {}) if filter_domain and data.get("domain") != filter_domain: continue rel_path = data.get("path") if not rel_path: continue rel_path = rel_path.replace("\\", "/").lstrip("/") if rel_path.startswith(".obsidian/") or ".." in Path(rel_path).parts: skipped += 1 continue target = vault_path / rel_path if target.exists() and not overwrite: skipped += 1 continue content = data.get("content") if content is None: title = data.get("title", target.stem) content = f"# {title}\n\n> Created from JSON-L lake sync\n\n{data.get('content_preview', '')}" target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") written += 1 print(f"[export:local] Wrote {written} notes to {vault_path}; skipped {skipped}") return {"exported": written, "skipped": skipped, "vault": str(vault_path)} # ═══════════════════════════════════════════════════════════════════════════ # Two-way sync # ═══════════════════════════════════════════════════════════════════════════ def sync_bidirectional( client: TopologicalEngineClient, lake_path: Path = LAKE_PATH, obsidian_wins: bool = True ) -> Dict[str, Any]: """ Two-way sync: 1. Ingest current Obsidian state into lake. 2. Export lake entries back to Obsidian (creating any missing notes). If obsidian_wins=True, existing Obsidian notes are not overwritten. """ print("[sync] Starting bidirectional sync...") ingest_res = ingest_obsidian(client, lake_path=lake_path) if "error" in ingest_res: return ingest_res if obsidian_wins: print("[sync] Obsidian-wins mode: skipping overwrite of existing notes") # In export mode, only write notes that don't already exist search_res = client.search_obsidian("*") existing_paths = set() for note in search_res.get("results", search_res.get("notes", [])): existing_paths.add(note.get("path", note.get("file_path"))) # Filter lake entries to only missing paths if lake_path.exists(): entries = [] with open(lake_path) as f: for line in f: if not line.strip(): continue try: entry = json.loads(line) except json.JSONDecodeError: continue path = entry.get("data", {}).get("path") if path and path not in existing_paths: entries.append(entry) written = 0 for entry in entries: data = entry.get("data", {}) path = data.get("path") title = data.get("title", "Untitled") md = f"# {title}\n\n> Created from JSON-L lake sync\n\n{data.get('content_preview', '')}" res = client.write_obsidian_note(path=path, body=md) if "error" not in res: written += 1 print(f"[sync] Created {written} missing notes in Obsidian") return {"ingested": ingest_res["ingested"], "created": written} else: export_res = export_to_obsidian(client, lake_path=lake_path) return {"ingested": ingest_res["ingested"], **export_res} def sync_local_bidirectional( vault_path: Path, lake_path: Path = LAKE_PATH, obsidian_wins: bool = True ) -> Dict[str, Any]: """ Local two-way sync: 1. Ingest current local Obsidian state into lake. 2. Export lake entries back to the vault. If obsidian_wins=True, existing local notes are not overwritten. """ print("[sync:local] Starting bidirectional local sync") ingest_res = ingest_local_obsidian(vault_path=vault_path, lake_path=lake_path) if "error" in ingest_res: return ingest_res export_res = export_to_local_obsidian( vault_path=vault_path, lake_path=lake_path, overwrite=not obsidian_wins, ) if "error" in export_res: return export_res return {**ingest_res, **export_res} # ═══════════════════════════════════════════════════════════════════════════ # CLI # ═══════════════════════════════════════════════════════════════════════════ def main(): parser = argparse.ArgumentParser(description="Obsidian ↔ JSON-L lake sync shim") parser.add_argument("mode", choices=["ingest", "export", "sync"], default="sync", nargs="?", help="ingest=Obsidian→lake, export=lake→Obsidian, sync=both") parser.add_argument("--backend", choices=["auto", "local", "engine"], default="auto", help="local=filesystem vault, engine=topological engine, auto=engine if healthy else local") parser.add_argument("--vault", default=None, help="Local Obsidian vault path; defaults to OBSIDIAN_VAULT_PATH or ./Obdisidan connector") parser.add_argument("--lake", default=str(LAKE_PATH), help="Path to JSON-L lake file") parser.add_argument("--query", default="*", help="Obsidian search query (ingest mode)") parser.add_argument("--obsidian-wins", action="store_true", default=True, help="In sync mode, don't overwrite existing Obsidian notes") parser.add_argument("--lake-wins", action="store_true", help="In sync mode, overwrite Obsidian with lake contents") args = parser.parse_args() lake_path = Path(args.lake) obsidian_wins = not args.lake_wins vault_path = _vault_path(args.vault) backend = args.backend client = None if backend in ("auto", "engine"): client = TopologicalEngineClient() health = client.health() engine_ok = bool(health.get("ok", False) or health.get("healthy", False)) if not engine_ok: if backend == "engine": print(f"Topological engine unhealthy: {health.get('error', 'unknown')}") sys.exit(1) print(f"[auto] Topological engine unavailable; using local vault: {vault_path}") backend = "local" else: backend = "engine" if backend == "local": if args.mode == "ingest": result = ingest_local_obsidian(vault_path=vault_path, query=args.query, lake_path=lake_path) elif args.mode == "export": result = export_to_local_obsidian( vault_path=vault_path, lake_path=lake_path, overwrite=not obsidian_wins, ) else: result = sync_local_bidirectional( vault_path=vault_path, lake_path=lake_path, obsidian_wins=obsidian_wins, ) else: if args.mode == "ingest": result = ingest_obsidian(client, query=args.query, lake_path=lake_path) elif args.mode == "export": result = export_to_obsidian(client, lake_path=lake_path) else: result = sync_bidirectional(client, lake_path=lake_path, obsidian_wins=obsidian_wins) if "error" in result: print(f"Error: {result['error']}") sys.exit(1) else: print(f"Done: {json.dumps(result, indent=2)}") sys.exit(0) if __name__ == "__main__": main()