claude-obsidian: unattended overnight runs -- lock, URL fallback, config fix

Needed to leave a full backlog drain running unattended:

- Run lock (flock): enabling the timer fired an immediate catch-up run
  (Persistent=true) while a manual run was about to start; without a
  lock the two would race on state.json and usage.jsonl.
- OBSIDIAN_API_URL accepts a comma-separated list, first reachable wins,
  so the permanent address and an interim port-forward share one config.
- Fix: inline "# comments" in config.env were parsed into values, so
  BUDGET_USD_PER_RUN=0.50  # ... crashed float(). The shipped
  config.env.example had exactly that shape on several lines -- anyone
  copying it would have hit the crash. A '#' inside a value (URL fragment)
  still survives. Tested against both the example and a real config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
This commit is contained in:
2026-09-17 16:14:45 -04:00
co-authored by Claude Opus 5
parent 2bfa7e9a2d
commit 376ec03428
4 changed files with 43 additions and 9 deletions
+4
View File
@@ -80,6 +80,10 @@ claude mcp add --scope user --transport http obsidian http://<host>:27123/mcp/ \
keys, bearer tokens, `password=`-style assignments, JWTs, provider tokens, ≥48-char hex, and 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. random mixed-case strings. It over-redacts on purpose; git SHAs, md5s and paths survive.
The summarizer is also told never to reproduce secrets. 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 - **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 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 session is left alone until idle for `SETTLE_MINUTES`, so a live conversation isn't
+34 -7
View File
@@ -18,6 +18,7 @@ from __future__ import annotations
import argparse import argparse
import concurrent.futures as cf import concurrent.futures as cf
import datetime as dt import datetime as dt
import fcntl
import hashlib import hashlib
import json import json
import os import os
@@ -46,6 +47,7 @@ def load_config() -> dict:
line = line.strip() line = line.strip()
if line and not line.startswith("#") and "=" in line: if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1) 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[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_")}) 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(): for k, v in DEFAULTS.items():
@@ -474,7 +476,10 @@ class LLM:
# --------------------------------------------------------------------------- vault # --------------------------------------------------------------------------- vault
class Vault: class Vault:
def __init__(self, cfg: dict): 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") self.key = secret(cfg, "OBSIDIAN_API_KEY_FILE")
def put(self, path: str, content: str) -> None: def put(self, path: str, content: str) -> None:
@@ -488,12 +493,19 @@ class Vault:
def ping(self) -> None: def ping(self) -> None:
"""Checked BEFORE any summarizing, so an unreachable vault never spends usage limits.""" """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}"}) errors = []
try: for base in self.candidates:
with urllib.request.urlopen(req, timeout=15) as r: req = urllib.request.Request(f"{base}/", headers={"Authorization": f"Bearer {self.key}"})
info = json.load(r) try:
except (urllib.error.URLError, TimeoutError, OSError) as e: with urllib.request.urlopen(req, timeout=10) as r:
raise SystemExit(f"Obsidian REST API unreachable at {self.base}: {getattr(e, 'reason', e)}") from None 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"): if not info.get("authenticated"):
raise SystemExit("Obsidian REST API reachable but the API key was rejected") 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") 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: def cmd_sync(cfg, args) -> None:
_lock = run_lock(cfg)
state = State(cfg["STATE_FILE"]) state = State(cfg["STATE_FILE"])
settle = dt.timedelta(minutes=int(cfg["SETTLE_MINUTES"])) settle = dt.timedelta(minutes=int(cfg["SETTLE_MINUTES"]))
now = time.time() now = time.time()
@@ -719,6 +745,7 @@ def cmd_sync(cfg, args) -> None:
def cmd_import(cfg, args) -> None: def cmd_import(cfg, args) -> None:
_lock = run_lock(cfg)
state = State(cfg["STATE_FILE"]) state = State(cfg["STATE_FILE"])
convs = parse_claude_ai_export(pathlib.Path(args.export)) convs = parse_claude_ai_export(pathlib.Path(args.export))
work = [] work = []
+5 -2
View File
@@ -3,12 +3,15 @@
# Obsidian Local REST API plugin -- the plain-HTTP server (Settings -> Local REST API -> # 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. # 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 OBSIDIAN_API_KEY_FILE=~/.config/claude-obsidian/api-key
# Summarizer. claude-cli = headless `claude -p` on your own Claude plan (no API key needed). # Summarizer. claude-cli = headless `claude -p` on your own Claude plan (no API key needed).
LLM_BACKEND=claude-cli 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 # CLAUDE_BIN=claude
# Or any OpenAI-compatible endpoint (LiteLLM, a local model): # Or any OpenAI-compatible endpoint (LiteLLM, a local model):