claude-obsidian: Claude conversations -> Obsidian notes (+ MCP) #1

Merged
noconnor merged 4 commits from claude-obsidian-sync into main 2026-09-17 20:18:11 +00:00
4 changed files with 43 additions and 9 deletions
Showing only changes of commit 376ec03428 - Show all commits
+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
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
+34 -7
View File
@@ -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 = []
+5 -2
View File
@@ -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):