diff --git a/claude-obsidian/README.md b/claude-obsidian/README.md index 877937d..132500e 100644 --- a/claude-obsidian/README.md +++ b/claude-obsidian/README.md @@ -80,6 +80,10 @@ claude mcp add --scope user --transport http obsidian http://:27123/mcp/ \ keys, bearer tokens, `password=`-style assignments, JWTs, provider tokens, ≥48-char hex, and random mixed-case strings. It over-redacts on purpose; git SHAs, md5s and paths survive. The summarizer is also told never to reproduce secrets. +- **One run at a time** (a `flock` on `run.lock`): an hourly timer firing during a long manual run + exits immediately instead of racing it on the state and ledger files. +- **Several vault URLs** can be listed; the first that answers wins, so one config covers both a + permanent address and a temporary `kubectl port-forward`. - **Incremental and idempotent.** State (`~/.local/state/claude-obsidian/state.json`) records each session's size+mtime. A resumed session is re-summarized into the *same* note path. A session is left alone until idle for `SETTLE_MINUTES`, so a live conversation isn't diff --git a/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc b/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc index d91fad6..f3a7dcf 100644 Binary files a/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc and b/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc differ diff --git a/claude-obsidian/claude-obsidian-sync b/claude-obsidian/claude-obsidian-sync index 7352e2d..d1dcf03 100755 --- a/claude-obsidian/claude-obsidian-sync +++ b/claude-obsidian/claude-obsidian-sync @@ -18,6 +18,7 @@ from __future__ import annotations import argparse import concurrent.futures as cf import datetime as dt +import fcntl import hashlib import json import os @@ -46,6 +47,7 @@ def load_config() -> dict: line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) + v = re.split(r"\s+#", v, maxsplit=1)[0] # inline comment; a '#' inside a value survives cfg[k.strip()] = v.strip().strip('"').strip("'") cfg.update({k: v for k, v in os.environ.items() if k in DEFAULTS or k.startswith("LLM_") or k.startswith("OBSIDIAN_")}) for k, v in DEFAULTS.items(): @@ -474,7 +476,10 @@ class LLM: # --------------------------------------------------------------------------- vault class Vault: def __init__(self, cfg: dict): - self.base = cfg["OBSIDIAN_API_URL"].rstrip("/") + # OBSIDIAN_API_URL may list several URLs, comma-separated, tried in order at ping() -- e.g. the + # permanent address first and a port-forward second, so one config works either way. + self.candidates = [u.strip().rstrip("/") for u in cfg["OBSIDIAN_API_URL"].split(",") if u.strip()] + self.base = self.candidates[0] self.key = secret(cfg, "OBSIDIAN_API_KEY_FILE") def put(self, path: str, content: str) -> None: @@ -488,12 +493,19 @@ class Vault: def ping(self) -> None: """Checked BEFORE any summarizing, so an unreachable vault never spends usage limits.""" - req = urllib.request.Request(f"{self.base}/", headers={"Authorization": f"Bearer {self.key}"}) - try: - with urllib.request.urlopen(req, timeout=15) as r: - info = json.load(r) - except (urllib.error.URLError, TimeoutError, OSError) as e: - raise SystemExit(f"Obsidian REST API unreachable at {self.base}: {getattr(e, 'reason', e)}") from None + errors = [] + for base in self.candidates: + req = urllib.request.Request(f"{base}/", headers={"Authorization": f"Bearer {self.key}"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + info = json.load(r) + self.base = base + break + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + errors.append(f"{base}: {getattr(e, 'reason', e)}") + else: + raise SystemExit("Obsidian REST API unreachable -- " + "; ".join(errors)) + print(f"vault: {self.base}", flush=True) if not info.get("authenticated"): raise SystemExit("Obsidian REST API reachable but the API key was rejected") @@ -675,7 +687,21 @@ def cmd_stats(cfg, args) -> None: f"{sum(1 for r in rows if r[0] > CHUNK_CHARS)} need map-reduce") +def run_lock(cfg: dict): + """One run at a time: a timer run overlapping a manual one would race on state and ledger files.""" + path = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent / "run.lock" + path.parent.mkdir(parents=True, exist_ok=True) + fh = path.open("w") + try: + fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print("another claude-obsidian-sync run is in progress -- exiting") + sys.exit(0) + return fh # held until the process exits + + def cmd_sync(cfg, args) -> None: + _lock = run_lock(cfg) state = State(cfg["STATE_FILE"]) settle = dt.timedelta(minutes=int(cfg["SETTLE_MINUTES"])) now = time.time() @@ -719,6 +745,7 @@ def cmd_sync(cfg, args) -> None: def cmd_import(cfg, args) -> None: + _lock = run_lock(cfg) state = State(cfg["STATE_FILE"]) convs = parse_claude_ai_export(pathlib.Path(args.export)) work = [] diff --git a/claude-obsidian/config.env.example b/claude-obsidian/config.env.example index e7dcce5..42c1116 100644 --- a/claude-obsidian/config.env.example +++ b/claude-obsidian/config.env.example @@ -3,12 +3,15 @@ # Obsidian Local REST API plugin -- the plain-HTTP server (Settings -> Local REST API -> # Enable HTTP server). Point this at wherever that port is reachable from this machine. -OBSIDIAN_API_URL=http://obsidian.example.internal:27123 +# Several URLs, comma-separated, are tried in order -- e.g. a permanent address, then a port-forward. +OBSIDIAN_API_URL=http://obsidian.example.internal:27123,http://127.0.0.1:27123 OBSIDIAN_API_KEY_FILE=~/.config/claude-obsidian/api-key # Summarizer. claude-cli = headless `claude -p` on your own Claude plan (no API key needed). LLM_BACKEND=claude-cli -LLM_MODEL=haiku +LLM_MODEL=haiku # sonnet writes noticeably sharper notes for ~1-3x the usage; set prices to match below +# LLM_PRICE_IN_PER_M=1.0 # used only for the pre-call budget estimate (haiku list; sonnet 3.0 / 15.0) +# LLM_PRICE_OUT_PER_M=5.0 # CLAUDE_BIN=claude # Or any OpenAI-compatible endpoint (LiteLLM, a local model):