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 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):