#!/usr/bin/env python3 """echo.py — the single validated, cross-platform client for the ECHO Obsidian Local REST API. Replaces the original echo.sh so the toolchain runs identically on Windows, macOS, and Linux with no bash / GNU-vs-BSD `date` dependency — only a Python 3 interpreter (which the linter already requires). Every read/write to the vault should go through this script. It centralizes: auth injection, HTTP-status checking (non-zero exit on >=400 so a failed write can never look like success), one bounded retry on 5xx/connection errors, WHOLE-LINE idempotent append (a new line that is merely a substring of an existing one is no longer wrongly skipped), correct `::` heading-target handling, frontmatter field patches, a read-back-confirmed advisory multi-writer lock, a one-call cold-start `load`, and scope show/set. Config (env overrides; defaults match the rest of the plugin): ECHO_BASE default https://echoapi.alwisp.com ECHO_KEY default the plugin bearer token (env overrides it) ECHO_VERIFY default 1 — read-back verify after a PUT ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale ECHO_TODAY YYYY-MM-DD to use as "today" (pass the conversation's currentDate) Usage: echo.py load # cold-start: the 6 orientation reads in one call echo.py get # print file contents (404 -> exit 44) echo.py map # document-map JSON (headings/blocks/frontmatter) echo.py ls # directory listing JSON echo.py search # /search/simple echo.py put [file] # create/overwrite (body from file or stdin) echo.py post [file] # raw append (NON-idempotent; prefer `append`) echo.py append # idempotent append: skips only on an exact whole-line match echo.py patch [file] echo.py fm # PATCH a frontmatter scalar echo.py bump [YYYY-MM-DD] # set frontmatter updated: to today (or given date) echo.py delete # DELETE (destructive; explicit use only) echo.py lock # acquire advisory lock (exit 75 if held & fresh, or lost the race) echo.py unlock # release advisory lock if owned by echo.py scope show # print active scope, its freshness, and sessions-since echo.py scope set "" # switch scope atomically (history + replace + stamp scope_updated) Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 2 usage · 1 other HTTP/transport error. """ from __future__ import annotations import argparse import datetime as dt import json import locale import os import sys import tempfile import time import urllib.error import urllib.parse import urllib.request from pathlib import Path # Never let a non-ASCII byte (em-dash in our own output, or Unicode in a fetched # note) raise UnicodeEncodeError on a legacy Windows console. for _stream in (sys.stdout, sys.stderr): try: _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] except Exception: pass BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/") # Key resolution is centralized in echo_secrets: ECHO_KEY env -> ~/.echo-memory/credentials # -> the deprecated baked-in fallback below. Move the key out of the tree with # `echo.py write-key `; remove DEFAULT_KEY before the 1.0 tag (see ROADMAP-1.0.md > M1). sys.path.insert(0, str(Path(__file__).resolve().parent)) import echo_secrets # noqa: E402 DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab" KEY = echo_secrets.resolve_key(DEFAULT_KEY) VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0" LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900")) LOCK_PATH = "_agent/locks/vault.lock" class EchoError(RuntimeError): def __init__(self, message: str, code: int = 1) -> None: super().__init__(message) self.code = code def today() -> str: return os.environ.get("ECHO_TODAY") or dt.date.today().isoformat() def now_iso() -> str: return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def vault_url(path: str) -> str: safe = "/".join(urllib.parse.quote(part) for part in path.split("/")) if path.endswith("/") and not safe.endswith("/"): safe += "/" return f"{BASE}/vault/{safe}" def request(method: str, url: str, data: bytes | None = None, headers: dict[str, str] | None = None) -> tuple[int, bytes]: """One bounded retry on transport failure (status 0) or 5xx. Returns (status, body).""" merged = {"Authorization": f"Bearer {KEY}", **(headers or {})} last_status, last_body = 0, b"" for attempt in range(2): req = urllib.request.Request(url, data=data, method=method, headers=merged) try: with urllib.request.urlopen(req, timeout=30) as response: return response.status, response.read() except urllib.error.HTTPError as exc: body = exc.read() if exc.code >= 500 and attempt == 0: time.sleep(1) continue return exc.code, body except Exception as exc: # connection error, timeout, DNS, ... last_status, last_body = 0, str(exc).encode() if attempt == 0: time.sleep(1) continue return last_status, last_body def check(status: int, body: bytes, context: str) -> None: if status == 404: raise EchoError(f"{context}: not found", 44) if status == 0: raise EchoError(f"{context}: vault unreachable ({body.decode(errors='replace')}) [{BASE}]", 1) if status >= 400: raise EchoError(f"{context}: HTTP {status} - {body.decode(errors='replace')}", 1) def read_body(arg: str | None) -> bytes: if arg in (None, "-"): raw = sys.stdin.buffer.read() try: text = raw.decode("utf-8") except UnicodeDecodeError: text = raw.decode(locale.getpreferredencoding(False) or "cp1252", errors="replace") return text.encode("utf-8") return Path(arg).read_bytes() def normalize_patch_body(data: bytes, op: str, target_type: str) -> bytes: """Heading PATCH bodies need a leading newline on replace and a trailing newline on append/prepend so the API does not concatenate onto adjacent text.""" if target_type != "heading": return data text = data.decode("utf-8", errors="replace") if op == "replace": text = text.strip("\r\n") text = f"\n{text}\n" if text else "\n" elif text and not text.endswith("\n"): text += "\n" return text.encode("utf-8") def normalize_json_scalar(value: str) -> bytes: """Accept either a raw scalar ('Fabrication Lead') or pre-formed JSON ('"2026-06-20"').""" value = value.strip() try: json.loads(value) return value.encode("utf-8") except json.JSONDecodeError: return json.dumps(value).encode("utf-8") def temp_file(data: bytes) -> str: fh = tempfile.NamedTemporaryFile(delete=False) try: fh.write(data) return fh.name finally: fh.close() # ---- commands ---------------------------------------------------------------- def cmd_get(path: str) -> int: status, body = request("GET", vault_url(path)) check(status, body, f"get {path}") sys.stdout.buffer.write(body) return 0 def cmd_map(path: str) -> int: status, body = request("GET", vault_url(path), headers={"Accept": "application/vnd.olrapi.document-map+json"}) check(status, body, f"map {path}") sys.stdout.buffer.write(body) return 0 def cmd_ls(path: str) -> int: if not path.endswith("/"): path += "/" status, body = request("GET", vault_url(path)) check(status, body, f"ls {path}") sys.stdout.buffer.write(body) return 0 def cmd_search(query: list[str]) -> int: q = urllib.parse.quote_plus(" ".join(query)) status, body = request("POST", f"{BASE}/search/simple/?query={q}") check(status, body, "search") sys.stdout.buffer.write(body) return 0 def _qwrite(method: str, url: str, data: bytes | None = None, headers: dict | None = None): """A mutating request that survives an outage: routes through echo_queue.safe_request, which enqueues the write and returns (202, ...) when the vault is unreachable (H2).""" import echo_queue return echo_queue.safe_request(method, url, data=data, headers=headers) def cmd_put(path: str, file_arg: str | None) -> int: data = read_body(file_arg) status, body = _qwrite("PUT", vault_url(path), data=data, headers={"Content-Type": "text/markdown"}) if status == 202: print(f"queued (offline): PUT {path} — will sync on the next reachable session") return 0 check(status, body, f"put {path}") if VERIFY: status, _ = request("GET", vault_url(path)) if status != 200: raise EchoError(f"put {path}: write did not verify (GET returned {status})") print(f"ok: PUT {path}") return 0 def cmd_post(path: str, file_arg: str | None) -> int: data = read_body(file_arg) status, body = _qwrite("POST", vault_url(path), data=data, headers={"Content-Type": "text/markdown"}) if status == 202: print(f"queued (offline): POST {path} — will sync on the next reachable session") return 0 check(status, body, f"post {path}") print(f"ok: POST {path}") return 0 def cmd_append(path: str, line: str) -> int: """Idempotent append. Skips ONLY when the exact whole line already exists — a new line that is a substring of an existing one is still appended.""" status, body = request("GET", vault_url(path)) if status == 200: existing = [ln.rstrip("\r") for ln in body.decode(errors="replace").splitlines()] if line.rstrip("\r") in existing: print(f"skip: line already present in {path}") return 0 elif status == 0: pass # offline: skip the idempotency read; queue the POST (flush dedups by content) elif status != 404: check(status, body, f"append(read) {path}") status, body = _qwrite("POST", vault_url(path), data=f"{line}\n".encode(), headers={"Content-Type": "text/markdown"}) if status == 202: print(f"queued (offline): APPEND {path} — will sync on the next reachable session") return 0 check(status, body, f"append {path}") print(f"ok: APPEND {path}") return 0 def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str | None) -> int: if op not in {"append", "prepend", "replace"}: raise EchoError("patch: op must be append, prepend, or replace", 2) if target_type not in {"heading", "frontmatter", "block"}: raise EchoError("patch: target-type must be heading, frontmatter, or block", 2) data = normalize_patch_body(read_body(file_arg), op, target_type) status, body = _qwrite( "PATCH", vault_url(path), data=data, headers={ "Operation": op, "Target-Type": target_type, "Target": target, "Content-Type": "application/json" if target_type == "frontmatter" else "text/markdown", }, ) if status == 202: print(f"queued (offline): PATCH {op} '{target}' -> {path} — will sync on the next reachable session") return 0 check(status, body, f"patch {path} ({target})") print(f"ok: PATCH {op} {target_type} '{target}' -> {path}") return 0 def cmd_fm(path: str, field: str, value: str) -> int: return cmd_patch(path, "replace", "frontmatter", field, temp_file(normalize_json_scalar(value))) def cmd_delete(path: str) -> int: status, body = _qwrite("DELETE", vault_url(path)) if status == 202: print(f"queued (offline): DELETE {path} — will sync on the next reachable session") return 0 check(status, body, f"delete {path}") print(f"ok: DELETE {path}") return 0 def parse_lock_time(value: str) -> int: try: return int(dt.datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") .replace(tzinfo=dt.timezone.utc).timestamp()) except Exception: # Unparseable timestamp -> treat the lock as FRESH (return "now"), so a # garbled lock is respected rather than silently stomped. return int(time.time()) def cmd_lock(owner: str, quiet: bool = False) -> int: status, body = request("GET", vault_url(LOCK_PATH)) if status == 200 and body.strip(): current = body.decode(errors="replace").strip() held_owner, _, held_iso = current.partition(" @ ") if held_owner != owner and int(time.time()) - parse_lock_time(held_iso) < LOCK_TTL: print(f"lock held by '{held_owner}' since {held_iso} (fresh)", file=sys.stderr) return 75 status, body = request("PUT", vault_url(LOCK_PATH), data=f"{owner} @ {now_iso()}\n".encode(), headers={"Content-Type": "text/markdown"}) check(status, body, "lock") # Read-back confirm we actually hold it. The REST API has no compare-and-swap, # so two racers can both PUT; last writer wins. Whoever does not own the file # on read-back backs off instead of proceeding under a false lock. status, body = request("GET", vault_url(LOCK_PATH)) if status == 200: held_owner = body.decode(errors="replace").split(" @ ", 1)[0].strip() if held_owner != owner: print(f"lock race: now held by '{held_owner}', not '{owner}' — backing off", file=sys.stderr) return 75 if not quiet: print(f"ok: locked by {owner}") return 0 def cmd_unlock(owner: str, quiet: bool = False) -> int: status, body = request("GET", vault_url(LOCK_PATH)) if status == 200: held_owner = body.decode(errors="replace").split(" @ ", 1)[0].strip() if held_owner != owner: print(f"lock owned by '{held_owner}', not '{owner}' — not releasing", file=sys.stderr) return 75 status, body = request("DELETE", vault_url(LOCK_PATH)) if status != 404: check(status, body, "unlock") if not quiet: print("ok: unlocked") return 0 def extract_heading(markdown: str, heading: str) -> str: """Return the body under a `## ` up to the next `## ` heading.""" out: list[str] = [] capture = False marker = f"## {heading}" for line in markdown.splitlines(): if line.strip() == marker: capture = True continue if capture and line.startswith("## "): break if capture: out.append(line) return "\n".join(out).strip() def cmd_scope(subcommand: str, text: str | None = None) -> int: path = "_agent/context/current-context.md" status, body = request("GET", vault_url(path)) check(status, body, f"scope {subcommand}") current = body.decode(errors="replace") if subcommand == "show": print("-- Active scope --") print(extract_heading(current, "Scope")) scope_updated = "" for line in current.splitlines(): if line.startswith("scope_updated:"): scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'") break print(f"scope_updated: {scope_updated or ''}") # Count session logs dated after scope_updated (the drift signal). if scope_updated: status, body = request("GET", vault_url("_agent/sessions/")) if status == 200: try: files = json.loads(body).get("files", []) n = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated) print(f"sessions logged since: {n}") except json.JSONDecodeError: pass return 0 if subcommand != "set": raise EchoError("scope: use 'show' or 'set \"\"'", 2) if not text: raise EchoError("scope set needs the new scope text", 2) prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)" cmd_patch(path, "prepend", "heading", "Current Context::Scope History", temp_file(f"- {today()}: {prior}\n".encode())) cmd_patch(path, "replace", "heading", "Current Context::Scope", temp_file(f"{text}\n".encode())) try: cmd_patch(path, "replace", "frontmatter", "scope_updated", temp_file(json.dumps(today()).encode())) except EchoError as exc: raise EchoError( "scope set: body switched, but scope_updated frontmatter is missing " f"(run bootstrap.py to add it) [{exc}]" ) print(f"ok: scope switched (prior archived to Scope History; scope_updated={today()})") return 0 def cmd_load() -> int: """Cold-start orientation: the canonical 6 reads in one call. 404s on today's daily note and the inbox are normal (printed as absent, not errors).""" targets = [ ("marker", "_agent/echo-vault.md"), ("preferences", "_agent/memory/semantic/operator-preferences.md"), ("context", "_agent/context/current-context.md"), ("heartbeat", "_agent/heartbeat/last-session.md"), ("today", f"journal/daily/{today()}.md"), ("inbox", "inbox/captures/inbox.md"), ] import echo_queue # H2: sync any writes queued during a prior outage, best-effort and quiet on empty. try: synced = echo_queue.flush() if synced: print(f"(synced {synced} write(s) queued during a prior offline session)\n") except Exception as exc: # noqa: BLE001 — never let queue upkeep block a load print(f"(queue flush skipped: {exc})\n", file=sys.stderr) marker_missing = False heartbeat_absent = False offline = False for label, path in targets: status, body = request("GET", vault_url(path)) if status == 200: echo_queue.cache_put(path, body) # refresh last-known-good for offline fallback print(f"===== {label}: {path} (HTTP 200) =====") text = body.decode(errors="replace") sys.stdout.write(text) if not text.endswith("\n"): sys.stdout.write("\n") elif status == 404: print(f"===== {label}: {path} (HTTP 404) =====") print("(absent — fine)") if label == "marker": marker_missing = True if label == "heartbeat": heartbeat_absent = True elif status == 0: # vault unreachable -> degrade to last-known-good cache offline = True cached = echo_queue.cache_get(path) if cached is not None: print(f"===== {label}: {path} (OFFLINE — serving stale cache) =====") sys.stdout.write(cached.decode(errors="replace")) if not cached.endswith(b"\n"): sys.stdout.write("\n") else: print(f"===== {label}: {path} (OFFLINE — no cache) =====") print("(unreachable and not cached)") else: print(f"===== {label}: {path} (HTTP {status}) =====") print(f"(error HTTP {status}: {body.decode(errors='replace')[:200]})") print() # M3: heartbeat pointer missing/stale -> fall back to the recent sessions listing # (matches the documented loading procedure) so orientation works without the pointer. if heartbeat_absent and not offline: st, body = request("GET", vault_url("_agent/sessions/")) if st == 200: try: files = sorted((f for f in json.loads(body).get("files", []) if f.endswith(".md")), reverse=True) except json.JSONDecodeError: files = [] if files: print("===== recent sessions (heartbeat absent — fallback) =====") for f in files[:5]: print(f" _agent/sessions/{f}") print() if offline: print("NOTE: vault unreachable — context above is last-known-good cache; writes this " "session will be queued and synced when the vault returns.") if marker_missing: print("NOTE: marker absent — vault not bootstrapped. Run bootstrap.py before relying on memory.") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client") sub = parser.add_subparsers(dest="cmd", required=True) sub.add_parser("load") sub.add_parser("flush") # H2: replay writes queued during a prior outage sub.add_parser("doctor") # M3: one-call readiness check sub.add_parser("get").add_argument("path") sub.add_parser("map").add_argument("path") sub.add_parser("ls").add_argument("path") sub.add_parser("search").add_argument("query", nargs="+") p = sub.add_parser("put"); p.add_argument("path"); p.add_argument("file", nargs="?") p = sub.add_parser("post"); p.add_argument("path"); p.add_argument("file", nargs="?") p = sub.add_parser("append"); p.add_argument("path"); p.add_argument("line") p = sub.add_parser("patch") p.add_argument("path"); p.add_argument("op"); p.add_argument("target_type") p.add_argument("target"); p.add_argument("file", nargs="?") p = sub.add_parser("fm"); p.add_argument("path"); p.add_argument("field"); p.add_argument("value") p = sub.add_parser("bump"); p.add_argument("path"); p.add_argument("date", nargs="?") sub.add_parser("delete").add_argument("path") sub.add_parser("lock").add_argument("owner") sub.add_parser("unlock").add_argument("owner") sub.add_parser("write-key").add_argument("token") # M1: move the key to ~/.echo-memory/credentials p = sub.add_parser("scope") p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"]) p.add_argument("text", nargs="?") p = sub.add_parser("reflect") # H5: apply a JSON proposal set (dry-run unless --apply) p.add_argument("file", nargs="?") p.add_argument("--apply", action="store_true") # High-level operations (entity index + cross-links + recall). See echo_ops.py. sub.add_parser("resolve").add_argument("mention", nargs="+") p = sub.add_parser("recall"); p.add_argument("query", nargs="+"); p.add_argument("--limit", type=int, default=6) p = sub.add_parser("link"); p.add_argument("a"); p.add_argument("b") p = sub.add_parser("capture") p.add_argument("title") p.add_argument("file", nargs="?") p.add_argument("--kind") p.add_argument("--inbox", action="store_true") p.add_argument("--status", default="") p.add_argument("--aliases", default="") p.add_argument("--source", default="") p.add_argument("--date") p.add_argument("--domain", default="business") p.add_argument("--no-log", action="store_true") p.add_argument("--json", action="store_true") # M4: emit a JSON result envelope p.add_argument("--dry-run", action="store_true") # M4: preview the plan, write nothing return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: if args.cmd == "load": return cmd_load() if args.cmd == "doctor": import echo_doctor return echo_doctor.run() if args.cmd == "flush": import echo_queue n = echo_queue.flush() remaining = len(echo_queue.pending()) if n: print(f"ok: flushed {n} queued write(s)" + (f"; {remaining} still queued (vault unreachable)" if remaining else "")) elif remaining: print(f"ok: {remaining} write(s) still queued (vault unreachable)") else: print("ok: queue empty") return 0 if args.cmd == "write-key": path = echo_secrets.write_key(args.token) print(f"ok: wrote credentials to {path} (chmod 600 best-effort); ECHO_KEY env still overrides") return 0 if args.cmd == "get": return cmd_get(args.path) if args.cmd == "map": return cmd_map(args.path) if args.cmd == "ls": return cmd_ls(args.path) if args.cmd == "search": return cmd_search(args.query) if args.cmd == "put": return cmd_put(args.path, args.file) if args.cmd == "post": return cmd_post(args.path, args.file) if args.cmd == "append": return cmd_append(args.path, args.line) if args.cmd == "patch": return cmd_patch(args.path, args.op, args.target_type, args.target, args.file) if args.cmd == "fm": return cmd_fm(args.path, args.field, args.value) if args.cmd == "bump": return cmd_fm(args.path, "updated", json.dumps(args.date or today())) if args.cmd == "delete": return cmd_delete(args.path) if args.cmd == "lock": return cmd_lock(args.owner) if args.cmd == "unlock": return cmd_unlock(args.owner) if args.cmd == "scope": return cmd_scope(args.subcommand, args.text) if args.cmd == "reflect": import echo_reflect raw = read_body(args.file).decode("utf-8", errors="replace") try: proposals = json.loads(raw) except json.JSONDecodeError as exc: raise EchoError(f"reflect: proposals must be a JSON array ({exc})", 2) if not isinstance(proposals, list): raise EchoError("reflect: proposals must be a JSON array of objects", 2) return echo_reflect.apply(proposals, confirm=args.apply) if args.cmd in ("resolve", "recall", "link", "capture"): import echo_ops # lazy: only the high-level ops pull in the index/link modules if args.cmd == "resolve": return echo_ops.resolve(" ".join(args.mention)) if args.cmd == "recall": return echo_ops.recall(args.query, limit=args.limit) if args.cmd == "link": return echo_ops.link(args.a, args.b) return echo_ops.capture( args.kind, args.title, args.file, status_v=args.status, aliases=args.aliases.split(",") if args.aliases else [], sources=args.source.split(",") if args.source else [], date=args.date, domain=args.domain, inbox=args.inbox, no_log=args.no_log, as_json=args.json, dry_run=args.dry_run) except EchoError as exc: print(f"echo.py: {exc}", file=sys.stderr) return exc.code return 2 if __name__ == "__main__": raise SystemExit(main())