From 46052cf57335bf41168e36a05c850c74c997160a Mon Sep 17 00:00:00 2001 From: Nic O'Connor Date: Thu, 17 Sep 2026 16:23:54 -0400 Subject: [PATCH] Revert "claude-obsidian: Claude conversations -> Obsidian notes (+ MCP) (#1)" This reverts commit d3393edfdd29cc04db394b9c4d6d3dc0af474565. --- claude-obsidian/README.md | 113 --- .../claude-obsidian-synccpython-312.pyc | Bin 63244 -> 0 bytes claude-obsidian/claude-obsidian-sync | 798 ------------------ claude-obsidian/config.env.example | 28 - claude-obsidian/install.sh | 14 - .../systemd/claude-obsidian-sync.service | 11 - .../systemd/claude-obsidian-sync.timer | 10 - 7 files changed, 974 deletions(-) delete mode 100644 claude-obsidian/README.md delete mode 100644 claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc delete mode 100755 claude-obsidian/claude-obsidian-sync delete mode 100644 claude-obsidian/config.env.example delete mode 100755 claude-obsidian/install.sh delete mode 100644 claude-obsidian/systemd/claude-obsidian-sync.service delete mode 100644 claude-obsidian/systemd/claude-obsidian-sync.timer diff --git a/claude-obsidian/README.md b/claude-obsidian/README.md deleted file mode 100644 index 132500e..0000000 --- a/claude-obsidian/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# 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-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: - -```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. -- **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 - 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. -- **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 - 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/__pycache__/claude-obsidian-synccpython-312.pyc b/claude-obsidian/__pycache__/claude-obsidian-synccpython-312.pyc deleted file mode 100644 index c8dc2039e3a2fa9e57246373619dbd2a6d6de43e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 63244 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

r ztDk7{E%Fp_t9u|N8j^!qoe{tP1zMFSfSr@Pj3P7{Z5gbPm*IY6Q&M5B5_ zE@s7|^Klo?W5Bo@Rp36SJY5E~@j}ZJY{h2KHq?wqtSp^q`j_AlYl!Y~C+J-HW){`ye{`3cB)Ij@(87Z#>KBFp$_K` zxL6{UA+e&It5q$aiUGwvr`sDJnx~=PsWeURA|qH#36`59<0DH68~h0y787!oEh%FS z%khb08i4h;8YUZ-tegGT%>k=@$y)BWmItgmzf{G?9tK~;nm<7R zePYVk=$q<$rn{yk+aAp2xtzJ0_csS@2j`su+mYo&3*$(YelPA(97=f7^_VG0xdpC%|n z3Q+?lHYf+sigd3a&S+6PMx3dGMq^^xL19MP-M2JZ~G~<{kWh}c)aM-}I zo8bZ$yFdBRH;<`dFK@`<>;{3(2jQ>b1U0~c!28A$@dEOKaHXe95?#RgPb33Cz`}n) zLNKanu?YW_oc{;T3O;qv6j4iW^3yfXqiWV58}{?W9Hje2YCM2WUv*j#Q7bvS+OgY)J3bFi>S4*HG-{0^2Slp@#@#LAQ(q2pVuNzN3g@|+V3kw z-

quyyF4Ox9-gQ?~AKMRyMGjg7%%%rSc7&is!j}ga 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 diff --git a/claude-obsidian/claude-obsidian-sync b/claude-obsidian/claude-obsidian-sync deleted file mode 100755 index fd0d5a0..0000000 --- a/claude-obsidian/claude-obsidian-sync +++ /dev/null @@ -1,798 +0,0 @@ -#!/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 fcntl -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) - 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(): - 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", - # 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", -} - - -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 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.""" - - -_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, 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"] - 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 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", - "--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, env=env) - 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 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: - 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: - 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): - 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): - # 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: - 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.""" - 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") - - -_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: - # 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}) - 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, int]: - ok = failed = skipped = 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, BudgetExhausted): - 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); skipped += 1 - else: - print(f"ok {label} -> {result}", flush=True); ok += 1 - except (QuotaExhausted, BudgetExhausted) 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, skipped - - -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: {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 = [] - 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 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() - 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 - 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 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, mode) - - 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 - skipped, llm.ledger) - - -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 = [] - 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, 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 - skipped, 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)") - 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, "usage": cmd_usage, "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 deleted file mode 100644 index 42c1116..0000000 --- a/claude-obsidian/config.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# ~/.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. -# 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 # 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): -# 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 -BUDGET_USD_PER_RUN=0.50 # list-price USD, refused pre-flight; see `claude-obsidian-sync usage` -BUDGET_USD_PER_DAY=2.00 diff --git a/claude-obsidian/install.sh b/claude-obsidian/install.sh deleted file mode 100755 index 407ee70..0000000 --- a/claude-obsidian/install.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/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 deleted file mode 100644 index 168a384..0000000 --- a/claude-obsidian/systemd/claude-obsidian-sync.service +++ /dev/null @@ -1,11 +0,0 @@ -[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 deleted file mode 100644 index 03cc583..0000000 --- a/claude-obsidian/systemd/claude-obsidian-sync.timer +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Hourly Claude -> Obsidian conversation sync - -[Timer] -OnCalendar=hourly -RandomizedDelaySec=10m -Persistent=true - -[Install] -WantedBy=timers.target