From 11138d90a99ed3fce86973b5280239191e272a93 Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Sun, 13 Sep 2026 08:51:28 -0400 Subject: [PATCH 1/4] claude-obsidian: summarize Claude conversations into Obsidian notes One note per Claude Code session (incremental, hourly user timer) and a claude.ai data-export importer, written through the Obsidian Local REST API plugin -- whose built-in /mcp/ server is also how Claude reads them back. Transcripts are condensed (tool output dropped, one line per tool call, ~60x smaller), redacted, then summarized by headless `claude -p` on the user's own plan. Three things learned the hard way are encoded here: - --no-session-persistence, or every summary becomes a transcript that the next run summarizes. - A LiteLLM gateway passing through the caller's Claude subscription has no key of its own, so a background job cannot use its claude-* models. - Summaries share the plan's usage limits. A parallel ~100-session backfill exhausted a session limit in minutes. So one job, a per-run cap, and the run stops at the first usage-limit error (exit 75) instead of failing the whole queue; a stop flag, because Future.cancel() cannot stop work a thread already picked up. Covered by an offline test of stop / resume / idempotent rerun. Nothing environment-specific is committed: hosts, keys and paths live in ~/.config/claude-obsidian/ (this repo is public). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g --- claude-obsidian/README.md | 91 +++ claude-obsidian/claude-obsidian-sync | 635 ++++++++++++++++++ claude-obsidian/config.env.example | 23 + claude-obsidian/install.sh | 14 + .../systemd/claude-obsidian-sync.service | 11 + .../systemd/claude-obsidian-sync.timer | 10 + 6 files changed, 784 insertions(+) create mode 100644 claude-obsidian/README.md create mode 100755 claude-obsidian/claude-obsidian-sync create mode 100644 claude-obsidian/config.env.example create mode 100755 claude-obsidian/install.sh create mode 100644 claude-obsidian/systemd/claude-obsidian-sync.service create mode 100644 claude-obsidian/systemd/claude-obsidian-sync.timer diff --git a/claude-obsidian/README.md b/claude-obsidian/README.md new file mode 100644 index 0000000..c7522d6 --- /dev/null +++ b/claude-obsidian/README.md @@ -0,0 +1,91 @@ +# claude-obsidian — Claude conversations → Obsidian notes + +Summarizes every Claude conversation into one searchable Obsidian note: what happened, what +was decided and why, the facts and gotchas worth remembering, and what was left open. + +- **Claude Code** sessions sync automatically and incrementally (hourly timer). +- **claude.ai** chats import from a data export (claude.ai has no API, so each export is manual). + +Notes are written through the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api) +plugin, so the vault can live anywhere the plugin is reachable. The same plugin exposes a +**built-in MCP server** (`/mcp/`), which is how Claude reads the notes back — no separate MCP +process. + +``` +~/.claude/projects/*/*.jsonl ─┐ ┌─ Claude/Code/2026/…md +claude.ai export .zip ────────┼─ condense → redact → summarize → PUT ────┼─ Claude/Web/2026/…md + └─ (headless claude -p, no transcript) └─ Claude/Index.md +``` + +## Install + +1. In Obsidian, install **Local REST API**, enable its **HTTP server**, and make it reachable + from this machine. Put the API key in `~/.config/claude-obsidian/api-key` (`chmod 600`). +2. `./install.sh` — links the binary into `~/.local/bin`, enables the hourly user timer, and + creates `~/.config/claude-obsidian/config.env` from `config.env.example` if absent. +3. Edit `config.env` (at minimum `OBSIDIAN_API_URL`), then preview one note: + + ```bash + claude-obsidian-sync stats # sizes, no network + claude-obsidian-sync sync --dry-run --limit 1 # prints the note, writes nothing + ``` + +## Use + +```bash +claude-obsidian-sync sync # what the timer runs +claude-obsidian-sync sync --session # one session, now, ignoring the settle window +claude-obsidian-sync import-claude-ai ~/Downloads/data-2026-09-13.zip +``` + +Claude reads the vault through the plugin's MCP server: + +```bash +claude mcp add --scope user --transport http obsidian http://:27123/mcp/ \ + --header "Authorization: Bearer $(cat ~/.config/claude-obsidian/api-key)" +``` + +## How it behaves — and why + +- **Summaries run on your own Claude plan** via headless `claude -p` (`LLM_BACKEND=claude-cli`). + No API key to manage. An OpenAI-compatible backend (`LLM_BACKEND=openai`) exists for local + models. Note a LiteLLM gateway that fronts Claude by *passing through the caller's* + subscription has no key of its own, so a background job cannot use its `claude-*` models. +- **`--no-session-persistence` is load-bearing.** Without it every summary writes a new + transcript into `~/.claude/projects`, and the next run summarizes its own summarizing. +- **It shares your plan's usage limits.** A first backfill of ~100 sessions in parallel hit a + session limit within minutes — which also limits *interactive* use. So: one job at a time, + `MAX_PER_RUN` sessions per run (newest first), and the run **stops at the first usage-limit + error** (exit 75, which the systemd unit treats as success) instead of failing the rest of + the queue. The next hourly run resumes. A 100-session backlog drains in about ten hours. +- **Secrets are redacted before anything leaves the machine**, and again in the note: private + 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. +- **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 + summarized mid-flight. Empty sessions are recorded and skipped. +- **Condensed, not truncated.** Tool output is dropped and each tool call becomes one line; + prose is kept. Typical compression is ~60× (271 MB of transcripts → 4.4 MB). Sessions over + 300 KB condensed are summarized in parts, then combined. +- **Subagent transcripts** (`/subagents/**`) are part of their parent session and are + not synced separately. + +## Note format + +```yaml +type: claude-conversation +source: claude-code # or claude-ai +session_id: 3f9c2a1e-… +title: Fix flaky CI cache after runner upgrade +project: my-service # basename of the working directory +branch: … +started / ended: … +prompts: 1 # user prompts, not tool-loop records +summarized_by: claude-haiku-4-5-20251001 +tags: [claude, claude-code, ci, caching, …] +``` + +followed by a summary callout and **Outcomes / Decisions / Facts & gotchas / Open threads**, +and a `claude --resume ` line. `Claude/Index.md` lists every note, newest first. diff --git a/claude-obsidian/claude-obsidian-sync b/claude-obsidian/claude-obsidian-sync new file mode 100755 index 0000000..5b0041d --- /dev/null +++ b/claude-obsidian/claude-obsidian-sync @@ -0,0 +1,635 @@ +#!/usr/bin/env python3 +"""claude-obsidian-sync -- summarize Claude conversations into an Obsidian vault. + +Reads Claude Code transcripts (~/.claude/projects//.jsonl) and claude.ai +data exports, condenses each conversation, redacts secrets, summarizes it with an +OpenAI-compatible LLM endpoint (e.g. LiteLLM), and writes one note per conversation into +Obsidian through the Local REST API plugin. Standard library only. + + claude-obsidian-sync stats # sizes and counts, no network + claude-obsidian-sync sync [--dry-run] # Claude Code sessions (incremental) + claude-obsidian-sync import-claude-ai EXPORT.zip|conversations.json + +Configuration comes from the environment, falling back to ~/.config/claude-obsidian/config.env +(KEY=VALUE lines). Nothing environment-specific lives in this file. See README.md. +""" +from __future__ import annotations + +import argparse +import concurrent.futures as cf +import datetime as dt +import hashlib +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile + +HOME = pathlib.Path.home() +CONFIG_DIR = HOME / ".config" / "claude-obsidian" + + +# --------------------------------------------------------------------------- config +def load_config() -> dict: + cfg: dict[str, str] = {} + f = CONFIG_DIR / "config.env" + if f.exists(): + for line in f.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, v = line.split("=", 1) + 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(): + cfg.setdefault(k, v) + return cfg + + +DEFAULTS = { + "OBSIDIAN_API_URL": "http://127.0.0.1:27123", + "OBSIDIAN_API_KEY_FILE": str(CONFIG_DIR / "api-key"), + "LLM_BACKEND": "claude-cli", # claude-cli | openai + "CLAUDE_BIN": "claude", + "LLM_BASE_URL": "", + "LLM_API_KEY_FILE": "", + "LLM_API_KEY_ENV": "LITELLM_KEY", + "LLM_MODEL": "haiku", + "VAULT_FOLDER": "Claude", + "SETTLE_MINUTES": "30", + "CLAUDE_PROJECTS_DIR": str(HOME / ".claude" / "projects"), + "STATE_FILE": str(HOME / ".local" / "state" / "claude-obsidian" / "state.json"), + "JOBS": "1", + # Cap per run. Summaries draw on the same plan limits as interactive Claude use, so the + # timer drips a backlog instead of bursting it (a 107-session burst exhausted a session limit). + "MAX_PER_RUN": "10", +} + + +def secret(cfg: dict, file_key: str, env_key: str | None = None) -> str: + path = cfg.get(file_key) + if path: + p = pathlib.Path(os.path.expanduser(path)) + if p.exists(): + return p.read_text().strip() + if env_key and os.environ.get(cfg.get(env_key, "") or ""): + return os.environ[cfg[env_key]].strip() + raise SystemExit(f"missing secret: set {file_key} (or {env_key})") + + +# --------------------------------------------------------------------------- redaction +# Applied to everything sent to the LLM AND to the note written. Over-redaction is the +# accepted failure mode: a summary missing a token is fine, a vault holding one is not. +_REDACTIONS = [ + (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[REDACTED PRIVATE KEY]"), + (re.compile(r"(?i)\b(bearer|token)\s+[A-Za-z0-9._~+/=-]{16,}"), r"\1 [REDACTED]"), + (re.compile(r"(?i)((?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|NB_API_KEY|PAT)[\"']?\s*[:=]\s*[\"']?)([^\s\"'`,;]{6,})"), r"\1[REDACTED]"), + (re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), "[REDACTED JWT]"), + (re.compile(r"\b(?:sk|pk|rk)-[A-Za-z0-9_-]{20,}|\bgh[pousr]_[A-Za-z0-9]{30,}|\bglpat-[A-Za-z0-9_-]{20,}|\bxox[abpr]-[A-Za-z0-9-]{10,}"), "[REDACTED TOKEN]"), + # >=48 hex: API keys / sha256 tokens. Deliberately leaves 40-hex git SHAs and 32-hex md5s. + (re.compile(r"\b[a-fA-F0-9]{48,}\b"), "[REDACTED HEX]"), + # Random mixed-case+digit strings (generated passwords). No '/' so paths survive. + (re.compile(r"(? str: + for pat, repl in _REDACTIONS: + text = pat.sub(repl, text) + return text + + +# --------------------------------------------------------------------------- parsing +_NOISE_BLOCKS = re.compile( + r"<(system-reminder|local-command-stdout|local-command-stderr|local-command-caveat|task-notification)>.*?", + re.S, +) +_CMD = re.compile(r"(.*?).*?(?:(.*?))?", re.S) +MAX_MSG = 6000 + + +def _clip(text: str) -> str: + return text if len(text) <= MAX_MSG else text[:4000] + "\n[...clipped...]\n" + text[-1500:] + + +def _clean_user(text: str) -> str: + text = _NOISE_BLOCKS.sub("", text) + m = _CMD.search(text) + if m: + args = (m.group(2) or "").strip() + text = f"{m.group(1).strip()} {args}".strip() + text = re.sub(r"", "", text) + return text.strip() + + +def _tool_line(block: dict) -> str: + name = block.get("name", "tool") + inp = block.get("input") or {} + desc = inp.get("description") or inp.get("command") or inp.get("file_path") or inp.get("query") \ + or inp.get("url") or inp.get("prompt") or inp.get("pattern") or "" + desc = " ".join(str(desc).split())[:160] + return f"[{name}: {desc}]" if desc else f"[{name}]" + + +def parse_claude_code(path: pathlib.Path) -> dict: + meta = {"session_id": path.stem, "cwd": None, "branch": None, "first": None, "last": None, + "title": None, "version": None, "prompts": 0} + turns: list[list[str]] = [] + for line in path.open(encoding="utf-8", errors="replace"): + try: + r = json.loads(line) + except json.JSONDecodeError: + continue + t = r.get("type") + if t == "ai-title" and r.get("aiTitle"): + meta["title"] = r["aiTitle"] + if t == "custom-title" and r.get("customTitle"): + meta["title"] = r["customTitle"] + if t not in ("user", "assistant") or r.get("isSidechain") or r.get("isMeta"): + continue + ts = r.get("timestamp") + if ts: + meta["first"] = meta["first"] or ts + meta["last"] = ts + meta["cwd"] = meta["cwd"] or r.get("cwd") + meta["branch"] = r.get("gitBranch") or meta["branch"] + meta["version"] = r.get("version") or meta["version"] + content = (r.get("message") or {}).get("content") + if t == "user": + if isinstance(content, str): + text = content + elif isinstance(content, list): + text = "\n".join(b.get("text", "") for b in content if b.get("type") == "text") + else: + continue + text = _clean_user(text) + if text: + role = "SUMMARY-OF-EARLIER-CONTEXT" if r.get("isCompactSummary") else "USER" + turns.append([role, _clip(text)]) + meta["prompts"] += role == "USER" + else: + parts = [] + for b in content if isinstance(content, list) else []: + if b.get("type") == "text" and b.get("text", "").strip(): + parts.append(_clip(b["text"].strip())) + elif b.get("type") == "tool_use": + parts.append(_tool_line(b)) + if not parts: + continue + if turns and turns[-1][0] == "ASSISTANT": # a tool loop is many records + turns[-1][1] += "\n" + "\n".join(parts) + else: + turns.append(["ASSISTANT", "\n".join(parts)]) + meta["transcript"] = "\n\n".join(f"{role}: {text}" for role, text in turns) + return meta + + +def parse_claude_ai_export(src: pathlib.Path) -> list[dict]: + """claude.ai 'Export data' -> conversations.json. Tolerant of field-name drift.""" + if src.suffix == ".zip": + with zipfile.ZipFile(src) as z: + name = next((n for n in z.namelist() if n.endswith("conversations.json")), None) + if not name: + raise SystemExit(f"no conversations.json inside {src}") + data = json.loads(z.read(name)) + else: + data = json.loads(src.read_text()) + out = [] + for c in data if isinstance(data, list) else data.get("conversations", []): + turns, count = [], 0 + for m in c.get("chat_messages") or c.get("messages") or []: + text = m.get("text") or "" + if not text and isinstance(m.get("content"), list): + text = "\n".join(b.get("text", "") for b in m["content"] if b.get("type") == "text") + for a in m.get("attachments") or []: + if a.get("extracted_content"): + text += f"\n[attachment {a.get('file_name', '')}]: {a['extracted_content'][:1500]}" + if not text.strip(): + continue + role = "USER" if (m.get("sender") or m.get("role")) in ("human", "user") else "ASSISTANT" + turns.append(f"{role}: {_clip(text.strip())}") + count += role == "USER" + if not turns: + continue + out.append({ + "session_id": c.get("uuid") or hashlib.sha1(json.dumps(c, sort_keys=True).encode()).hexdigest(), + "title": c.get("name") or None, "first": c.get("created_at"), "last": c.get("updated_at"), + "cwd": None, "branch": None, "version": None, "prompts": count, + "transcript": "\n\n".join(turns), + }) + return out + + +# --------------------------------------------------------------------------- LLM +SYSTEM = """You write concise knowledge-base notes summarizing a conversation between a user and Claude. +The reader is the same user, months later, searching their notes. Capture what happened and +what was learned, not a play-by-play. Be specific: name the systems, files, commands, errors +and decisions. Never include passwords, API keys, tokens or other secrets, even if present; +refer to them generically ("the API key"). Text marked [REDACTED] must stay redacted. + +Reply with ONLY a JSON object, no prose, with these keys: + "title": short specific title (<= 70 chars) + "summary": 3-6 sentences + "outcomes": list of what was actually built, fixed or answered + "decisions": list of choices made and why + "facts": list of reusable facts, gotchas, root causes worth remembering + "open_threads": list of unfinished work or follow-ups + "tags": 3-8 lowercase kebab-case topic tags +Use empty lists where nothing applies.""" + +CHUNK_SYSTEM = """You are condensing one part of a long conversation between a user and Claude so that a +later step can summarize the whole. Output dense bullet notes: goals, actions taken, results, +errors and their causes, decisions, open questions. Be specific. Never reproduce secrets.""" + +CHUNK_CHARS = 300_000 + + +class QuotaExhausted(RuntimeError): + """The plan/API usage limit is hit. Every further call would fail the same way -- stop the run.""" + + +_QUOTA = re.compile(r"(?i)hit your (?:session |usage |weekly )?limit|usage limit|quota|credit balance|resets? (?:at )?\d") + + +class LLM: + """Two backends. + + claude-cli (default): headless Claude Code on the user's own plan -- no API key to manage. + --no-session-persistence is load-bearing: without it every summary writes a new transcript + into ~/.claude/projects and the next sync would summarize its own summarizing. + openai: any OpenAI-compatible /chat/completions endpoint (LiteLLM, local models). + """ + + def __init__(self, cfg: dict): + self.backend = cfg["LLM_BACKEND"] + self.model = cfg["LLM_MODEL"] + self.resolved = cfg["LLM_MODEL"] + if self.backend == "claude-cli": + self.bin = shutil.which(os.path.expanduser(cfg["CLAUDE_BIN"])) + if not self.bin: + raise SystemExit(f"claude CLI not found ({cfg['CLAUDE_BIN']})") + self.workdir = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent # no project CLAUDE.md here + self.workdir.mkdir(parents=True, exist_ok=True) + elif self.backend == "openai": + if not cfg["LLM_BASE_URL"]: + raise SystemExit("LLM_BASE_URL is not set (see config.env.example)") + self.url = cfg["LLM_BASE_URL"].rstrip("/") + "/chat/completions" + self.key = secret(cfg, "LLM_API_KEY_FILE", "LLM_API_KEY_ENV") + else: + raise SystemExit(f"unknown LLM_BACKEND {self.backend!r}") + + def chat(self, system: str, user: str, max_tokens: int = 2500) -> str: + return self._chat_cli(system, user) if self.backend == "claude-cli" else self._chat_openai(system, user, max_tokens) + + def _chat_cli(self, system: str, user: str) -> str: + cmd = [self.bin, "-p", "--model", self.model, "--no-session-persistence", "--tools", "", + "--strict-mcp-config", "--output-format", "json", "--system-prompt", system] + last = "" + for attempt in range(5): + try: + proc = subprocess.run(cmd, input=user, capture_output=True, text=True, timeout=900, cwd=self.workdir) + except subprocess.TimeoutExpired: + last = "timeout" + else: + try: + d = json.loads(proc.stdout) + except json.JSONDecodeError: + d = {"is_error": True, "result": (proc.stdout or proc.stderr)[-300:]} + if not d.get("is_error") and d.get("result"): + used = list((d.get("modelUsage") or {}).keys()) + if used: + self.resolved = used[0] + return d["result"] + last = str(d.get("result") or proc.stderr)[-300:] + if _QUOTA.search(last): + raise QuotaExhausted(last.strip()) + if not re.search(r"(?i)rate.?limit|overloaded|429|529|timeout|temporar", last): + break # not transient -- do not hammer + time.sleep(2 ** attempt * 10) + raise RuntimeError(f"claude -p failed: {last}") + + def _chat_openai(self, system: str, user: str, max_tokens: int) -> str: + body = json.dumps({"model": self.model, "temperature": 0.2, "max_tokens": max_tokens, + "messages": [{"role": "system", "content": system}, + {"role": "user", "content": user}]}).encode() + for attempt in range(5): + req = urllib.request.Request(self.url, data=body, method="POST", headers={ + "Authorization": f"Bearer {self.key}", "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=600) as r: + return json.load(r)["choices"][0]["message"]["content"] + except urllib.error.HTTPError as e: + detail = e.read()[:300].decode(errors="replace") + if _QUOTA.search(detail): + raise QuotaExhausted(detail) from None + if e.code in (429, 500, 502, 503, 504, 529) and attempt < 4: + time.sleep(2 ** attempt * 5) + continue + raise RuntimeError(f"LLM HTTP {e.code}: {detail}") from None + except (urllib.error.URLError, TimeoutError) as e: + if attempt < 4: + time.sleep(2 ** attempt * 5) + continue + raise RuntimeError(f"LLM unreachable: {e}") from None + raise RuntimeError("LLM retries exhausted") + + def summarize(self, meta: dict) -> dict: + text = redact(meta["transcript"]) + if len(text) > CHUNK_CHARS: # map-reduce for long sessions + chunks, cur = [], [] + size = 0 + for part in text.split("\n\n"): + if size + len(part) > CHUNK_CHARS and cur: + chunks.append("\n\n".join(cur)); cur, size = [], 0 + cur.append(part); size += len(part) + 2 + chunks.append("\n\n".join(cur)) + notes = [self.chat(CHUNK_SYSTEM, f"Part {i + 1} of {len(chunks)}:\n\n{c}", 3000) + for i, c in enumerate(chunks)] + text = "Condensed notes from each part of a long conversation, in order:\n\n" + \ + "\n\n---\n\n".join(notes) + header = f"Working directory: {meta.get('cwd')}\nGit branch: {meta.get('branch')}\n" \ + f"Existing title: {meta.get('title')}\n\n" + raw = self.chat(SYSTEM, header + text) + m = re.search(r"\{.*\}", raw, re.S) + if not m: + raise RuntimeError(f"LLM did not return JSON: {raw[:200]!r}") + out = json.loads(m.group(0)) + for k in ("outcomes", "decisions", "facts", "open_threads", "tags"): + out[k] = [redact(str(x)) for x in (out.get(k) or []) if str(x).strip()] + out["summary"] = redact(str(out.get("summary", ""))) + out["title"] = redact(str(out.get("title") or meta.get("title") or meta["session_id"])) + return out + + +# --------------------------------------------------------------------------- vault +class Vault: + def __init__(self, cfg: dict): + self.base = cfg["OBSIDIAN_API_URL"].rstrip("/") + self.key = secret(cfg, "OBSIDIAN_API_KEY_FILE") + + def put(self, path: str, content: str) -> None: + req = urllib.request.Request(f"{self.base}/vault/{urllib.parse.quote(path)}", + data=content.encode(), method="PUT", headers={ + "Authorization": f"Bearer {self.key}", + "Content-Type": "text/markdown"}) + with urllib.request.urlopen(req, timeout=60) as r: + if r.status not in (200, 201, 204): + raise RuntimeError(f"vault PUT {path}: HTTP {r.status}") + + 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 + if not info.get("authenticated"): + raise SystemExit("Obsidian REST API reachable but the API key was rejected") + + +_BAD = re.compile(r'[\\/:*?"<>|#^\[\]]+') + + +def note_path(cfg: dict, kind: str, meta: dict, title: str) -> str: + when = (meta.get("first") or dt.datetime.now(dt.timezone.utc).isoformat())[:10] + slug = " ".join(_BAD.sub(" ", title).split())[:60].strip() or "untitled" + return f"{cfg['VAULT_FOLDER']}/{kind}/{when[:4]}/{when} {slug} ({meta['session_id'][:8]}).md" + + +def render(meta: dict, s: dict, source: str, model: str, src_file: str | None) -> str: + project = pathlib.Path(meta["cwd"]).name if meta.get("cwd") else None + tags = ["claude", source] + [t for t in s["tags"] if t not in ("claude", source)] + fm = { + "type": "claude-conversation", "source": source, "session_id": meta["session_id"], + "title": s["title"], "project": project, "cwd": meta.get("cwd"), "branch": meta.get("branch"), + "started": meta.get("first"), "ended": meta.get("last"), "prompts": meta.get("prompts"), + "summarized_by": model, "synced": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "tags": tags, + } + lines = ["---"] + [f"{k}: {json.dumps(v)}" for k, v in fm.items() if v not in (None, "")] + ["---", "", + f"# {s['title']}", "", "> [!summary]"] + [f"> {l}" for l in s["summary"].splitlines() or [""]] + for heading, key in (("Outcomes", "outcomes"), ("Decisions", "decisions"), + ("Facts & gotchas", "facts"), ("Open threads", "open_threads")): + if s[key]: + lines += ["", f"## {heading}", ""] + [f"- {x}" for x in s[key]] + lines += ["", "---"] + if source == "claude-code": + lines.append(f"Resume: `claude --resume {meta['session_id']}`" + (f" · transcript `{src_file}`" if src_file else "")) + else: + lines.append(f"claude.ai conversation `{meta['session_id']}`") + return "\n".join(lines) + "\n" + + +# --------------------------------------------------------------------------- state +class State: + def __init__(self, path: str): + self.path = pathlib.Path(os.path.expanduser(path)) + self.lock = threading.Lock() + self.data = json.loads(self.path.read_text()) if self.path.exists() else {} + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(self.data, indent=1, sort_keys=True)) + tmp.replace(self.path) + + def record(self, key: str, value: dict) -> None: + with self.lock: + self.data[key] = value + self.save() + + +def write_index(cfg: dict, vault: Vault, state: State) -> None: + rows = sorted((v for v in state.data.values() if v.get("note")), key=lambda v: v.get("started") or "", reverse=True) + out = ["---", 'type: "claude-index"', "---", "", "# Claude conversations", "", + f"{len(rows)} conversations. Regenerated by claude-obsidian-sync.", "", + "| Date | Title | Project | Source |", "|---|---|---|---|"] + for v in rows: + link = v["note"][:-3] + title = v.get("title", "").replace("|", "-") + out.append(f"| {(v.get('started') or '')[:10]} | [[{link}\\|{title}]] | {v.get('project') or ''} | {v.get('source')} |") + vault.put(f"{cfg['VAULT_FOLDER']}/Index.md", "\n".join(out) + "\n") + + +# --------------------------------------------------------------------------- commands +def top_level_sessions(cfg: dict) -> list[pathlib.Path]: + root = pathlib.Path(os.path.expanduser(cfg["CLAUDE_PROJECTS_DIR"])) + return sorted(root.glob("*/*.jsonl"), key=lambda p: p.stat().st_mtime) # nested = subagents + + +def process(cfg, llm, vault, state, key, meta, source, kind, fingerprint, src_file, dry_run) -> str: + s = llm.summarize(meta) + prev = state.data.get(key, {}) + path = prev.get("note") or note_path(cfg, kind, meta, s["title"]) # stable across re-syncs + body = render(meta, s, source, llm.resolved, src_file) + if dry_run: + print(f"\n===== {path} =====\n{body}") + return path + vault.put(path, body) + state.record(key, {"note": path, "title": s["title"], "fingerprint": fingerprint, "source": source, + "started": meta.get("first"), + "project": pathlib.Path(meta["cwd"]).name if meta.get("cwd") else None}) + return path + + +SKIPPED = "(empty, skipped)" +EX_TEMPFAIL = 75 # "try again later" -- systemd logs it, the next timer run retries + + +def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]: + ok = failed = 0 + quota = None + stop = threading.Event() # Future.cancel() cannot stop a job a worker already picked up + + def guarded(*w): + if stop.is_set(): + raise cf.CancelledError() + try: + return fn(*w) + except QuotaExhausted: + stop.set() + raise + + with cf.ThreadPoolExecutor(max_workers=int(cfg["JOBS"])) as ex: + futs = {ex.submit(guarded, *w): w for w in work} + for fut in cf.as_completed(futs): + label = futs[fut][0] + try: + result = fut.result() + if result == SKIPPED: + print(f"skip {label} (no conversation content)", flush=True) + else: + print(f"ok {label} -> {result}", flush=True); ok += 1 + except QuotaExhausted as e: + if quota is None: + quota = str(e) + for f in futs: # don't launch the rest + f.cancel() + except cf.CancelledError: + pass + except Exception as e: # keep going; next run retries + print(f"FAIL {label}: {e}", file=sys.stderr, flush=True); failed += 1 + return ok, failed, quota + + +def finish(ok: int, failed: int, quota: str | None, dry_run: bool, remaining: int) -> None: + verb = "rendered (dry run, nothing written)" if dry_run else "written" + print(f"done: {ok} {verb}, {failed} failed, {remaining} still queued") + if quota: + print(f"stopped: usage limit reached -- {quota}", file=sys.stderr) + sys.exit(EX_TEMPFAIL) + sys.exit(1 if failed else 0) + + +def cmd_stats(cfg, args) -> None: + total = 0 + rows = [] + for p in top_level_sessions(cfg): + m = parse_claude_code(p) + n = len(redact(m["transcript"])) + total += n + rows.append((n, p.stat().st_size, m["prompts"], m["title"] or p.stem)) + rows.sort(reverse=True) + for n, raw, msgs, title in rows[: args.top]: + print(f"{raw / 1e6:7.2f} MB raw -> {n / 1e3:7.1f} KB condensed {msgs:4d} prompts {title[:60]}") + print(f"\n{len(rows)} sessions, {total / 1e6:.1f} MB condensed (~{total / 4 / 1e6:.2f}M tokens in), " + f"{sum(1 for r in rows if r[0] > CHUNK_CHARS)} need map-reduce") + + +def cmd_sync(cfg, args) -> None: + state = State(cfg["STATE_FILE"]) + settle = dt.timedelta(minutes=int(cfg["SETTLE_MINUTES"])) + now = time.time() + work = [] + for p in top_level_sessions(cfg): + if args.session and p.stem != args.session: + continue + st = p.stat() + fp = f"{st.st_size}:{int(st.st_mtime)}" + key = f"claude-code:{p.stem}" + if not args.force and state.data.get(key, {}).get("fingerprint") == fp: + continue + if not (args.force or args.session) and now - st.st_mtime < settle.total_seconds(): + continue # still being written + work.append((p, fp, key)) + total = len(work) + cap = args.limit or int(cfg["MAX_PER_RUN"]) + work = work[-cap:] if not args.oldest else work[:cap] # newest first by default + print(f"{total} session(s) pending, summarizing {len(work)} this run", flush=True) + if not work: + return + llm, vault = LLM(cfg), (None if args.dry_run else Vault(cfg)) + if vault: + vault.ping() + + def one(p, fp, key): + meta = parse_claude_code(p) + if not meta["transcript"].strip(): + if not args.dry_run: + state.record(key, {"note": None, "fingerprint": fp, "source": "claude-code"}) + return SKIPPED + src = str(p).replace(str(HOME), "~") + return process(cfg, llm, vault, state, key, meta, "claude-code", "Code", fp, src, args.dry_run) + + ok, failed, quota = run_jobs(cfg, [(w[0].name, *w) for w in work], lambda _l, p, fp, key: one(p, fp, key)) + if vault and ok: + write_index(cfg, vault, state) + finish(ok, failed, quota, args.dry_run, total - ok) + + +def cmd_import(cfg, args) -> None: + state = State(cfg["STATE_FILE"]) + convs = parse_claude_ai_export(pathlib.Path(args.export)) + work = [] + for m in convs: + key = f"claude-ai:{m['session_id']}" + fp = m.get("last") or str(len(m["transcript"])) + if args.force or state.data.get(key, {}).get("fingerprint") != fp: + work.append((m, fp, key)) + total = len(work) + work = work[: args.limit or int(cfg["MAX_PER_RUN"])] + print(f"{len(convs)} conversations in export, {total} new or changed, summarizing {len(work)} this run", flush=True) + if not work: + return + llm, vault = LLM(cfg), (None if args.dry_run else Vault(cfg)) + if vault: + vault.ping() + ok, failed, quota = run_jobs(cfg, [((m.get("title") or m["session_id"])[:50], m, fp, key) for m, fp, key in work], + lambda _l, m, fp, key: process(cfg, llm, vault, state, key, m, "claude-ai", "Web", + fp, None, args.dry_run)) + if vault and ok: + write_index(cfg, vault, state) + finish(ok, failed, quota, args.dry_run, total - ok) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + s = sub.add_parser("stats", help="condensed sizes, no network") + s.add_argument("--top", type=int, default=15) + s = sub.add_parser("sync", help="summarize Claude Code sessions") + s.add_argument("--dry-run", action="store_true", help="print notes instead of writing them") + s.add_argument("--force", action="store_true", help="ignore state and the settle window") + s.add_argument("--session", help="only this session id") + s.add_argument("--limit", type=int, help="max sessions this run (default MAX_PER_RUN)") + s.add_argument("--oldest", action="store_true", help="take the oldest pending sessions instead of the newest") + s = sub.add_parser("import-claude-ai", help="import a claude.ai data export") + s.add_argument("export", help="the export .zip or its conversations.json") + s.add_argument("--dry-run", action="store_true") + s.add_argument("--force", action="store_true") + s.add_argument("--limit", type=int, help="max conversations this run (default MAX_PER_RUN)") + args = ap.parse_args() + cfg = load_config() + {"stats": cmd_stats, "sync": cmd_sync, "import-claude-ai": cmd_import}[args.cmd](cfg, args) + + +if __name__ == "__main__": + main() diff --git a/claude-obsidian/config.env.example b/claude-obsidian/config.env.example new file mode 100644 index 0000000..7531d71 --- /dev/null +++ b/claude-obsidian/config.env.example @@ -0,0 +1,23 @@ +# ~/.config/claude-obsidian/config.env (chmod 600; never commit the real one) +# Every key can also be set as an environment variable, which wins. + +# 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 +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 +# CLAUDE_BIN=claude + +# Or any OpenAI-compatible endpoint (LiteLLM, a local model): +# LLM_BACKEND=openai +# LLM_BASE_URL=https://litellm.example/v1 +# LLM_MODEL=some-model +# LLM_API_KEY_FILE=~/.config/claude-obsidian/llm-key # or LLM_API_KEY_ENV=LITELLM_KEY + +VAULT_FOLDER=Claude +SETTLE_MINUTES=30 # leave sessions alone until they have been idle this long +JOBS=1 +MAX_PER_RUN=10 # summaries share your plan's usage limits; the timer drips a backlog diff --git a/claude-obsidian/install.sh b/claude-obsidian/install.sh new file mode 100755 index 0000000..407ee70 --- /dev/null +++ b/claude-obsidian/install.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Install claude-obsidian-sync for the current user: binary into ~/.local/bin, systemd user +# timer enabled. Idempotent. Does not create config or secrets -- see README.md. +set -eu +HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user" "$HOME/.config/claude-obsidian" +chmod 700 "$HOME/.config/claude-obsidian" +ln -sf "$HERE/claude-obsidian-sync" "$HOME/.local/bin/claude-obsidian-sync" +cp "$HERE/systemd/claude-obsidian-sync.service" "$HERE/systemd/claude-obsidian-sync.timer" "$HOME/.config/systemd/user/" +[ -f "$HOME/.config/claude-obsidian/config.env" ] || { cp "$HERE/config.env.example" "$HOME/.config/claude-obsidian/config.env"; chmod 600 "$HOME/.config/claude-obsidian/config.env"; echo "created config.env from the example -- edit it"; } +systemctl --user daemon-reload +systemctl --user enable --now claude-obsidian-sync.timer +echo "installed. check: systemctl --user list-timers claude-obsidian-sync.timer" +echo "timers only run while logged in unless lingering is on: loginctl enable-linger \"\$USER\"" diff --git a/claude-obsidian/systemd/claude-obsidian-sync.service b/claude-obsidian/systemd/claude-obsidian-sync.service new file mode 100644 index 0000000..168a384 --- /dev/null +++ b/claude-obsidian/systemd/claude-obsidian-sync.service @@ -0,0 +1,11 @@ +[Unit] +Description=Summarize Claude conversations into Obsidian +After=network-online.target + +[Service] +Type=oneshot +ExecStart=%h/.local/bin/claude-obsidian-sync sync +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +# 75 = usage limit reached. The run stopped cleanly and the next timer run resumes -- not a failure. +SuccessExitStatus=75 +Nice=10 diff --git a/claude-obsidian/systemd/claude-obsidian-sync.timer b/claude-obsidian/systemd/claude-obsidian-sync.timer new file mode 100644 index 0000000..03cc583 --- /dev/null +++ b/claude-obsidian/systemd/claude-obsidian-sync.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Hourly Claude -> Obsidian conversation sync + +[Timer] +OnCalendar=hourly +RandomizedDelaySec=10m +Persistent=true + +[Install] +WantedBy=timers.target -- 2.54.0 From 2bfa7e9a2d3cb793ff15fe017b13bcc78a6e334b Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Mon, 14 Sep 2026 00:56:38 -0400 Subject: [PATCH 2/4] claude-obsidian: meter and cap usage; cut per-session cost ~5.6x The user asked for usage to be monitored closely, and measuring it turned up that most of the spend was avoidable. Metering: every model call is appended to usage.jsonl with BILLED tokens (modelUsage -- the visible `usage` turn undercounted by half) and the CLI's list-price cost. `usage` reports 5h/24h/7d. Each call's cost is estimated first and refused if it would cross the per-run or rolling 24h budget, so budgets cannot be overshot. Removed --max-budget-usd: it aborts after spending and discards the result ($0.26 lost in one call). Cost, measured on one session ($0.057 -> $0.010, same summary content): - alwaysThinkingEnabled=false: extended thinking was ~85% of output. - DISABLE_PROMPT_CACHING=1: one-shot prompts were being written to the 1h cache at 2x input price. - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1: a hidden side request re-read the whole prompt, doubling billed input (9,638 vs 4,663). Summaries are cached until their note is written, so a vault failure never re-pays; --prefetch summarizes while the vault is unreachable. Offline tests (fake claude binary): budget refusal pre-flight, cache reuse with zero calls, map-reduce, env/argv levers, billed-token ledger. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g --- claude-obsidian/README.md | 18 ++ .../claude-obsidian-synccpython-312.pyc | Bin 0 -> 61341 bytes claude-obsidian/claude-obsidian-sync | 168 ++++++++++++++++-- claude-obsidian/config.env.example | 2 + 4 files changed, 172 insertions(+), 16 deletions(-) create mode 100644 claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc diff --git a/claude-obsidian/README.md b/claude-obsidian/README.md index c7522d6..877937d 100644 --- a/claude-obsidian/README.md +++ b/claude-obsidian/README.md @@ -36,6 +36,8 @@ claude.ai export .zip ────────┼─ condense → redact → sum claude-obsidian-sync sync # what the timer runs claude-obsidian-sync sync --session # one session, now, ignoring the settle window claude-obsidian-sync import-claude-ai ~/Downloads/data-2026-09-13.zip +claude-obsidian-sync sync --prefetch --limit 3 # summarize into the local cache, vault untouched +claude-obsidian-sync usage # calls, billed tokens, spend: last 5h / 24h / 7d ``` Claude reads the vault through the plugin's MCP server: @@ -53,6 +55,22 @@ claude mcp add --scope user --transport http obsidian http://:27123/mcp/ \ subscription has no key of its own, so a background job cannot use its `claude-*` models. - **`--no-session-persistence` is load-bearing.** Without it every summary writes a new transcript into `~/.claude/projects`, and the next run summarizes its own summarizing. +- **Usage is metered and capped.** Every model call is appended to + `~/.local/state/claude-obsidian/usage.jsonl` with *billed* tokens (the CLI's `modelUsage`, not + the visible turn) and `total_cost_usd` — API list-price equivalent, a faithful proxy for how much + was consumed; the plan's own meter is only visible in Claude Code's `/usage`. Before each call + its cost is **estimated** (+20% margin) and the call is refused if it would cross + `BUDGET_USD_PER_RUN` or the rolling `BUDGET_USD_PER_DAY` — so budgets are never overshot. Not + `claude -p --max-budget-usd`: that aborts *after* the tokens are spent and discards the result + (measured: $0.26 for nothing). Every run ends with a one-line usage report. +- **Three cost levers, measured on one session — $0.057 → $0.010, same summary content:** + `alwaysThinkingEnabled=false` (extended thinking was ~85% of output tokens), + `DISABLE_PROMPT_CACHING=1` (the CLI writes each prompt to the 1-hour cache at 2× input price, and + a one-shot call never reads it back), and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` (otherwise a + hidden side request re-reads the entire prompt, doubling billed input). A whole ~110-session + backlog is about $2 at list price with all three. +- **Summaries are cached until their note is written**, so a vault outage never pays for the same + summary twice, and `--prefetch` can do the summarizing while the vault is unreachable. - **It shares your plan's usage limits.** A first backfill of ~100 sessions in parallel hit a session limit within minutes — which also limits *interactive* use. So: one job at a time, `MAX_PER_RUN` sessions per run (newest first), and the run **stops at the first usage-limit diff --git a/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc b/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d91fad68668cb49a985b326c0f151c4771927750 GIT binary patch literal 61341 zcmc${3s@Xieka=154!1Y-pyM=%|n8gcnF~voXFqUyMDIj3&w5siyIOJ7ll-?(CO%cfW7tkrT(7 z-Q@oMr@E>eRA}}#&CK4GHVb=O+pO#z z*A~a#@on+!ozRxR-id9A?48t>#NNqm$?TodW@GQv@H;L1PH#(hS^Kuj)sDRTDk6T_ z_jcIRmgUOg^*)P6!yCwL%jR8t%q#k~4Q>r@o{?J~dGnH(Pf4{yNmcQbR6CVayPlG2w~}hlQ&R0!Qtf+6s!Ao*{%&oL zu`Q49<*P92`Rlg#z|;Gxtzg}p2Y*G*&HSOZE%^R3_)~pC97e@UG&|w;kXwv>oKH@a_102(w$@FJg8-!+ZD+gjD1GJl==# zcH&)wcPHMpcz5Al=Q_gkmx?qR!GyZj$?KofjOa#m4zIHuoz>Ov9OhjmgPk5X?{*H9 zct!@g>?I|3&+z5TPQmSS*=tw|d)MH=RhQs#dfkHq9=m(MJ7{+f*iVM@*{?c>`@N-R z^J$lp_sDr_26>m=D>w%{U4nba>#-NTSXL^1v1~{f>~nQ_J!Ms4-@&pfkIUmh@dr!$ zJc9%M4t#*OOUX-}ZZq%nI_<9OLxY0XQ%v>YT?5ErcR9OyS5>drF1UCn%C>u4U4n~J zE6qXGz4mKvZ!ZcrpB!=xR5z4#4PG8XyE^+__Qu8&cGmzuG>8V;i(I8WrS?X**M(q5 zF{{os0Y5$V!2y?jV9@Kb54nU@-)22yR=UO8D+~_z^xD0>D0{HW*>68x*W6;Sezw6r z)IZ$g9w@apd(lj%z}x%Xoq|&sLD~Hy=t+CH>1)Qu<8`88YoC0(2V>=tdTelbfZ9AT zXdiHSuMG;9)-8($`+P|WFN~B3!vpP7srjphFFYm~JNE!O^RjEe>+E-|SEBne4NpmU zRyp1Fy0g!oJl#_2a}W7f%^)_!W^>KpK)1VRSYXo%9eEjT=oSVqvkr3&Ty+bB1N6ya zd$+T{-#yS{?{s!uvSaemOk$bK!XJ~TaG4ZbirnU+<8|i_oT+YXtwZk)xIB(hd()t| zm&%DMyu>r)>T-9xyO8KAD&QW#=iR7?yWfTJak(&P)wL(;N-y)J=Fe$^ad07va{v=S zS`JM&>r0k>k#}Ee#4r2ga%(lWbhq?fI=7}v+YZ1>X{pYeCZ|O&<)e0LJ!+Ke z?@3-Kg-J=l-7NR({Q6h5H+ShZUFgkGogBw$B42xYZQIkM>Zs|LUC<1{xj zsn43xIxhUpYZ^yvlg}JkX^*-5$J~L>2~vE_*W+9C0CY z&B>-C4M#g_8%~E}T-V(muO}2M;K1ncx~_XeX3tQ++soEz$l&qf#6XbOiQS&|YXnhr zC}wyF2Z1Z39rT2Z;YA!WU{k*A37Kl^j#RfcwlpK7%gejE=}>YQLb|T*o)Fg^(o^9f z?ox=m>Y+8n_PfW2%6hTylwHP_U*;Jc7P?$z{GgW>zvs!lMdi2*S^5Xj2B|ZIO(@ra zKTifaW?_7GmKk$6Nc=`G#&yUbYC-uPu-=hA7mBftO@sshvMEfr^oMGd*evBI{Up858 z#gE5NT$#%K#I)g|4iTSO;%^-L`jJtS_yXs#qKAIy*in~h{?P^l+)$!2QE)bPw4QGC zrS*EfLzQJ^O`>b!%;1$Ki&iP>d8FvPy}Y>*$jqO(>CmQw`*DLkZC#bxmhbenU$g z1tLi(mOLj;*48zK482bGrQwkE49&ofBPSbc>oCo0*n|niH`leaG}d*TXlQC}scQ~t zE4GJH!u5If^vPp&H7(7wF6bbUa+qWQDP(SLscxy0S{YN?Pf!G{4=e&7tq-q@ouwiD zF?39bD-T&tRG;m5w(fMt>DH!@wtRai`EYCP(Ylt7*5+C{+^64Oy6ef9+Uj#Y{f^S@ zh@pl(d%B^fuA`xexAdMN(p^HyVpM67&)ZRB>N(7wVl|%sCvEkgmt&4H*GT z_X3s;>7Rv1upw(mPhlZ5fOo*U!ycC)&>#r$#n=8X$w^Qkcqb8 z4qDc<*ONye>^;N{MK*9DheBecS_$>|aRz^$dvLJ-TH#qxMsyWyP9u zt8u*XjpR4e@1!qfZVO~?o2d}6IDa9pu>H^l&(`ym2iobyemjcJU%cRf_xa-e?JuCR4xjZxc~oT&UFdX; z9E-FJ)p?vbvZZDB6Fav#0m` z(BQB~Xzz$bx4%#kj_k)8P|tUL@cMaY=aA4I6&5`@jp^i+79`9eUzhKC@Eu_O9q{j{q-rG#r8Fb`$~x^h5?sIUpgbXpmp)*T~!A zr~;VY=b9YakflSK_zfqUnnOCzaHm5T(lf|XhprFl1=mo&hmIJ4ec?Srq7w+G5O@WD zo~>|hXjW1gD3d=PJEj|JSx!j4b#46Ggmm$BWIc)a}Ju=mM8?1F0oyPQ{DPOsnV^jslp>sESaFl^V zw}<`SFsW3dxs-*S8v}xq4#9}2)P9k0pKkiS{u&^NiKai;o`%;!r>0p7TW zhFm;+?PhnurSRqRnixg*_02))pX*{p(cN04dxh*tw+WB`M$3AIubFz0Ex=~$=3 z{ky72sPYYsR{@Xul!2C8N*n{$3t>QQlmI(RBv$sxrD5=6B;et5DoY@!@G)oiZK^6e zBv08QkbFGO9+#iyq@O0RRhN;uj zwxDIpjoKBH^_FGaGEo~eW!|X%)M#2xOuB8HG){4^nimr7Q=Ll*g*e@V363%Sa&pS; z!pXv^+*h|QCFcc_^Mc6*V$$a6Q`6q*ZZV-`DWM{eP!UYnHKt#&#NFCGzFSP*Ioml` z5VRb+QM+tOxK%k``OPX(pZ=8T`7<^>)uCuGhP29-h$*TDY4geyktP}_PecU@uW0mw z@_=3w4n;~Y2S`#il>#3km2=%x3S|bVn$}IF&_9rB|GKFZ8VFJqt(z(~s`jbtrZPvR zBFNe@s^hI{OWvzc+9J4w+H#jCyj(7J!?-L^yMWci^6?C^PVnon1QWk&Xx4;762Gg% z0!eBL>1o#Es2UsuV&@(h8uo@Pyi1~)gB%_*hSy3cmO;Y|mNr}&b_pXP-LTLfiUF#4 zc}Rl1y)I#Zf$PFqjOpjoa2|8#A8RWCqPIKr!XX5N^nIXjGwYJj zNwGRqA*81YcxaEqX&K&z%^g(54kDrm!-)A!{CWO690IcwZ|xi3H*sd_Owdw*d2LR( zkwg&+w3wVEmYs|~kLPM!?fHe=0CduMc_r5G4O-j=abW8SguiNh1# ziSDUt(UK$T_slqF^)pvzPt6?1k0%!@?aJ)ftOb(F&|L(qeK08^|FCG^FpY9Ov=B9F zQ;$fe?-=BTU8|YUw*P<#sIi*Jr0i2r;>fom6P1x#Rd#1yS(c*)zkxS04pAl$U$wN# zvJ*^xJ#QkU%n$xBky4S@{M5V_c{#AWmNj{;S2<7KI$w>Gb9$22Nf9rn02%r>*GZEg zrx}g$#|TNNcOumR8weJ?KZbs@c91WL`8Yl~oFWM+)O#dZE)}_wSuXW-DRMe|&w$dv z9}o9{?J2oZpU9OO&XxL9)=OQ-o=EvU3Il_UNzQfcbw(`l$MPGgM|^;Px^!Jy z>TT5G2UwxzU(tH4-#CDk4E$vhE7an*sIDYs)$yBH&o{1Yq*eAEcV zezRJuquOlYH&F*XtrWR0FRWK9G7-8+r zJ`l{x{8~PbYRPlV&1Zd6poA94b%t-j4A(w6!?D^6*?Q%gHTjw-TtS6&S)dX0oaP*M_?wTeX|C(eX|8dOq9%br zW`~Jb8H_95;pV}}xWObzk?`vXe$1JlK;D1}a*z5Dd0fM>gSUMfmLTy^ z)nHo%hvm8O6wX*DWfV;PzjL=aEQ=C7#3ufY?izPrFT6}OB_x$WT21#k23FnIhO{7% zbDbUnbke=lyQ$MxXN0BP9RYjC*N)0$V01c8ZcB(}q&j06r7#tCJ_9KgO1qCPo%n$A5+_dT#DbF_RL)rfGLE~SK((oGy=}$N1|yZJu%6I-$Ux# zl;a^LCb1-Q-gMk?%+$`+KG-RqYhO;yoaSe*d;wUx@<#1v8#mpkTTZmyj-8C1-ZQ&z zA~u+K;E|@-aBxijP^*hgT|u0A(mb^j9KW&ZhcTMC_*-@3brV~@d4dtpC(PeCG`U3YiQY+TIWL-Cgb8;X?#qRkPo zIA%`0+j_rs_P}CE_57(Hw|><6VDFDQmaVpBe64l7bz*-oZqu0dQ%l_P2K#jSof8Wi z%4SZ7%gEjcPuh6na$45ZuE|RaY30+W?zP@+o!Rxh3uF4R)^W>nX4adjcT%Tb2xe|u z$UHFPovpwBBBIWY$1i7Pzq$F&=IPjA*4Blr12g>WzWalSIyas`Oo3gKyQVUODftU2 zj%i^jxipYmIx`YX-Zy5Xw#9s7|4KsURN-3{iwT9x={b}7m89&cqi?k=CT%6g?a1T^ z*6ZBSMSDjuy<<}U9}_cHQgWue!IXlr+P_FhS>Chv{o(h9#RDf7_dFX&uMp2%n9wgL zrrx$rTBrE8>ShYQcU;`KeKD~dBQ$aD)r9GtGrR8|3?!C+C*d<&+F0F+ee2Ag@7FHc z_f0-GCxE3Kw|#<>>z(%Mf$1X)DO+c*ygPh0sG2OJzp_Wk(jv>X&Vqw;LxL z-%6dnGLv?9yUJKx=Rf8T6taNB-yW7W?R4?H}_Vdgz-<1|_JH?!|#W6?fHT`V{m z%shFcc5FjHp9TubjpKj!<#U`S(YBP3A4tdtmKd8Fvf4nXjZJ;*Ny5yZO>H!3|J;<) zxXJM6IUL+o2MIPR348lK(EW9>H+EzQ-lsDuBXqxAIEK7L@L~_I1)QKt1C-&p2n+#Y z4ILnOh)Cm!_gbW1fRXfm4Yp7Pf_<5bK&E}viZu_uNUdM<_?7%JR0i?~`zL>w(+uSQ zijp;I(2(4gNV$NX^${rlr6_?I`CA|$>jsh__(I^{nuj;^DAaynz+C%d^a;ZFm{B8e zI)iGSXmS#ffYehd_=bcH67-Jp(OZOnsFoW|S|Xy68F)vmr!vyIb@C}Na6~*uX?r%v zPAV`yABWju;u*#qnHNg0#;)@v1t*TQseKn(WConB*1HOR3|KxD_)p?G>5_P}Tw@&L z$$m4Rz+iP9pOQuMQBP3!Xza~BdW}D}Pl5g;gDKAoeCH3=sk=>X9qLZFxB3_;*fF1W z6|4t79r#}cFs_(BI_%dxehX@w>CKgcpI$fl`vjuMe$>P0xA5r%EV4Elff3r!${2>B zHUL9a`>y(o%aTh)3y(lpD_^cxUxDn4yrWj%mPn}V1NNT%&IWl*`W$k2g~#r)2$P~NsgEH7{8IXtIc_T6n^=Jver1l!T%?ETu*pZ zIk3)aSbOIH>?1~db9)$D%JVf!P&MR)?S*wrM%YgB!iDydgRA6oJxo}p)ZRiMCh!&e zU^nDEUHyCs5n=7T;O_R8`t&5Qy6TKjK-^k%p911$VBY^qWP`0PE)4c&MZjKPGO(M7 zOf{=m6C_K4pYcrO4UjQ|hq-}hj6wo~9jm}Rr@KQEj8;J?L{!+b0geOUG`AisyK$IBCOZx?Fb`o z5XF;y5v)RjWxs`*tm;`JvH$;o#tFCZU15TpNjU$K9$umR#c;wX8-VEt)jzHKNu7B5 z?4O+w+q;5|U2AYQ{%heo$N(u~uNUI$B;xD|B|!>O0NUna!i0qP`r^#zBPr}*1^~@e zpRT>q?p{IX5kri)&H^C_z{m}~!(clzj9DP4U-&I@2Iy=0;bAx5f+-g3fs{5$(|9D3 zzZsI!Bd zvCTbnggkm+!rqXH8o)3lT1>+4kn=q_#G-fdl2p*|QsBQP=MXszl}aQZiS;l%DrD?+ zdL%(3Pp`8)WZ;J{4|zb-AnJxI6x-{%&bxa+b#Pe0>JYj@oKI@3o;HS%4m|k~*CpIX zc_Bj=$;=BEsq7{S;GC>G*TJ#U)EA!+MAkpYpXWCiqV<{4{2RVs^G%#tFlMjB*=}`= zcT6=0<8mKD(zAH_)JjS^mfm#ry<>NeeXnsbqg8zFd@$w0Sna1N=__e=G5?wQlzD@g zdnA~4R7^aI;1u$ZDX}xL87ym75JqU5U?bD^k&JOlGn?E30uLQ zG+QBmn!0IP{}#WPx^28=*7knJdl_?O4|W90n;vOW4HjT+>6ugIW5Z*H<)q}>sgtP_ zF9nkd7m^Ool)t;{{;t`q;I=CKn%6$kSYivts-y6Ug_JGRhgpr^I5vId-tgUFuyYFc z{J^%j;h=)>@N?C3?hhIl?R+qu2R;)qs9zI7S6(6&36%!)5)nmuMy_;;(*j>qZ#EY5H|-RQgpPSv@= zVLMZ0p>4j)?Qz*J4GdoEckw;0l1?XnLi_}p5VG0~N&Pujoe1_$sFt`~0|+JxAX7Y% zWL-lwOZ2 zp{sY$jq=eG=zSWRYrP|swwqMDqS6VjVULrPw^&56y=TyiN@EO!K}?Mx`ZxTF_&DO&dw6%^$rd}nTS%ETM^>|bAE(%4C;Yd!d4ekLc+|31f-|b84q$b ztx1Sx(lR5>Hd+*QE=Xg`jBAYyCxN_s~Sc;&GbUO2fQy4WTavlqC(tJj}Xl zy?}#D?I(x5kUGHJheDmbbGW}BlD%vPs6=l%`_Y%^b6ON=6ec_=*mMzup;!NZDJwL0ZR2JVC~$Pag6-AXEZrS8wbDC)vtsysT$$2}sb z;kL%5Y2%=n4LNBjV#k8OqHojlW~eCZp(1JEt-JvtF{G>vh5emyjgqZk5-h_87 zR3MW=F^#n43BI;!MxZSr-8(d{3-Ybh=d=NgV9VlN{b*Q!f2F+OX*$wOFM&iWD%~~c z@s`@F`_b#Jf$l-5ka*CIDB0^N?FtifSnY{nk9Kg6>M`IDI8(S3YQk1gY$Fv$znVZ9 z*iMX%_I9{j`Q2xh`Yzgqte%Br^*X}+s)-W)LOD7@@#XwTrI4~Bs3}tE*Q8R|Tiz7; zTYV$}zUEO-PfZ8vAU(VRU$H8`V(<%Og>YQVbx1aKC?qcU6)F7@jlVIM+-N;rQdm4RP05{^XS8N7sQa;R#I1~1gW2SFOFKk%>&-JtdZ5{6D>hct{K zXrGSA{#a?tzygJq6U1gf?kCqo$!R7)sfm1;F91i$3r?7k-qG-ghaz~Feq&8^Dj_#JO%8W ztYJAmaZCrW{&vG;!;-BqU@HvT980#H z0o%@?ZTBxUF|j#gwO}#DWv(F6vB_iLi`WV#2%t|)9UFa1d(U#$vSi z(0+K{6|^5;PE27Osq*i{J&Z#s592k7DP!ip`!W`oz*0(KAf*sIfjHt#fF}@_`Pf5J zZy#Aw4{y|dyfNi)h2i5J9NbkCTiK~UL)ulM!O{CU;^=h;(%7m|M5WcjGf36FeXqSX6F3)!4CPsz1FA=+h|B4Sq?~4+-Yjb$20jeMH zcCb0BLh8z=8JLR#BtlH`(HZANIb+#nfx`xt-3%8@aRic&e*K6V_VT7&&S4Vhd=UN` zPDlqF2)u7B5g#Bg2v>T$B+&(&|3oqX1T6eFBm|?H7K`xT$@wpER`IDrmZ)0#lAo-3 zE>*J@*|47{Cf~M9T5cyyCP?%9Uc%jkVB+@g?|8rJy{dWFVnt&hv2o0>l8}sQ&+cTQ zhG4~!#pI*GgrlPAs3eRSJtt|-ur2u_zO&wzybAp?J9EuYsT&Oork>-z61MBkUWSg>{IpG?+f^;5R)Nb$VF;34*y zQd^ak!fKlvRa+Bkq>?UNTkU!^8;My{JC1YnfW zE$1)yMTy2wF`fSKBWC5*9EH3HRx$$#f zXmqg+9GwWGgfzJv=ZLX^=x}kPpd5Fe?(ek8Ov!&zW9w8$Rumjc-i3%OARKY z(|paN26q8TjD1JpWU}o=t+eztMd2r}@NmJ^hb*xgH`S}~ar=nbvgYwCy~tas|KvUe zADR7&-t?|>eK~8g<0l`-WE-^+#K#Xw=05wADgAo6N5eUkR6QLp+m?|hcy>33q>Xd!hB_k4Jtj7Vj35Dk1mYo97?T?WB`;)UdJ#}S zAbOj`amwxj=LB+e#JT`&0@xF17~vG5bSaWZJF6q&fP_WTVM`<+I;1B95+MWIxCNd< zVx)ACq0wksfVDxbnQ%jJwwUFLj=@X90-^-sXECWbmQAo?|8t~7U<%=HDE@EB`3g=b zHUf4t#s=Gv4^#9qMH?<(;@yI9m-!iiR)XYzLMc8Yhi%^fk$nFT9EULsw)Zk8j9%U%Pa~uvS*rk#Z^zB2Fhu+$8 zZ~xu>_YU1X6ilgDN;wcnIWRvWrW^>SoD$DJFJ2m2PTxS%P18BS^z94jyJoBBHh<{& zz#-N;gO!~Nl|5p2Pav&#IWy!ETrk5|I@18w1 zU;Bko7k7*sqrE*oSvp6Iu|><)#9IyH4NKMy0qce(YhJ*bH?woDK(yv9S`RHJrQXh* z%)Fg5nKP{mCKceEh%24p!pd83=-)KoF;ADj9yg;8WE3wZmV503=y{)um#m}>d0bJM2+)-B&UK_SP#aeS(L>bX0W0qf>(9jB1SZ!}I-Pxan89Da;Yhw#7*AwgU4t$iToKuRPkupF-gtOcPVRTz!pXtPzpPWTwa z84;%je<`9fELAL0slilcKzCMP7M3PoBAbw)Hv+l=FjHyw#YfT9KwNyx75iey>{1y; zVc~_zY{m8h;eVpFzMN=co9oK3`>Nd!76RZWf^Q0hzo$e7qSzG({{wz3>(B5KlyxF4 zn+SBnv1JpjV;TbzBxuA@tATzVpw%FtDB!fodDA=Z9lU!`%-b2+=(;d15+WOxYrCshw|Gs%s6@wTfrX2J6m=ZS7+1 zMe$;n$oDSso&fI=z1PI9>wt2Sd<5UbzQ8fS9#|1=%uvU2cX@^5@Ir^{zu@Id=Dnpd zLXXa9B7_BPAR*GnW+F|Vr!1uM#juo5kw@5o)(GT-j_s0E7SI4Gh((6suuBIIyyq;jt*?E!!YO25wdo4 zTme%c9AoV0;0L=pI)wjBA0z}zfeaW=*taSL%3gA?k<#|JSc=7?h&1qtEtN)T0q19 zfM8#9HPPRh#WDc!1bXu_bWL-cEYTsH%;$jxzzFOFyc40Falo#DcLUZHvCKfH#GJnD z_Leq5pxjRFBeYP+BZu(VwaumPvq~IBBqPbZf@#q%bqGcNQ(6;>KQ#>FWeVKyyU>C< zf_FAhMtrEK>JdiLi(1<27%9Aa7;F^qu?U#~b7Z)m2iL{jzq*NQ+F#lv5LU3ZL4_1X z4uqj14eFK}MBzS|TyQ(^ar+495_Yn`AZbF_{Za+}U_f31z20$%^<+3y@?W9$`2id7 z$lvKi%eq{C;>CGAho~qZWR61@_*I>zLoec4hrwXxbzmdt8G}L37z}Y>F~IgKV=(B! zV2BGd7!tw^2CXA8q=UH}=Zk1f%e6sfyN-l=m~}tJz8`ek?M1S&s!Dq=Ko+nkR~u0r zlx7jJP=P}ZUK?P8MKcYOI$_o!F&82EK@@PabkLW;ia<%Y(@J2ekaRSNXGeod^6VvK z3j}2BN`|J=zKhP1cphP9pTs1lDLdf07G=sY`~hYL@=2SHGCN4ZN|{MXX|BlpWYhpg z45=A%2x=kKnwUjScO{A#u|H{_u?(C&FPS?S0*i_8 z>{BECz8#K4O$dZzkIZ>Nm_kQHn1Rj;gej+XRG4x`i_gA=uaOJ z5Y{|if+X3ahUBOULvp;4YV~%w7v(o{avMH{xt{>ZzpB;uES*g!1rSR)AE2+`VvCPO=C1e0uF z_7XQQR`OxT6jo3MX$N7;VNg@ZNES-5D@n+h31`k=|5X>%_B_4AUN5%mZF?(b+VedN@%dIVHcoYkdAkESdx9Bz=PCjjQ2e3{3+y`y)JE)_r}Yxk^Yt}3vnK3LEY zOg}bOd$XR*A@2S5UM3K?Z!UkXKTvsG+}HS^TFh+K*O7#{0&l zqRK!~_5J-no)6IIc0sWP~ zOGKs98^&tCRsZwk4dSMX+1mFT-)j^rYQ&AT!Q?v8TE`%pE@jupSkW25&Y`RD5Pqk2 z0!4)>WIbBT@;Rc0_xVsVYCHGlJr(SEbf^cRcudb=J~hK55G_F14hJCW z&k=w*%tWY|aB0YV*?B!oZt+m727}#zfwWz7`VZqihzq3EJur){=ayQ#0{CCw6-es}B=Dk%f3pARJY@a%F+A&W zI@FlY=o#w!HIGCrmG?n;=VXLJH5^$|2uem-%6S_pkjj$xc(RYAMyOr=J&Xldy0kq4 zd!YnC<3EM=IUp&>i zbgDCO3V5ztyfi3Yc_Dbpk8o*VFc8X<03REe-$OUAH!x(l1|6eHw%|dU05b@;5%94Q zuuGLx0g)fK!Vu-Hey%5eR2Sj-dlkS(4Y&f#9`{bXJmjPJ>Zrj>wPz2n9J9>+2FNj^ zF_0%v=%alK>$F2@sa!W`J89HFK{YKIFF_>iL7rmSA9?fH{04b$sLxct zd7XMG{rfg;jd-w)sQ0d-XXANxofL|Ojd19nF;*&I;S*y8n-zi?noi7KL^O~HEwmhh zD#-M6vnC2;RBww?Q;Y^E;h4QDybyVY@F`>@4(n|v>mWFNdt_ha9pRh^ncjVJICPEZ zG+FbkZ5Pmk19FnS-Lfz8=Itmwy#J6*n1LK`wH&KuIjC0il4`a(ayh@;wFM1tqGl_owxy)Qi0B4eh9j*^n^H5<>W!hq zP`&2CH({5C&mA>Du4q)w<=EWh)0PbRj3p(E8{*4YkBUG^$WDNVSwZlnl)%y<0Az2; z<*p$ZKO{1lFQKGF;-i#=WsQCZmGGI7)aCU8=l6tCo&6w-d0HS3dkFz`10>4JAEBIK zvx<@>3xUQD>Md!t4b9buVM-ZhlTSR`(os`gQ{T{ZG*lF}rd$JK%&?>^rD;0ZRM*^G z*VNKb-PqA`y86hGhMLdm&`I(UGa~ z#lL6f3QcZL2a|gxf-A|v3Pc(W#d|?=>Sq#wB-;Y`h~!3FiPX1>s!!i%GBv*qk0Mip z$@7nwsaeNrg#sT+n;~u#z!%m@t0*}}rwuEq9fhopp4nzFz@7)O)mozXZZ^hRI}I{b zLnJDc!9r`o%($qS)#5#*9r~nr58+;=3V5R;*)u@^%sJ#`l0A^-+R?`?J3GOt-?{7k zUGMFh+Ze1kx=_*hpj9v}pTgqjdxxWwfv-{^Lh0+^dQN6zq~(rI{Ah ztx`7$r>UW|CCRY#Yw-JiW&eDevgIMEB>A+X4Z0(<<*a9Nf)(`(6(=542P;klcC>vh ze2HYB(&Nr5jSx!?jmcx~kZ_EC94Ch=;Hx0=D{kg+m4@l7B$Gm9Ll^JgxnrMy7yiO) z#1HN%*dE42AsuMx!XC=?JtPAz!tAO_@{RNZOHxu6``0Y`s| zTZrRB+RMYR>*FR#B4$#DSJylf4xtRNsG?WPFoHb4g%Ja4l}IXJ*T=6<*``J$EhcgI zv*PLVVtdDOLV>tt&s^GaLiQUiZ?@fO6CKaYZx)N{gBu&f;}^t>Jk+NWy16mkmobp0 zkqlkWf?{(_OQ+c^(LY z*wHEW_Am8%1HE3+H@cYq;)MR8Nte{dtz>Lq4A`B-Ne*U|O_1#n+=4(BHPT-@D4FOG zx9wltT!k8B<%DgBTnKL3v9M|HZ0AzWGl86Ef;oqSnKcuKzlhO1&vkNN8Z>1)q!ql} z-TS4E_5}zDmGyByFWZ6HEhIP zf^An8ZNhST#_i$B;ib`MG|3=($_J*mmclIyXwyc2GQ2yqwJG-VcUO%*uy>Etp_tO1K!F1TK zncF3%S5N3yQZjEJm^?6TTLhINt7yjdZr1&*w>Jc{c8j*%E9sfUh8J_6gJGA&^oyU` zGFQN^J~n;~S1}x&^iOvN(u;m(bv)dMA^fsZlaMWLEF*(BV)pJ}{2tM?=kLDUrpef_ zl)f#Hz76{<25xz?W0rsa(tDQ{c0W7c^5gR#ofl7^5uuf~>uez7+^f~Zv~1&k`K3i; z%UMb(4x|*5v~BKky8YW^EIBUsv8MpD{-dHT$Evg+Cu};V)&8lr;@CdJpYG+ztx7#^ zGyGW!2X_^9gs^IcME*DI!M{pZ0^m7Pt$n~T*O6s=UiSv1`(RWI?;b60cta7vewsB; zn66MTd&nhXozcS#MZgch^{J?(+C2iOr2>*krI8E4Cm_0rQ$Z&QjD+Bl`3>zm78#Xb zNa)kQt*_CK75kgj&W@c1-?0$1JY8YK>1qI7%>iMlZ^!(%y10oz+ zrj0HcP#}N;QXLj-K_Lr)5wK%H%CZ8mY&VAiFQ4Ptzq|KiJ_^g|(n<{*@X~9-B-3bE( zFl`5IjHSf9Kw{ood+^!H`<38G)dXRSq2R|`KiawsbhGZgy1BC8&SPTx#ijPnKs(6c zSA*@>0{PcP--`f2lb+FjmYy+{323W&@}=n!;u$#t=~WAfRS#3Bsb7{7+?Bl}7+)cp zD!wX2U05Z52@?*stlL$SRZGxPNpbu_V~jh&k*&J4J87?HOczX7Oyw+PmIg9QXWe3E zX)yDdi5M9iOj}GTB5*J*n6({@E!b>>wYuFfaIkTIAZ7o&@j>p#7Fn^X+{_^~OsE>HWh zBdspY@bRI;Cj9v0G(+8{*gwuTl0R=_Lxtgw4^{7|Pci&>&jz>;Qnn!I!9IIKso^J^ z638tzAY_&5mLfK32dO0*a0DQ@N>dN2QvfifC!!z~QM+XdBwzNBb|Nm?cYtryQ^`bnC5)Ns=Yjl_RpeIg2J5$#G`l4!hT>eBVlC5h!>)1a^|4PZ~;u;>7!cW z9jWPe@`^Qbd+*XNmM1XQNklb(W%a==tyd$@o$TM^_ zj!(tPXhAQ>`{UI5&%XwBAbWhb61t!pjM)N^b2K5sC-N(O-S-rr82YyxBM6RKZn<`t zD?4CnKs%b?kMqZqxXR5l0PS(oNl#Q>)w~-=H`Oar}mh4V2 z%?6*IB&(ryW;VO$EQmV{K#|#s;3X>&(v`jJ9_FW5t0*Q3ZzvqN1Qg^mn!yDqfhXlT z2)KaqZ-aIFl%n#G#o*luG;O50DO*fto;i1z_sJ-J^=t5or9AQ4x@30m{i1K|tYy z1rMmBNLG{?mxGzYa@g37QA|c6WT~ldZ93jjQ(t|$ncb!#S*RMq$3n5Lf#J&pp1Xu< zg1oF!+U9f3Ep;a()4?&}A4Fng0APq~X5e0w2KNPuBl;vW9Sj2oKPUeTIm}p9Ojj>t zm_Q)y8Wvz(02e{ig?j?bTe$@hsX_*3%Oeycf$)F_o!2B=S3jjPHc%4ox+f8D`vR>ZEwS+T^AdB3^uwHFpFdAI|0s&k@d>(W9>`FmZccTjl`wgIx&7?Y6~-! z)CFt4i`H#F&)qt+JD9rz@L%fAPct{tWvAdFq-EVcH+k+2|IG00skx;0T4#^W?fj8$ z{>Tq4bE6Mn#jRO9)gqRVeXdVZF91|a-8ohVqGRgL&r;4Ur`VTLHV0BR!yHpeFJvMxL|zgm~O=yPl_)S?&*}bI`8p!`Ji>%824#H z;!1Lk2=ZdZoOfO~w{NaQ+*BV-ZV;^vD=4LJyl*OrE;^h}dOa%`UjRC8(s5AdVp}+v z@haQ#?zQ{ZX0N>MTPQm;clu|k%`-_qf`zRg9{F+OM~#2n^jh~D&B%D>I|B=;&0{qa z+&7M(hRFN#%uR3UrfW!QYo=jV2o_WZ?fZk7Rk$v#VLToZQEzm~*CS%!7V_FbKkR+4 zckWtn_fh4Fx=*+GHFJ7D_%SRoJPRTypZrVN(A8nX0CtpVj0LeP1Bd zOL&uxksk0hP{u@ za@mHRUsh%sQlwHSZx^yD50)-H+#@1AMQJ_YnlISM(Ln@9va4f+@H>RwNA$Rmz;K4H zRbB;g6C%z?wP#r|GMueygkND&Le2oyk)DW*4-sqS(-bKb_#9?l^i6^t$%PVR6LR4L z8DuZ~z;$7OiHSoBgp>>a3302KXw=KTcvf#k266={Ssy|s%9bu>Yz^49E!lPiY&&Kv zg0?-7Rg490HI*-J+8s>6&u^KYlyibCfYk2aMq&RP&0j#j#CKft06yfrN?0F#WzC}s z&ni+-fQ509tD4rYLNWv?9N$(9>O_hs;uJ|=f#4h?lR_a+r6f?3*HX1It>caFnB?%#& zvXJiC))rw9W9Eya3nI(N*cuNRU}1=+j{xU$g4zV)tw9*_8x-&p1bqJIaC~M~iXCOx zea1?O>;y9`KFa$7Iev0}7Y?)6?*W&P)KOd#X^G9yQHli95|ld+JMP2wS0ssGqJ|0o z8x=xSo3-{=bl}JPVx`Fp?`KHo`55Uyl8Lw78Wst(Eh;maA&!Ykyq-QT=?SSlP6^ul@^z zwwXIdO7;nxA8B+6$B8irRZQEhi{lrU;_?G=`O`JC$zoi7FmC5VjTTz?b5Qb6WZcJ> z0tux=Q)pwCW<57|>cg`ioSi!+<{u5F)r*PsC^fO{D;;I}ie>$Rxssaw#|c|&bh;nv z3^mr(+K_-erN#C?(Bl8Kiw#p=S`aErjTVHPdq*!%vQb#7_!Y~6YP#mZwr0SB3Qtrl zhbXHpYRfP}jsW4oNdM%#oaQB+8vi7sqz+91Ivv@=TV+(>Wp2dy1ZQH4!kLgWsB);( z7OvMOXNjPk-8#_Di@`UGe4h|}^(`(oqB0K-m>GnP@Ma&q$ay2E`o>ZHi~4JvLLZOw zovtC%6A`l;q%9;vrN9kfSQ17x2BImzc4^T{)-{;so+nz*%~L8JD)2K8~qUa(`OEEH}HRsf<2!j5jRV#M4l6 zy|~oY8EESi&vl8<^5Q|47+(;K?;g`V)aqkB+)7e9scJ7J6$X+DXLPft#iYVu(q6#E zaRqbLE7Elt(@C%TC-kJLT>zLqEZvZ>m{7C=Df^Aj1(VN;*0Z#qIKGOdG?e+5KTk}f z8#9&?HwO|ozvch_b7H~)f_m{ylFfex%Db5#WzJh3?D+E@@eEkyPH651n_S|FZgFEz zFu7M85Jao+OM-SipjDQkwIAjlF4TNvPdl8g`)Icb{*SW_hYO4!+l}NeEUMYB`(Z+1 z&2HTfcN^eu60*=w5HLH8yb|omc6A1iGN3RA@v^ck`H^-FfXHCO-$Rt}Z^>B$41SNo z$oUKWc~bD1S2T~bT0ng!qoINOGC9_8PEsOfseCEtFI*_A+YzXW;)U}U+S|8v z5#T023fXiF1(H^rD)^=V86Z5=L1!+!qd|Bxx~y=SYQ)m094fo5nl8D#gFO|X0SOes zq_8^lbaJ8^@d==i0&@1LaL#3*Nv;P#4n6p-3Mo$Rzz9b+EK>(za@>U+Lz^K$IwJ#Y zjM(cyu%wF<5TYkiAPI}|xWtZUnrDc$uvjb8pO0&jjBewC6R@RFk}v~b$VBo+pnvc} z3579`cmxqXEi|aEA?S1JT%X=idYOkj65~t#6{Y-Za{h*#za{4@auTU*MzPRC1FDwY zwky#xh$euENi7KJL8aj()P_hdAw6uFg74+&AMRnoa}wzwOfeveLkHbdD*O=&CzhS( z17rfM4RMtE@%oA9g4T@-))Q|f-^;$6eQ(p_f&%suhI zBR+dtv^3LIxzf#@wYTdh>!+HhHwSG+OSaO0tu$!c9<)F&XLg5Z*}Y_`3RtRwmV*nH z#`&$t7N0ax8i>yo#dm9QyC!$j?2iF;GmcR=84Z2ZNMbPf zmZU1mNt4r0W*{&`Yad>z{W|fA4WD)56;l!G#48l|b>fw~{rozwvRSBY5o%jQR1Ot9 zM@C2bf69h|v5CE94)Z(;!aR>y66fIK+4xxeu~_;E zPp9ffZY2a@q{Db_!FcLMasLeQEug_dx+dX~Bw!W4lY0ylhtL$7BWm|;$eS9rDji9l z_i(7KB|MX$pE7EJ!Y2t>Xz|5ZHiImTPhw@XeC#^xa<@28^f|wzpvv6@R{>v7^T$c` z=+Vh>QPg}YJAqI4C#)(vfy!naDC^BKaG~*lxH-0 zGYhz{UWnnd+2)F0ST%`Mx;(m&^UqkPoDHkXiR9ons%g>q72ulRNM8uk*tMfEVG27N zC575=Nuo=L!IzHBcXis`-#Cc)Rt|a`g&A0ok2kqx`rK}_E0!eK9 zv3kTTf6P5U%!u0`zaM47!2Y~1LrO`P z#zgnI{ds29350fbY(7IxttO|Fob^u8kO}G!-6RAokThMCSP4TR?ok+>u_$&1uMp=E z=Fyt4nlLV?=peK7!bcQFi<9+tNZWmx5$KteO^Ac~FkQb5t_0EY0gX#oz;7edQ|R|V zNZ1V_3rP0&l(Ys=8;;`bJKs0GXPQg>e!|V3Tm9qxQ#+>(clHP43zy=H7vhWOTW%c1 zm3tH2qGjVu^-TBuhM(D6r?1Q<&F_71Mm*fIu&?EJk{0Z(0ZS|H4NTlTn>O!#&=@#+ z4%bLb?3v7(F+!!&^nqzU_1`6k&Ce}0pAR&j7ccO^X1dGZ_TI_8Q%7N!6Oy*q?_Zyb z36>ptQ2W#RpVW)37lVx*uod-A-n-lGZzE%YKgj!V+Xve|Ec>7=xb@i5*5<(0X0fF= zxYZ4jjV~-Q=9n9YVf8gWW$ftOjt}>Ku=n3pE~T} zTy8k_VBb$REHykIXm~!@(7DjiC-%6-?!KSxzBFGwmy9beDt`a^!tP7p0L1lkH|ieh zxy*AM7#xO1ZrnO%oJjf1VjXM#=5D%4qI0Td+Bh{bRW`FjOf8+Sp6~kT$j{1;&s~{4 zO!5-HU$jtuJdg_GQpd;Zz|TMtKz`A@Og$3l_lk`d#CDf>p_`0v9qWatb0Br}6Vr>I$$238J9{Cp{jgY8 zgF7lx>lRH%NIjb_a&HY zS7WKy$9!bf!~Ic$wLaGLqYWG2{_$pOeL~Eiq*&{1<}S=<Rr)e@V`NAcyeA zC~hUeT1<2hQj0$)|6w>P)Oa?c3N=0hv%=Gfl%Qg&*f_7nJ(NKk88DAsS#7R--+>P^ zKge9#*AUp(u(31j4W;15!kdNxC!Q^cY%HnW1r3Rl0bP(8s(uuVpE9_ z@b%P{)uxr27CF?K$pEK3%Ly@1J{0k&Va5?LBBomu8%Ztuo<8mHB^5vy#C+}@gG^hK zrvMWyNRrEqywz(P$ptn{46Xr=Lr3A3G%qf6rq08O9s}eMv@YR~=x3}nTbALM>{eLl zhLVo(y-;Rm*sy~r?@_1Zzo*16vBD%{13Hu*J#T<&IFz8fRt_!HvFo{3`>Zh=!j*B>arW9>UVTW#6s)L&G6s6Lx^Z z_$`sY{*bB@Bf%?-3ig)>kT|#sJ!~Bs7_~$)1h<9;wn!1=q*JAdyF@fFouP$$WY!xt zeQDJhGT7L$M5}B-5m3ekkZ2Rp#|Jo{MEgR4YoDhLRYb9!Y_Rh>@C&G)@qm6$rVI?E z;{gG-$bk?oz!nR%%}9c3J!UYuWa2Hi>s9B4vupl;EXrz*Pw43H_@0X4=T z)0_ymXB0FiHDnnmqc06f2%Iu#yV+(dz^0ECZeSrsB9v&;`p2;tmO#xft9s`{OmJ9+0p z-$wPihof25X@G8M#O9Wz=Cgt3v*NkVU~|_(vm5a94N1D81set1p&)5bn6FFt6V$+G z^Fj}y(w-N_@>u`mN%&@3zP(1$ER9CoJieW)5INl8b=t+Q+9C#wy>rAKE^08$$bpLa zKv$_R-*2x)3doMM5KR^DXC+J-zGj9++x@5M)}GA{qoB%8Grqd3q9l(1hpZrG=U_sMNra^_rQUm_FyQeKXeE?jKK@)s7CA` zqLVN5A4l#-`gLFUOP zok%z`DxNtX5dIrArgun*=}(_zqh*v@64|ELf^locyA*zh`}ye0t3(B$%$kD_E#7ha z#LW|v=LGqBVqeRFlc2F77PQ`%fQZ@x8{1pymE;8Ac zmp_9?c|>L`6HsIxx5{Gx;Us|m$fnxp!mvl9D!1|b*KZS+pmoq(y{MYyr0t$YSZEz# zgk=RPk0UHHfXVilyWnGvO92`A0GK)wo3x%Hu?gtR2As&CGs6GEWCkk`OSQEdgrU1N z9m_J1K_%0aW+i>91OURC0q!S;cMU%_jjr63T)8PtV$eBJ7{tqd5{_M6~GFbEan+1a*IPEW+<`-3q!r0Xq7nxGBKdE$waNP2(uciVE=Uii;YP^O{lx z%_-}Ka3g+9v5HrGH+=CeDR{dq+%#g{Eb^Nrl&QQq^hR7611S;y|L6CUO~NjP8&fU& zv#}b07Fk#d`D2j3&TP_kxedUDjw!$RKXE71zrvmL>`n&cFUOoCi#U(piel7&nNM$O zin5S3n}ex5cA-KwXvh}#23OCP`&9qRSIe_=$#!}t#!;%akZWor99ZvLv%RCN*7z_T zQSGq6_E}Gm#QBjeW8{^OoWiau=axr_estMFVrI)T9^$(c3$06b*#FP_>K)*@fnO1A zKq82A*t0;|BMu2U*!9o{Fy*LXk><#7QN`Ji0duxkzDE5UGQXAPuVCE|c+Em;!Vx;; z0A#ekrbB2p1iMxpR^i`4XPitsp;(&XX&q>(J+WNvS+0c~@JAq`h>gu|tyIq}@dlJY zoNVS+WuBGl`1&03Q-(}<9(~q%QoI8mb6$b#&^M-M6#Ad^_%iPb*1}ChPM-G@t256^ zb<#J&DOxdGlIFpeE2Y~*)?|uz{fP^Wh5wD43Gbo^>e-?pi3k`%l9Ue!o{0>o$jljZ zrF8@dBF(~OOA$Ze5U`FmnACY$#e)0~7Gx=-M3|IqfoVQ2;9g)733y4}T9K*~0Xh*8 z6S4;aR1)?gDLPy`pQNGHQSpcfi%BMaMkj`-_%Bra0~H?Xf}m1Xu@*DRr9hm*eJXxV z?HI4kFd1pO1(LA~%TU1fj}40O8C*$EGvr0=Hd7-&3&55bU3d2>;Wu=Y@S4OOlX^gU zmLJmzVo-&DLNRXi3m01{3d#dI^pA815kxwkmALHhVE=2B0Hpn)I4S~Z0M^iY^-EM> z#AEYN^5a`>vJ)RIoR=(w^JKp*Ie%TMaDB@5#Hg(~X=@&uk+f}3FKuxh1@d^+ma@(n zwJu6p7pdBJ(Nf!g?my1>$;@|W4xSus-j{6NmulXhT6JJ_RY!7FM{3nE2-?N26XL3q z;>vCjz{@0%#5@3r`N!_Oa03CY7q^TVZI?qYgkF434qnmw%JVm#PtB-G*?dv%W1Ewi z_E)5Avwsk7nC1ebUSAzueSJN0 z1C;|^TFVHxIer2$T7F!-Rjirru2Fp=a3zG*&A!`tnDvI zSy$saqa#*5Y@9ikQ`%S2m*00zboxY-@4nG7VY0_eh{0lrAMfkw>l7UeMobHl0V!e~ z5_OiJ@ZgNat8txmymz@-n^a6KD2eTRqcY)7E+Op9T zc^$jrjA$dyP8d|aXu4!j?Ygr`zt!*>VC1JI90ejx-;DxQgh)4SYS(s=HyD&_mrPoU zEoYI(qy7yUNGJb&?yn7hf$$CA(_SlJUVx^IaVV1RjH{H@2ZEPv=-(1Dha_VhW|MKr zYQ{Q|O#mT3HTl!2K8tLXajVjM_%K3~?l-(P%_f@k&>3yatc@jPgI*i~L8h(QI=_`^ z!gbfS^BN>dWB+BWI?#BB^qpAEC@=;5x=iPhc_*uZrWOX83$Y68!)2sTJkcX~?Xb~v`=l09Q4 zfNp4YW>y4zk5_JYqD-!-e=$!=^iHf!h|bUEiC4z;8+lx^@#t4fU~i{WxjDBn7r1t{ zbB%_c1^N_jst=WywXbNRS&ic4uRzHCdK(|P9a*kxHBqE<`3u+#;7t*q$Trnxg@vJT zb96_md{+G{_rz(wt&yKhVyg_0#0S7A^^9xVJI5Ujl=y(cN=bI0ET+|$=m*IRJ9-d^ zj5`k%Gg)zgv5}%ihr^Nym+@F{8=Uq0!9uJ!ttTKdQMs=4sa9G=HlerAAu9$IT@zh% zZQX=Bchp^;beE^xb41%5vh{S|_vFRS#a2YNem>zUdEF^G7ylmOVt3jW?Am7NL?IV2 zyB`LK2ED=0*W4|wv+D%HTYv#piZo1dupt`?>WP4PgGJsdq&~=${?HE68}#^gQ!dNS z?%u%hp#IEB$T;A@xL|$Smr>tbx2Z)k)Fl>_>D+C24xc+6%DV}f+j|5Z4vo8aGqZ#3 z2y)nTp`*PQ&WX$y2$GrJqsZMcZfgr5-XBXw06EPTkgRdzt}UCkZ*OW8zQCRoLyYE| z$-u`A$iR))A@J0@x#>cC$63l^fR}MGARNR6GA!WfX$2!S)1EmwZlEL^WTnBscp(qFCW1fQ8x`~;y`?yz|V@+eTN?}NxS03la z3JPzVZkk@Rz(^pk{L9aO<;ffMR3tqWIB(Cp@5-I<%%tjGZo-up*T*~JPxsaLo$G5C z^Q+(S{HW;dqQRPbu8l*L;_f{^pZ8CdWNeJa4?insLt}IJFmMc?&U<=&(|p}~mg0tD z!+UeeP=DW4r)%)?A6T{6`Jk9if8cdC`HUYds%R`Qe(0ei9~R`}$cGE5&4-IkR9`u_ zslxcv*>v`&6?z=`sZU4s`TC~i#&(4W;hsct7!=rsZ-HQdA3hM-OaxU2e=u$aVr~R@ zQh;&bvG}70X`?(-v;Z=Vfn*13V&+-q*Oa9f*Xj}CRk;QX&ey2MWO#t9v_h?V75oUa zWLTz>)NJlkh^2)Ooi?zVMyUoiDGQ+JQQ(4l$FIL4#0fAER9j!fQkJ>rb8HhRm`!l5 z#0SA$*ALHVByVB-khe;5RDgZ&nO@?Le8F&vZJYi zpp5Y}jEWUvxI!MCCmJ=*BLiN~xV33tE8Lj36Fo(1(!dF%#TN>x_#-tm3mvE0kklMz zN(A{?_uQ<(_HT1Z2ymsOhER5!0**-gtTA z3mb1#Mt0oez40~_l7J2)R5nG6cmYQ<1-v1Jv8F7w*<#SkOZX*Np9gFzKvj)yIk>a= zwSZ&)BW-NS1B5HT(rn*#HmHzsh4L*cX;kWY_Xbq3XeFr*kA zL)(ayqN;JN-~1w&0##EhKc^qL>iij@HT<^6w6mj~Bcng(W7@fp`!0j7UHhL1C!x?X zXj-+|g1YJMUPAqcGB~q%eIras^R@+x0ftz-VV0VUe1|BvBS^+Y zf_Xk|(8$u|EkDkJgJIt!r_W=_^OOd){!f_r;h%9=mKqZ_oU0_aCT)#BXi9hQou(5Y zn!Su*GRY1VwJD$+sZISFdY|bSxK>0K=2&E5EepN8P4a4)LW@i}yOi{MCZeVyk}~2a ztH^ULsq-$J71n>32n9)XB&}#o%V1sqj{fuiet)v2C7s^enZpJ7^-C7dhZYW@C;m0Y zYy%nl^T+(X>0{1a(6=MDBX;5C1IgS4UkIda3+mRdoDT^YVpMp&71Jh}co8(XuqKX%DhZxm(OYU6DoF5@dXv*RD6U2 zmP_=t3au!{b>v<{?rsvU{ues&TPl7>XSI-}5x1fMYH2YuD<7?}5HIu!z4CJO0mYET z74ux37j7U=7O+ZJ*M%GJTb)>lM8~`l)BHr|SV0Rgr@o5}M)i^2Z<-u_tZ>#Z>|0}X zk}u6@>FOi`Lcpix-P%-X!(eZ!wB<^hXx|#qqM&6jFDAq+@iGK=aKHL=-_CeT!t{;@ zKbs-34Dab#XAw;bs{GhA7-L#BzHk7XF;M}bS;H5aXH?nc=*W3I%IZj6OVR? zt2zN)oyWO9XgG5hH)*5?cwixO)y=Vh6dV|sH!-)r{6`DlUWoMDOPa){=O+O&>$G?` z`g-jbzc*?e+mJE<)A>GM-B@dQe-V#zn!P7!F+pg!DF>#MAn7GU2ZCoh=2qI&B^OKP z3H&a26&9*SAXq{bQmig;kcMmN#mfX%{tJ^EEn@!T-dM)7M#X}pp3k=I%FBB;`%v7C zNxSfuEq3^Qg0P?;hx{z+-%dpm28l~IPkNUuIyUB9oSq2+;eIlh$>MEa1b?AJKEMf3 zlS>AQj&;eLv2;RLM7l7)2}_X~oD@oz{VK+Kg!^W^m>>D-depwqjho`eYL z&_-TiE;+ucU&{5bP++>krUbpcvOl%d4w4cCN3kukB9b-8HF-6))u^UA12@6OwP|6mu^b zi#a_1=?>tHQ*CGAcj51C?||DOsm6uBp&>9@yMtPKYogXo^aX^(?=FM36U7Cx@x{J1SnI?)X!IV*&Q!0~Q=q=(1$#IL-(&dt8CE z^{>zauEur5XxL#;Z?!|vZ=a3K(RT3gy#6U9pPsPIinpa~UaC1?G9ZTbOsFATb5ph| zrg(_uL@i{IVq?~#JL24J!%f4hrqO~bWIIcgCN{k}e}Eg9`>thpWy@gO&pO}h6mz%U zGi^fGs3| zHwxo5BH|ud6LtN%H#hV*Ce{zk9?Bg$Is^k}xK}Wm+|JDY4;&L6$M2cCK%dbSF-rB< z0R+tT zj!aPzzQSo~O)pt+U4`0St_6S&LKZ8uBt@Fjh=7B2*+OpWJ#*z2%mdVvZ7lc(%+Lf` zGKEzkKENR0boTycSVX(G;uE8>oLBDtMLzvD&Il^oUL zA`0A-;du%S0ZSaK-$~RVTljG3_pGGfgD_x*4b;pizay%4q`Sg*)G8|72|h? zY=T`4za7;?bE8VCQ@qht@)N;^1koR(E@=L2^u0x$*(@U7N{e4I7;t!X`f?oG`o19Wa=hc!fpDaXzr80o13i!NmaU^FxC}&x$!LA^Q_8ywy z_SgkhzW4NQ!Ac)Ei4B{lscZ=p11bO%OcJZYVUjRO!=g;}wX)KYRZ zaMOIB!`PWi$~eFAsjW@SassG^zz4KuXEUrd{*hV|(-ToHn4sn4!@6q1ipoyp<|nHdi!!x&T?4DIz6$Lq_cF? zIXCH?JL2?_L*?4Zp#`1!u>c$r^AdWfaT*5}4CTX9O5B5FCx=tcR?*Zdxlmvrns%(~%0_$? z`}hh_d6|2KbegcCqZcCo7SqO_Ov;1Uo7&cHHhaPoG`*{6YU=3us3*c}lIl@1`z-qE zbjVic|3KH!iikN$9tPlPCyU;$?$qH^VacK#wm&1(`>)YRU>yG)+C54Ifl^>ku)u{wr z57SL3(jF6nv*0UI02flQNj}8Cqq8LLW^!>~UIxlFP-Zmv0W+yZJ~fd(H}tMfS`CgP z+-KWarU86Z_D)})KtbBsb{6}%;kQ?a>Eh{#g~wjfw!SlnFO{op1QU#}zd>%BthL|3m-f&Nt zJh6s&UY~x%R5`(0B1=h08u#}#CF<{N?$`au^0wt3zlNkXbDN>{!v{4~P_${WWg0I) z!W_m6{5?v>3s^7PF55GgsIPDVHkAudB?#Fphk!PeMF#gtZ}Ng6|CJ;}AyT3I8(;z+ zkx>vJh>!(C#ro`$8!R{Y0nrB3Hv`KVEwXG=GsBYFA(MZRuefBu$g(I)fV6GdaDFz) zQAnSGw3X#k9s{H^N<4WeM}Q?D4>${{kRp9iTLOE+j&vIC{SI(1DXrM1%4T3=1(^}X zo`p+DV_$k)wmph{foFB9{cuL_N~BL9Src;;GiRJt&^XRo5WC%a*Y*|&4Q(XBVmMbp z{3KKUGTuIbAel-%0o{)L&w&LEn@G~`<;(q zSjgtUSJ;7JO~9}U#{dj#kJ{uF7T>4B4Zv;~c5B&Zh&UjIYpo}$$IkReY%17Bnl* zG_HHPgZw_oIF_y3&;&GKM3Rt6U3sTtybNGRq6b<~8LVSVsRUz~603`{BlcuyvC>{E zzr-B^#GC=hyfb6KGtfS`d}t0uo2ICAezEi9*s3)Hy9aB2w)VZXQ^;<&gA1ntPQC|i;>dt0dLW@^ z0|EmmOb*Au9GfQsCs`p$4c(ieV1oD=7zFS`0v{FvAJSwAyUQf0lxGEbr?6NQ+pow10X3TyfRjk*D{I2U=549})MqiESrD%Sm|G-QzE$!9gY*xY_tl z$3VjmPka>&OcZ$gJmT4&(aK)2vNw6OTLH+4oWIR`GcR%et&(BiW~hh`zWc;b!_ZE# z3P~b?`0>ph9G-h0ucXA3gK+Z*z{juoacK6NhL{oqLo0|u8Ke4D5-u)I&iF>TUfNm8oUx9G;i9$M$OX zJHA1$NQmdpK{k1{x2!Rrks;dU&;17N@+fxKH)uD*U!aZ-&yfADg&EIWbhr3Vj&dgM zK5N?Dv(I>mf3|w}$IiIdU#hlx>=-eFcgy7O<*}_WFW;b5=JS?MdtS}gN`Q5$<+F@M zARkoFoEluWhuCbk^(ZDH{)$@&7m&@;n&28RM}}Aida^kblA!}<#+_0=Zm85-u?E5` zqytnIe%RwYF|y+(N_j?UZdq#U;Ia(UlKHYKtL?Pk%L3_2=|3oXEt6Y#p*Dw04&-3` z@s94ZNRdlAreLwE8zZGpfNa=+2ztoUHK?al;%9|^3<4wkHjU8e@8~2)W+;Z>q#Z1g zMPnVAiho&%76_UbEP!#`0tm~HG#G}u9j!e=Z%444IpL(!lysdA^dM{&WiugbITEsA z)TbzV34)K2G+-YV97<2FI4k+p+cFzH^(iH5J&J2W#v066FWsROYIu#e&D#yHa=}aE zXE#LRIl3Q33dXZj_>(r^r9cm5QfDHEU|H9((-=F<^vEBbW|(d$i3Ggo;Q@c1mf{ zz=X8zOrjB7(bIOagAGDDAd4#1ZZjs8#tMvw59pywTE!PA04rD`9l@+f$0+KWzpbY& z4G@JLH1$5}Lc%cKg+bVP11@$UEF7T?IV4_yIhaAkd5Er9OwTy&^q+cAUU&u2$cdd7=hR0WDN{qZ zZj9GUZC>{zj#8r~a-(O(ibiVGq&8a97ZO*uQmZ5Kb!!v0v4ZEwZqxGsIz(j3OLw@c z@1VHyAhkUtw_PL7Swjcb$_G}xzD!)OpAH?653TO&7FV64Lqd8W;tZd-csq?`hdPp# zV%b(|v`xMaL!e8ct*!WN&7_xCOvM(wOC%d1`Yau2q8h{M z&8R&D^%4*rVjEZQkpDtZy2(6RPaWAP{4VlWF5FIGc$ER*A||aS zkR=_Rg<;8>b~eNx(>U3P5=!^PlRbKSTSlT2_I0wW2bHU>N{!qXg)ZfeZ19=+p>3w=~5V-fxX-DZPC5-hE)@VY?m)z(&lK$y^qOUK=y zM##hjqwSuB(rJWiOjnp+iDZ_>MAJW`tG-VKiL)g}?hw^#=&sLD!R)Rl=om?_$8Azl z(uE*P@J6AZxpIgRm2pS9K{{!v@IGByhXTtn(q0Mc>G4Q7Cs7{unrG1NMrvaucNU66 z`7+(Rg^ur_;u$LTQ?Z+hJyaZ_qL+%lp@OlZFVSV+qk;@jB%?gaUMhS^O_=-95!z)u zCwt+4PrLt_ieW0)t7nWSGah05I8(-y(zuxjUE)Y-mkc>FC?-)qDTw-Obhk}Zkgy2x zY5-t*yF1nkWCnxVz{VUe48{x`_bG4Z_&?0laOJ<$IRCq5_D7n$k2H>tG}ezaW>!II z|45Vbk;d|o#>I|%KGNWSYWA_wL5`}U#)71=AZ08H^N5)h+my7=61_{3_9bDr(o9v2nM^?@UYQxs4A|2<#3&|;ls}mF#V|-wYH(YLfp>ayD z%h87Ic%deDShyT~At<_$ZJ=Yc{aCX7Sju=DUyQ{OHclBh?g+9r8M%U~Tm!dq%Dt3x zO`YIM@$qct9G2>^VbZI09pb_^^ySPCTR$_@a^K=U)lm7M2OsibF4m1j6@MR?IPMr1 zJ4k!SD0HYLH{!lt6fF{SFQ&}Fu;DYqW-aIWR72%w2l*M?Gw6s)yeM^?RVkw{%ug9~ z+)-}ImdBNUw$#ZX&>M=W3NyEnn<~%m6O%#}_R ySh#xaRFRF^l~i$Sr}lFBTq8HNG>_}yCKqdXTO>HFE4ruh literal 0 HcmV?d00001 diff --git a/claude-obsidian/claude-obsidian-sync b/claude-obsidian/claude-obsidian-sync index 5b0041d..7352e2d 100755 --- a/claude-obsidian/claude-obsidian-sync +++ b/claude-obsidian/claude-obsidian-sync @@ -70,6 +70,16 @@ DEFAULTS = { # Cap per run. Summaries draw on the same plan limits as interactive Claude use, so the # timer drips a backlog instead of bursting it (a 107-session burst exhausted a session limit). "MAX_PER_RUN": "10", + # Spend guards, in API list-price USD as reported by `claude -p` (total_cost_usd). Not the + # plan's own meter -- that is only visible in Claude Code's /usage -- but a faithful proxy + # for how much was consumed. Hitting one stops the run like a usage limit (exit 75). + "BUDGET_USD_PER_RUN": "0.50", + "BUDGET_USD_PER_DAY": "2.00", + # Pre-call estimate prices (USD per million tokens, list). A call is refused BEFORE it runs + # if the estimate would cross a budget. Deliberately NOT `claude -p --max-budget-usd`: that + # aborts after the tokens are spent and throws the result away (measured: $0.26 for nothing). + "LLM_PRICE_IN_PER_M": "1.0", + "LLM_PRICE_OUT_PER_M": "5.0", } @@ -251,6 +261,68 @@ errors and their causes, decisions, open questions. Be specific. Never reproduce CHUNK_CHARS = 300_000 +class Ledger: + """Append-only record of every model call: tokens and list-price cost. Also enforces budgets.""" + + def __init__(self, cfg: dict): + self.path = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent / "usage.jsonl" + self.run_budget = float(cfg["BUDGET_USD_PER_RUN"]) + self.day_budget = float(cfg["BUDGET_USD_PER_DAY"]) + self.lock = threading.Lock() + self.run_cost = 0.0 + self.run_calls = 0 + self.run_in = self.run_out = 0 + + def entries(self, since: dt.datetime | None = None) -> list[dict]: + if not self.path.exists(): + return [] + out = [] + for line in self.path.read_text().splitlines(): + try: + e = json.loads(line) + except json.JSONDecodeError: + continue + if since is None or dt.datetime.fromisoformat(e["ts"]) >= since: + out.append(e) + return out + + def spent_since(self, hours: float) -> float: + since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours) + return sum(e.get("cost_usd") or 0 for e in self.entries(since)) + + def check(self, estimate: float = 0.0) -> None: + """Called BEFORE each model call with that call's estimated cost -- so a budget is never overshot.""" + with self.lock: + if self.run_cost + estimate > self.run_budget: + raise BudgetExhausted(f"run budget ${self.run_budget:.2f}: ${self.run_cost:.3f} spent, " + f"next call est. ${estimate:.3f}") + day = self.spent_since(24) + if day + estimate > self.day_budget: + raise BudgetExhausted(f"24h budget ${self.day_budget:.2f}: ${day:.3f} spent, next call est. ${estimate:.3f}") + + def record(self, label: str, model: str, usage: dict, cost: float | None) -> None: + e = {"ts": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "item": label, "model": model, + "input": (usage.get("input_tokens") or 0) + (usage.get("cache_creation_input_tokens") or 0) + + (usage.get("cache_read_input_tokens") or 0), + "output": usage.get("output_tokens") or 0, "cost_usd": cost} + with self.lock: + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("a") as f: + f.write(json.dumps(e) + "\n") + self.run_calls += 1 + self.run_cost += cost or 0 + self.run_in += e["input"] + self.run_out += e["output"] + + def report(self) -> str: + return (f"usage this run: {self.run_calls} model calls, {self.run_in:,} in / {self.run_out:,} out tokens, " + f"${self.run_cost:.3f} list-price equiv | last 24h ${self.spent_since(24):.3f} of ${self.day_budget:.2f}") + + +class BudgetExhausted(RuntimeError): + """A configured spend budget is reached -- stop the run the same way as a usage limit.""" + + class QuotaExhausted(RuntimeError): """The plan/API usage limit is hit. Every further call would fail the same way -- stop the run.""" @@ -267,7 +339,11 @@ class LLM: openai: any OpenAI-compatible /chat/completions endpoint (LiteLLM, local models). """ - def __init__(self, cfg: dict): + def __init__(self, cfg: dict, ledger: "Ledger | None" = None): + self.ledger = ledger or Ledger(cfg) + self.price_in = float(cfg["LLM_PRICE_IN_PER_M"]) / 1e6 + self.price_out = float(cfg["LLM_PRICE_OUT_PER_M"]) / 1e6 + self.label = threading.local() self.backend = cfg["LLM_BACKEND"] self.model = cfg["LLM_MODEL"] self.resolved = cfg["LLM_MODEL"] @@ -285,16 +361,31 @@ class LLM: else: raise SystemExit(f"unknown LLM_BACKEND {self.backend!r}") + def estimate(self, system: str, user: str, max_tokens: int) -> float: + # ~3 chars/token over-estimates input (measured 3.1 on transcripts); assume the full output; + # +20% margin for anything unmeasured. Errs high on purpose: refusing early beats overshooting. + return 1.2 * ((len(system) + len(user)) / 3.0 * self.price_in + max_tokens * self.price_out) + def chat(self, system: str, user: str, max_tokens: int = 2500) -> str: + self.ledger.check(self.estimate(system, user, max_tokens)) return self._chat_cli(system, user) if self.backend == "claude-cli" else self._chat_openai(system, user, max_tokens) def _chat_cli(self, system: str, user: str) -> str: + # Cost levers, measured on one session: $0.057 -> $0.015 with identical summary content. + # alwaysThinkingEnabled=false -- extended thinking was ~85% of output tokens + # DISABLE_PROMPT_CACHING=1 -- Claude Code writes prompts to the 1h cache at 2x input + # price; a one-shot call never reads it back + # CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 -- otherwise a hidden side request re-reads the + # whole prompt (~20 tokens out, title-generation shaped), + # doubling billed input: 9,638 vs 4,663 visible tokens. cmd = [self.bin, "-p", "--model", self.model, "--no-session-persistence", "--tools", "", - "--strict-mcp-config", "--output-format", "json", "--system-prompt", system] + "--strict-mcp-config", "--output-format", "json", + "--settings", json.dumps({"alwaysThinkingEnabled": False}), "--system-prompt", system] + env = dict(os.environ, DISABLE_PROMPT_CACHING="1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1") last = "" for attempt in range(5): try: - proc = subprocess.run(cmd, input=user, capture_output=True, text=True, timeout=900, cwd=self.workdir) + proc = subprocess.run(cmd, input=user, capture_output=True, text=True, timeout=900, cwd=self.workdir, env=env) except subprocess.TimeoutExpired: last = "timeout" else: @@ -302,6 +393,14 @@ class LLM: d = json.loads(proc.stdout) except json.JSONDecodeError: d = {"is_error": True, "result": (proc.stdout or proc.stderr)[-300:]} + if d.get("usage") or d.get("total_cost_usd"): # failed calls can still cost + # modelUsage is what was BILLED across every request the CLI made; `usage` is only + # the visible turn and undercounted by half before the nonessential-traffic fix. + mu = list((d.get("modelUsage") or {}).values()) + billed = {"input_tokens": sum((v.get("inputTokens") or 0) + (v.get("cacheReadInputTokens") or 0) + + (v.get("cacheCreationInputTokens") or 0) for v in mu), + "output_tokens": sum(v.get("outputTokens") or 0 for v in mu)} if mu else (d.get("usage") or {}) + self.ledger.record(getattr(self.label, "v", "?"), self.model, billed, d.get("total_cost_usd")) if not d.get("is_error") and d.get("result"): used = list((d.get("modelUsage") or {}).keys()) if used: @@ -324,7 +423,11 @@ class LLM: "Authorization": f"Bearer {self.key}", "Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=600) as r: - return json.load(r)["choices"][0]["message"]["content"] + d = json.load(r) + u = d.get("usage") or {} + self.ledger.record(getattr(self.label, "v", "?"), self.model, + {"input_tokens": u.get("prompt_tokens"), "output_tokens": u.get("completion_tokens")}, None) + return d["choices"][0]["message"]["content"] except urllib.error.HTTPError as e: detail = e.read()[:300].decode(errors="replace") if _QUOTA.search(detail): @@ -466,14 +569,26 @@ def top_level_sessions(cfg: dict) -> list[pathlib.Path]: def process(cfg, llm, vault, state, key, meta, source, kind, fingerprint, src_file, dry_run) -> str: - s = llm.summarize(meta) + # Cache the summary until the note is written, so a vault failure never pays for it twice. + cache = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent / "cache" / \ + f"{hashlib.sha1(key.encode()).hexdigest()[:16]}-{hashlib.sha1(fingerprint.encode()).hexdigest()[:8]}.json" + llm.label.v = key + if cache.exists(): + s = json.loads(cache.read_text()) + else: + s = llm.summarize(meta) + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(s)) prev = state.data.get(key, {}) path = prev.get("note") or note_path(cfg, kind, meta, s["title"]) # stable across re-syncs body = render(meta, s, source, llm.resolved, src_file) + if dry_run == "prefetch": + return f"summary cached ({path})" if dry_run: print(f"\n===== {path} =====\n{body}") return path vault.put(path, body) + cache.unlink(missing_ok=True) state.record(key, {"note": path, "title": s["title"], "fingerprint": fingerprint, "source": source, "started": meta.get("first"), "project": pathlib.Path(meta["cwd"]).name if meta.get("cwd") else None}) @@ -494,7 +609,7 @@ def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]: raise cf.CancelledError() try: return fn(*w) - except QuotaExhausted: + except (QuotaExhausted, BudgetExhausted): stop.set() raise @@ -508,7 +623,7 @@ def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]: print(f"skip {label} (no conversation content)", flush=True) else: print(f"ok {label} -> {result}", flush=True); ok += 1 - except QuotaExhausted as e: + except (QuotaExhausted, BudgetExhausted) as e: if quota is None: quota = str(e) for f in futs: # don't launch the rest @@ -520,15 +635,31 @@ def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]: return ok, failed, quota -def finish(ok: int, failed: int, quota: str | None, dry_run: bool, remaining: int) -> None: - verb = "rendered (dry run, nothing written)" if dry_run else "written" +def finish(ok: int, failed: int, quota: str | None, dry_run, remaining: int, ledger: "Ledger | None" = None) -> None: + verb = {"prefetch": "summarized into cache (vault untouched)"}.get(dry_run) or \ + ("rendered (dry run, nothing written)" if dry_run else "written") print(f"done: {ok} {verb}, {failed} failed, {remaining} still queued") + if ledger: + print(ledger.report()) if quota: - print(f"stopped: usage limit reached -- {quota}", file=sys.stderr) + print(f"stopped: {quota}", file=sys.stderr) sys.exit(EX_TEMPFAIL) sys.exit(1 if failed else 0) +def cmd_usage(cfg, args) -> None: + led = Ledger(cfg) + rows = led.entries() + if not rows: + print("no model calls recorded yet"); return + for label, hours in (("last 5h", 5), ("last 24h", 24), ("last 7d", 168)): + since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours) + es = [e for e in rows if dt.datetime.fromisoformat(e["ts"]) >= since] + print(f"{label:>9}: {len(es):4d} calls {sum(e['input'] for e in es):>10,} in " + f"{sum(e['output'] for e in es):>8,} out ${sum(e.get('cost_usd') or 0 for e in es):.3f}") + print(f"budgets: ${led.run_budget:.2f}/run, ${led.day_budget:.2f}/24h (list-price equiv; plan meter: /usage in Claude Code)") + + def cmd_stats(cfg, args) -> None: total = 0 rows = [] @@ -566,23 +697,25 @@ def cmd_sync(cfg, args) -> None: print(f"{total} session(s) pending, summarizing {len(work)} this run", flush=True) if not work: return - llm, vault = LLM(cfg), (None if args.dry_run else Vault(cfg)) + prefetch = getattr(args, "prefetch", False) + llm, vault = LLM(cfg), (None if args.dry_run or prefetch else Vault(cfg)) if vault: vault.ping() + mode = "prefetch" if prefetch else args.dry_run def one(p, fp, key): meta = parse_claude_code(p) if not meta["transcript"].strip(): - if not args.dry_run: + if not args.dry_run and not prefetch: state.record(key, {"note": None, "fingerprint": fp, "source": "claude-code"}) return SKIPPED src = str(p).replace(str(HOME), "~") - return process(cfg, llm, vault, state, key, meta, "claude-code", "Code", fp, src, args.dry_run) + return process(cfg, llm, vault, state, key, meta, "claude-code", "Code", fp, src, mode) ok, failed, quota = run_jobs(cfg, [(w[0].name, *w) for w in work], lambda _l, p, fp, key: one(p, fp, key)) if vault and ok: write_index(cfg, vault, state) - finish(ok, failed, quota, args.dry_run, total - ok) + finish(ok, failed, quota, mode, total - ok, llm.ledger) def cmd_import(cfg, args) -> None: @@ -607,16 +740,19 @@ def cmd_import(cfg, args) -> None: fp, None, args.dry_run)) if vault and ok: write_index(cfg, vault, state) - finish(ok, failed, quota, args.dry_run, total - ok) + finish(ok, failed, quota, args.dry_run, total - ok, llm.ledger) def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("usage", help="model calls, tokens and spend from the local ledger") s = sub.add_parser("stats", help="condensed sizes, no network") s.add_argument("--top", type=int, default=15) s = sub.add_parser("sync", help="summarize Claude Code sessions") s.add_argument("--dry-run", action="store_true", help="print notes instead of writing them") + s.add_argument("--prefetch", action="store_true", + help="summarize into the local cache without touching the vault; a later sync writes them at no extra cost") s.add_argument("--force", action="store_true", help="ignore state and the settle window") s.add_argument("--session", help="only this session id") s.add_argument("--limit", type=int, help="max sessions this run (default MAX_PER_RUN)") @@ -628,7 +764,7 @@ def main() -> None: s.add_argument("--limit", type=int, help="max conversations this run (default MAX_PER_RUN)") args = ap.parse_args() cfg = load_config() - {"stats": cmd_stats, "sync": cmd_sync, "import-claude-ai": cmd_import}[args.cmd](cfg, args) + {"stats": cmd_stats, "usage": cmd_usage, "sync": cmd_sync, "import-claude-ai": cmd_import}[args.cmd](cfg, args) if __name__ == "__main__": diff --git a/claude-obsidian/config.env.example b/claude-obsidian/config.env.example index 7531d71..e7dcce5 100644 --- a/claude-obsidian/config.env.example +++ b/claude-obsidian/config.env.example @@ -21,3 +21,5 @@ VAULT_FOLDER=Claude SETTLE_MINUTES=30 # leave sessions alone until they have been idle this long JOBS=1 MAX_PER_RUN=10 # summaries share your plan's usage limits; the timer drips a backlog +BUDGET_USD_PER_RUN=0.50 # list-price USD, refused pre-flight; see `claude-obsidian-sync usage` +BUDGET_USD_PER_DAY=2.00 -- 2.54.0 From 376ec034286b9f120b8fdc95a190e3adb9e31a9c Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Mon, 14 Sep 2026 01:03:22 -0400 Subject: [PATCH 3/4] 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) Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g --- claude-obsidian/README.md | 4 ++ .../claude-obsidian-synccpython-312.pyc | Bin 61341 -> 63148 bytes claude-obsidian/claude-obsidian-sync | 41 +++++++++++++++--- claude-obsidian/config.env.example | 7 ++- 4 files changed, 43 insertions(+), 9 deletions(-) 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 d91fad68668cb49a985b326c0f151c4771927750..f3a7dcf73bf8bcf6f9332ea8724bcb14ba11875f 100644 GIT binary patch delta 12503 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 YZMuuh`gSczA!X#-Le~COnuyZ>2OuUyB>(^b delta 11438 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

yK zeIG;{(AVc01~69(nVK&%q9SgT!d)M(4kIvSh78>I9HfRYhAloVB|Xr15&ik#P$-8-J~&kUYbyN62veZOCCKn(go6V`u^~gYd8X7P|2rgsrx; zeP#OsgNY~q_8TGka>9r3X*&fjaMPeLh(i?_1_|LB3ZA?4%m4BN3^w=l;i zTgeL6|H%}xo;~-;OvhU6pA*M+E2cIu-KV`x#aI?yM(!|kV4>Ip>O;9;3joO)unY!x zBi3fv77Y2HQkMs=W!s_RKg7hTPq!1kTB^zI#tgR`dRTx0@{ype89J-b7~4#mZdnXl}d)CE4)wGvIHnT#l!p!##jn+<122 zKsmP%M?2(@b6L+}M|oR30_Hr79yH!aOSX|qCT)s%$rA+jpH|d5EC{1!>K|^BcV9cP z(YntgZ*G+MhT(@|yrClO9Nwfoe4!!EEehYnJm%vm9{6#|V^BQjRbp$0kqpbA{9JA^ zEd#D(8uE46GzH4Y`H4oXa?A7SASk-UuL0VHVU zHFaE%sp;74S|pj+pEK;q&u7P9z?^Czs%Z5z82l-rQ1Nxe{%*n07hXv-#R$>##n3)9({uh-cokhvFcCRt=5xj2>W{W@1d G(f<$qRty{f 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): -- 2.54.0 From db3a3e6e82e48fdbfc55113856662afca9213517 Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Mon, 14 Sep 2026 01:19:56 -0400 Subject: [PATCH 4/4] claude-obsidian: don't count skipped empty sessions as still queued The first full drain reported "105 written, 0 failed, 9 still queued" when the 9 were empty sessions already recorded as skipped -- nothing was left. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g --- .../claude-obsidian-synccpython-312.pyc | Bin 63148 -> 63244 bytes claude-obsidian/claude-obsidian-sync | 16 ++++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc b/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc index f3a7dcf73bf8bcf6f9332ea8724bcb14ba11875f..c8dc2039e3a2fa9e57246373619dbd2a6d6de43e 100644 GIT binary patch delta 1987 zcmZ8hdrVVT7(b`IeRF$TC@qg7R|+DC0zT2XSrz8ghzfB^e4!Kw#US1mU%OY?G;2%P zraRl5GcM|q%>_*$HxniONGj#+O3y#ehnEl%Sq;)NO$N(oR2;*l&PvvnQFBiA<3XZqMlqiZi^Fjl~^Gfck)|f@1#e^6+a$6F&_U*< z2=$W)b>lJy8IULZHIa8)&74-$R4~P`I}8JgKiry}ZWgumDSldPm_&0Ej!2SLG{UZ; zVR266NXtHF0!EEC29-cY$-KkdcqH<_1^XG&T;iOZiUc(VbK^<>s3++V=yE0V3#CVe zOJ_whE#1$PdxDdM+N_SqHkoJ1??jT5mbOHENWRc^5l0I;$huv#v6|=k?F>KuHJ+R} zc?es{^7bUH&R1PkTT3>z&&;=i8_$=5*K=|UJZOjXLS4;T@5c2$AsNoN0*3E1*zcoy zbjg{dFiG`ufCcS)(#fCgUvmStB}!3=vU*K#5&TNXC#R<9Kdn?uX$q4@dQa^y*J%nS zt2?6-=eo3==_w$eG0xV{9m{k@t6-69?-rilhCl_@q80W~Ya~*1& zAz%OgBF1_0sUN2^IE6e^#$h)}>HXEF*T9890UWRfq60AjTOc-ICk2-$sbeF3FPHa~ zU>qx-?!SPsjl4H79(emelEKdB!dnQCOM*mIEaGQV06$Hj1Af=bRP3= zeM1bHGPr16Oa=B>cI%tq0#aJVK-aL6S>hzfA#frqu>R>08$uxzvWH@y`eNjN<*7x4 zz7V-kG~6O~J6vwjLV3vQok{SC82QfNJhf>)fF?`_(1eA=J>-tB^eWXGl$u&asOzuX zTn}ob`(V3n}}DPN&j5w?OhaKq@We$(i9)<8rW{hT+SU?+u^B6H_vey3X4MvkNE&CfhC#17By1mA$`tu!(Kp zLL5NIdDGzV63Bz!-BFrb&Yac=TeR(b0vY2XnsP-sjOUWPCQg(*1 zcnJoJkVqX$DJzDcAx+uFn$14J@hoi^@G$gKsVNz;{tp#wNliN)n0aAaR zGp>Qin=pJm^8V{H@XFB{M_tM`>0}(~jVG)jAK%WyKKZBHnJT=QJi5CH*OBUbRXp#ntKCq(vZ}_P#fO>CFYqE? zxUr@@tRQ#q#hJoqSV!}ulIW3HxSAA=N0H`q%M;71)6bN#jHQe_rb=RR910 delta 1899 zcmZux4NOy46u!6bJ=(tZwS~6)v_;B4bs#WBVLBNDrlM2CY>o{a(u#t=Jc=58Pna=l zOW2ROON?=3Y|AnTIwfzLIM@UMb#c*x3Ja)-@lS>OYOT~-S0vcf3pa&+ym!6f*thl1iW zc-ZfKASjTmE|>@*#0)@-*9#&8aYzR)s0f`hbnN0 zL+jf@(R*!aa&oy=YY{rv78)`G-(U%z74PDD9@T)K1N1d3YF>5+BDTGW>Nn(~lkF*f zUX{g5ThKr42i7r6c0AR|nlsIQozW3kKdH!|GOcu%N`@C*X5#L$YO`(fuEeOUV0pKm zSh|A)v88(!iRn&K5Ir?EJ4)UYM!Y>y47T*xD5A$R*(uWB-{;7n2`%nOh0)SG9gk@^ zQ>y9lLYOG6Jl#RTI4R)l0EF|T-+xM@VK|EFeGaCh+}>Df zjQQfzFkgHQ+CKPF$eOj@V!L%1FMJB>CZ^w52 z9@!X8mb0*C3q;jJ3CpG-Q?O@{vaj97D@jZ+5g|mFh+* z07XUU@lX`|I(DDHvm#Q8zkC`dgoHQfJLvwH?AGK)*cjeKA5k_b8>)|Hd|urblYL>) z<_p1FdTAr>JiiYa6md0jA$5#xVK341AKjOO*jHeM9|k`YX<9O$wPW0=p~f1%XE^S~ z(nq}7#kn+ua>$|ft3z0>WsTC|UmQ@SuGM%nm{m<#G+`CZy|ybw?dIH?62_r@hB&3j ziYgqf6_l7b+vI_mIE{xxx38tr9(7s#6K3|nOw@6GpK2MomA7#$zZezV_z3sp*o{Qp za$-GCtc^tEqNJMZl7SV$2N0OxOD2)r!*{lUZK*o_&9pRTZkk%r9 zCcYFEj_lFRBWfZMq_y}$bZ;aT@>1O0nKD?3R*jZJC+Zn3g=UmEWpkTb&}vn zR6dpt%TUW$5==wa$9BL)=*|1bY!SriMO$DozSi)JrAf X#S{!hKRz(a7mU9FgJ2dKeqj9 diff --git a/claude-obsidian/claude-obsidian-sync b/claude-obsidian/claude-obsidian-sync index d1dcf03..fd0d5a0 100755 --- a/claude-obsidian/claude-obsidian-sync +++ b/claude-obsidian/claude-obsidian-sync @@ -611,8 +611,8 @@ SKIPPED = "(empty, skipped)" EX_TEMPFAIL = 75 # "try again later" -- systemd logs it, the next timer run retries -def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]: - ok = failed = 0 +def run_jobs(cfg, work, fn) -> tuple[int, int, str | None, int]: + ok = failed = skipped = 0 quota = None stop = threading.Event() # Future.cancel() cannot stop a job a worker already picked up @@ -632,7 +632,7 @@ def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]: try: result = fut.result() if result == SKIPPED: - print(f"skip {label} (no conversation content)", flush=True) + print(f"skip {label} (no conversation content)", flush=True); skipped += 1 else: print(f"ok {label} -> {result}", flush=True); ok += 1 except (QuotaExhausted, BudgetExhausted) as e: @@ -644,7 +644,7 @@ def run_jobs(cfg, work, fn) -> tuple[int, int, str | None]: pass except Exception as e: # keep going; next run retries print(f"FAIL {label}: {e}", file=sys.stderr, flush=True); failed += 1 - return ok, failed, quota + return ok, failed, quota, skipped def finish(ok: int, failed: int, quota: str | None, dry_run, remaining: int, ledger: "Ledger | None" = None) -> None: @@ -738,10 +738,10 @@ def cmd_sync(cfg, args) -> None: src = str(p).replace(str(HOME), "~") return process(cfg, llm, vault, state, key, meta, "claude-code", "Code", fp, src, mode) - ok, failed, quota = run_jobs(cfg, [(w[0].name, *w) for w in work], lambda _l, p, fp, key: one(p, fp, key)) + ok, failed, quota, skipped = run_jobs(cfg, [(w[0].name, *w) for w in work], lambda _l, p, fp, key: one(p, fp, key)) if vault and ok: write_index(cfg, vault, state) - finish(ok, failed, quota, mode, total - ok, llm.ledger) + finish(ok, failed, quota, mode, total - ok - skipped, llm.ledger) def cmd_import(cfg, args) -> None: @@ -762,12 +762,12 @@ def cmd_import(cfg, args) -> None: llm, vault = LLM(cfg), (None if args.dry_run else Vault(cfg)) if vault: vault.ping() - ok, failed, quota = run_jobs(cfg, [((m.get("title") or m["session_id"])[:50], m, fp, key) for m, fp, key in work], + ok, failed, quota, skipped = run_jobs(cfg, [((m.get("title") or m["session_id"])[:50], m, fp, key) for m, fp, key in work], lambda _l, m, fp, key: process(cfg, llm, vault, state, key, m, "claude-ai", "Web", fp, None, args.dry_run)) if vault and ok: write_index(cfg, vault, state) - finish(ok, failed, quota, args.dry_run, total - ok, llm.ledger) + finish(ok, failed, quota, args.dry_run, total - ok - skipped, llm.ledger) def main() -> None: -- 2.54.0