claude-obsidian: Claude conversations -> Obsidian notes (+ MCP) #1
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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}"})
|
||||
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=15) as r:
|
||||
with urllib.request.urlopen(req, timeout=10) 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
|
||||
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 = []
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user