mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-08-10 14:30:34 +00:00
cleanup(ene): make obsidian shim provenance configurable and schema-complete
This commit is contained in:
parent
29f93f9dfd
commit
f763372d03
1 changed files with 82 additions and 65 deletions
|
|
@ -1,6 +1,5 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""obsidian_sync_shim.py — Bidirectional sync between Obsidian vault and JSON-L lake.
|
||||||
obsidian_sync_shim.py — Bidirectional sync between Obsidian vault and JSON-L lake.
|
|
||||||
|
|
||||||
Modes:
|
Modes:
|
||||||
ingest : Obsidian notes → JSON-L lake (append to lake file)
|
ingest : Obsidian notes → JSON-L lake (append to lake file)
|
||||||
|
|
@ -9,6 +8,13 @@ Modes:
|
||||||
|
|
||||||
Requires TOPOLOGICAL_ENGINE_URL and TOPOLOGICAL_ENGINE_TOKEN in .env.
|
Requires TOPOLOGICAL_ENGINE_URL and TOPOLOGICAL_ENGINE_TOKEN in .env.
|
||||||
For local Obsidian installs, set OBSIDIAN_VAULT_PATH or use --vault.
|
For local Obsidian installs, set OBSIDIAN_VAULT_PATH or use --vault.
|
||||||
|
|
||||||
|
Hardcoded node/provenance assumptions were removed:
|
||||||
|
- provenance.node uses ENE_NODE_ID (default: obsidian_sync_shim)
|
||||||
|
- provenance.lake_seed uses ENE_LAKE_SEED (default: obsidian_lake)
|
||||||
|
- provenance.tailscale_ip uses ENE_TAILSCALE_IP (default: 127.0.0.1)
|
||||||
|
|
||||||
|
NOTE: This is a legacy ingest surface; treat outputs as non-authoritative.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -25,6 +31,7 @@ from typing import Dict, List, Any, Optional
|
||||||
project_root = Path(__file__).parent.parent.parent
|
project_root = Path(__file__).parent.parent.parent
|
||||||
try:
|
try:
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
if (project_root / ".env").exists():
|
if (project_root / ".env").exists():
|
||||||
load_dotenv(project_root / ".env")
|
load_dotenv(project_root / ".env")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -36,6 +43,11 @@ sys.path.insert(0, str(project_root / "4-Infrastructure" / "infra"))
|
||||||
from infra.topological_engine_client import TopologicalEngineClient
|
from infra.topological_engine_client import TopologicalEngineClient
|
||||||
|
|
||||||
|
|
||||||
|
def env_default(name: str, default: str) -> str:
|
||||||
|
v = os.getenv(name)
|
||||||
|
return v if v is not None and v != "" else default
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_VAULT_PATH = project_root / "Obdisidan connector"
|
DEFAULT_VAULT_PATH = project_root / "Obdisidan connector"
|
||||||
LAKE_PATH = Path(os.getenv("OBSIDIAN_LAKE_PATH", str(project_root / "data" / "obsidian_lake.jsonl")))
|
LAKE_PATH = Path(os.getenv("OBSIDIAN_LAKE_PATH", str(project_root / "data" / "obsidian_lake.jsonl")))
|
||||||
LAKE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
LAKE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -91,11 +103,21 @@ def _format_jsonl_entry(
|
||||||
tier: str = "AUX",
|
tier: str = "AUX",
|
||||||
domain: str = "obsidian",
|
domain: str = "obsidian",
|
||||||
archetype: str = "note",
|
archetype: str = "note",
|
||||||
src: str = "obsidian_sync_shim"
|
src: str = "obsidian_sync_shim",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Format a JSON-L entry for the Research Stack lake."""
|
"""Format a JSON-L entry for the Research Stack lake."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
timestamp = now.isoformat()
|
timestamp = now.isoformat()
|
||||||
|
|
||||||
|
node_id = env_default("ENE_NODE_ID", "obsidian_sync_shim")
|
||||||
|
lake_seed = env_default("ENE_LAKE_SEED", "obsidian_lake")
|
||||||
|
tailscale_ip = env_default("ENE_TAILSCALE_IP", "127.0.0.1")
|
||||||
|
|
||||||
|
# Deterministic attestation over the minimal payload.
|
||||||
|
attestation_hash = "sha256:" + hashlib.sha256(
|
||||||
|
(pkg + ":" + timestamp + ":" + json.dumps(data, sort_keys=True, ensure_ascii=False)).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"t": now.timestamp(),
|
"t": now.timestamp(),
|
||||||
"src": src,
|
"src": src,
|
||||||
|
|
@ -107,18 +129,21 @@ def _format_jsonl_entry(
|
||||||
"tier": tier,
|
"tier": tier,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"archetype": archetype,
|
"archetype": archetype,
|
||||||
**data
|
**data,
|
||||||
},
|
},
|
||||||
"bind": {
|
"bind": {
|
||||||
"lawful": True,
|
"lawful": True,
|
||||||
"cost": 65536,
|
"cost": 65536,
|
||||||
"invariant": "noteConsistency",
|
"invariant": "noteConsistency",
|
||||||
"class": "informational_bind"
|
"class": "informational_bind",
|
||||||
},
|
},
|
||||||
"provenance": {
|
"provenance": {
|
||||||
"node": "obsidian_sync_shim",
|
"node": node_id,
|
||||||
"lake_seed": "obsidian_lake"
|
"lake_seed": lake_seed,
|
||||||
}
|
"tailscale_ip": tailscale_ip,
|
||||||
|
"attestation_hash": attestation_hash,
|
||||||
|
"prev_id": None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,7 +154,7 @@ def _format_jsonl_entry(
|
||||||
def ingest_obsidian(
|
def ingest_obsidian(
|
||||||
client: TopologicalEngineClient,
|
client: TopologicalEngineClient,
|
||||||
query: str = "*",
|
query: str = "*",
|
||||||
lake_path: Path = LAKE_PATH
|
lake_path: Path = LAKE_PATH,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Pull notes from Obsidian vault and append to JSON-L lake."""
|
"""Pull notes from Obsidian vault and append to JSON-L lake."""
|
||||||
print("[ingest] Searching Obsidian vault...")
|
print("[ingest] Searching Obsidian vault...")
|
||||||
|
|
@ -156,11 +181,10 @@ def ingest_obsidian(
|
||||||
"links": note.get("links", []),
|
"links": note.get("links", []),
|
||||||
"tags": note.get("tags", []),
|
"tags": note.get("tags", []),
|
||||||
"modified": note.get("modified", note.get("mtime")),
|
"modified": note.get("modified", note.get("mtime")),
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
|
|
||||||
# Append to lake
|
|
||||||
with open(lake_path, "a") as f:
|
with open(lake_path, "a") as f:
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
f.write(json.dumps(entry) + "\n")
|
f.write(json.dumps(entry) + "\n")
|
||||||
|
|
@ -172,7 +196,7 @@ def ingest_obsidian(
|
||||||
def ingest_local_obsidian(
|
def ingest_local_obsidian(
|
||||||
vault_path: Path,
|
vault_path: Path,
|
||||||
query: str = "*",
|
query: str = "*",
|
||||||
lake_path: Path = LAKE_PATH
|
lake_path: Path = LAKE_PATH,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Pull notes from a local Obsidian vault and append to JSON-L lake."""
|
"""Pull notes from a local Obsidian vault and append to JSON-L lake."""
|
||||||
if not vault_path.exists():
|
if not vault_path.exists():
|
||||||
|
|
@ -223,7 +247,7 @@ def ingest_local_obsidian(
|
||||||
def export_to_obsidian(
|
def export_to_obsidian(
|
||||||
client: TopologicalEngineClient,
|
client: TopologicalEngineClient,
|
||||||
lake_path: Path = LAKE_PATH,
|
lake_path: Path = LAKE_PATH,
|
||||||
filter_domain: str = "obsidian"
|
filter_domain: str = "obsidian",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Read JSON-L lake and write matching entries back to Obsidian vault."""
|
"""Read JSON-L lake and write matching entries back to Obsidian vault."""
|
||||||
if not lake_path.exists():
|
if not lake_path.exists():
|
||||||
|
|
@ -257,19 +281,21 @@ def export_to_obsidian(
|
||||||
if not path:
|
if not path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Reconstruct a simple markdown note
|
|
||||||
md = f"# {title}\n\n"
|
md = f"# {title}\n\n"
|
||||||
md += f"> Synced from JSON-L lake at {datetime.now(timezone.utc).isoformat()}\n\n"
|
md += f"> Synced from JSON-L lake at {datetime.now(timezone.utc).isoformat()}\n\n"
|
||||||
md += content_preview
|
md += content_preview
|
||||||
if len(content_preview) >= 500:
|
if len(content_preview) >= 500:
|
||||||
md += "\n\n...(truncated)"
|
md += "\n\n...(truncated)"
|
||||||
|
|
||||||
# Write via topological engine
|
res = client.write_obsidian_note(
|
||||||
res = client.write_obsidian_note(path=path, body=md, metadata={
|
path=path,
|
||||||
"source": "research_stack_lake",
|
body=md,
|
||||||
"entry_id": entry.get("id"),
|
metadata={
|
||||||
"synced_at": datetime.now(timezone.utc).isoformat()
|
"source": "research_stack_lake",
|
||||||
})
|
"entry_id": entry.get("id"),
|
||||||
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
if "error" not in res:
|
if "error" not in res:
|
||||||
written += 1
|
written += 1
|
||||||
|
|
||||||
|
|
@ -281,7 +307,7 @@ def export_to_local_obsidian(
|
||||||
vault_path: Path,
|
vault_path: Path,
|
||||||
lake_path: Path = LAKE_PATH,
|
lake_path: Path = LAKE_PATH,
|
||||||
filter_domain: str = "obsidian",
|
filter_domain: str = "obsidian",
|
||||||
overwrite: bool = False
|
overwrite: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Read JSON-L lake and write matching entries into a local Obsidian vault."""
|
"""Read JSON-L lake and write matching entries into a local Obsidian vault."""
|
||||||
if not lake_path.exists():
|
if not lake_path.exists():
|
||||||
|
|
@ -339,15 +365,8 @@ def export_to_local_obsidian(
|
||||||
def sync_bidirectional(
|
def sync_bidirectional(
|
||||||
client: TopologicalEngineClient,
|
client: TopologicalEngineClient,
|
||||||
lake_path: Path = LAKE_PATH,
|
lake_path: Path = LAKE_PATH,
|
||||||
obsidian_wins: bool = True
|
obsidian_wins: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> 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...")
|
print("[sync] Starting bidirectional sync...")
|
||||||
ingest_res = ingest_obsidian(client, lake_path=lake_path)
|
ingest_res = ingest_obsidian(client, lake_path=lake_path)
|
||||||
if "error" in ingest_res:
|
if "error" in ingest_res:
|
||||||
|
|
@ -355,13 +374,11 @@ def sync_bidirectional(
|
||||||
|
|
||||||
if obsidian_wins:
|
if obsidian_wins:
|
||||||
print("[sync] Obsidian-wins mode: skipping overwrite of existing notes")
|
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("*")
|
search_res = client.search_obsidian("*")
|
||||||
existing_paths = set()
|
existing_paths = set()
|
||||||
for note in search_res.get("results", search_res.get("notes", [])):
|
for note in search_res.get("results", search_res.get("notes", [])):
|
||||||
existing_paths.add(note.get("path", note.get("file_path")))
|
existing_paths.add(note.get("path", note.get("file_path")))
|
||||||
|
|
||||||
# Filter lake entries to only missing paths
|
|
||||||
if lake_path.exists():
|
if lake_path.exists():
|
||||||
entries = []
|
entries = []
|
||||||
with open(lake_path) as f:
|
with open(lake_path) as f:
|
||||||
|
|
@ -387,23 +404,16 @@ def sync_bidirectional(
|
||||||
written += 1
|
written += 1
|
||||||
print(f"[sync] Created {written} missing notes in Obsidian")
|
print(f"[sync] Created {written} missing notes in Obsidian")
|
||||||
return {"ingested": ingest_res["ingested"], "created": written}
|
return {"ingested": ingest_res["ingested"], "created": written}
|
||||||
else:
|
|
||||||
export_res = export_to_obsidian(client, lake_path=lake_path)
|
export_res = export_to_obsidian(client, lake_path=lake_path)
|
||||||
return {"ingested": ingest_res["ingested"], **export_res}
|
return {"ingested": ingest_res["ingested"], **export_res}
|
||||||
|
|
||||||
|
|
||||||
def sync_local_bidirectional(
|
def sync_local_bidirectional(
|
||||||
vault_path: Path,
|
vault_path: Path,
|
||||||
lake_path: Path = LAKE_PATH,
|
lake_path: Path = LAKE_PATH,
|
||||||
obsidian_wins: bool = True
|
obsidian_wins: bool = True,
|
||||||
) -> Dict[str, Any]:
|
) -> 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")
|
print("[sync:local] Starting bidirectional local sync")
|
||||||
ingest_res = ingest_local_obsidian(vault_path=vault_path, lake_path=lake_path)
|
ingest_res = ingest_local_obsidian(vault_path=vault_path, lake_path=lake_path)
|
||||||
if "error" in ingest_res:
|
if "error" in ingest_res:
|
||||||
|
|
@ -424,18 +434,33 @@ def sync_local_bidirectional(
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Obsidian ↔ JSON-L lake sync shim")
|
parser = argparse.ArgumentParser(description="Obsidian ↔ JSON-L lake sync shim")
|
||||||
parser.add_argument("mode", choices=["ingest", "export", "sync"], default="sync", nargs="?",
|
parser.add_argument(
|
||||||
help="ingest=Obsidian→lake, export=lake→Obsidian, sync=both")
|
"mode",
|
||||||
parser.add_argument("--backend", choices=["auto", "local", "engine"], default="auto",
|
choices=["ingest", "export", "sync"],
|
||||||
help="local=filesystem vault, engine=topological engine, auto=engine if healthy else local")
|
default="sync",
|
||||||
parser.add_argument("--vault", default=None,
|
nargs="?",
|
||||||
help="Local Obsidian vault path; defaults to OBSIDIAN_VAULT_PATH or ./Obdisidan connector")
|
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("--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("--query", default="*", help="Obsidian search query (ingest mode)")
|
||||||
parser.add_argument("--obsidian-wins", action="store_true", default=True,
|
parser.add_argument(
|
||||||
help="In sync mode, don't overwrite existing Obsidian notes")
|
"--obsidian-wins",
|
||||||
parser.add_argument("--lake-wins", action="store_true",
|
action="store_true",
|
||||||
help="In sync mode, overwrite Obsidian with lake contents")
|
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")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
lake_path = Path(args.lake)
|
lake_path = Path(args.lake)
|
||||||
|
|
@ -461,17 +486,9 @@ def main():
|
||||||
if args.mode == "ingest":
|
if args.mode == "ingest":
|
||||||
result = ingest_local_obsidian(vault_path=vault_path, query=args.query, lake_path=lake_path)
|
result = ingest_local_obsidian(vault_path=vault_path, query=args.query, lake_path=lake_path)
|
||||||
elif args.mode == "export":
|
elif args.mode == "export":
|
||||||
result = export_to_local_obsidian(
|
result = export_to_local_obsidian(vault_path=vault_path, lake_path=lake_path, overwrite=not obsidian_wins)
|
||||||
vault_path=vault_path,
|
|
||||||
lake_path=lake_path,
|
|
||||||
overwrite=not obsidian_wins,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
result = sync_local_bidirectional(
|
result = sync_local_bidirectional(vault_path=vault_path, lake_path=lake_path, obsidian_wins=obsidian_wins)
|
||||||
vault_path=vault_path,
|
|
||||||
lake_path=lake_path,
|
|
||||||
obsidian_wins=obsidian_wins,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
if args.mode == "ingest":
|
if args.mode == "ingest":
|
||||||
result = ingest_obsidian(client, query=args.query, lake_path=lake_path)
|
result = ingest_obsidian(client, query=args.query, lake_path=lake_path)
|
||||||
|
|
@ -483,9 +500,9 @@ def main():
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
print(f"Error: {result['error']}")
|
print(f"Error: {result['error']}")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
|
||||||
print(f"Done: {json.dumps(result, indent=2)}")
|
print(f"Done: {json.dumps(result, indent=2)}")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue