1
0
forked from jason/echo

ver 1.3 and 1.3.1

This commit is contained in:
Jason Stedwell
2026-06-23 22:17:39 -05:00
parent 1881d2b105
commit b4605a52f2
53 changed files with 549 additions and 297 deletions
@@ -19,9 +19,11 @@ reused across requests), and `read_many` fans bulk GETs across a thread pool —
full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips
instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts.
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)
Config (owner/endpoint/key are machine-local — see echo_config; the plugin ships none):
owner/endpoint/key resolved by echo_config from ~/.claude/echo-memory/config.json
(env ECHO_OWNER / ECHO_BASE / ECHO_KEY override per field).
Set up a machine with `echo.py config init` then edit, or
`echo.py config set --owner ... --endpoint ... --key ...`.
ECHO_VERIFY default 1 — read-back verify after a PUT
ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
ECHO_TIMEOUT default 30 — per-request socket timeout (seconds)
@@ -45,8 +47,9 @@ Usage:
echo.py unlock <owner-id> # release advisory lock if owned by <owner-id>
echo.py scope show # print active scope, its freshness, and sessions-since
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
echo.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 2 usage · 1 other HTTP/transport error.
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 78 config-required · 2 usage · 1 other HTTP/transport error.
"""
from __future__ import annotations
@@ -73,15 +76,17 @@ for _stream in (sys.stdout, sys.stderr):
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 <token>`; remove DEFAULT_KEY before the 1.0 tag (see ROADMAP-1.0.md > M1).
# Owner / endpoint / key are machine-local and resolved by echo_config from
# ~/.claude/echo-memory/config.json (env ECHO_OWNER / ECHO_BASE / ECHO_KEY override).
# The plugin ships none of them; load() returns "" for anything unset so --help,
# `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key
# are missing (see _require_endpoint / request).
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo_secrets # noqa: E402
DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab"
KEY = echo_secrets.resolve_key(DEFAULT_KEY)
import echo_config # noqa: E402
_CFG = echo_config.load()
BASE = _CFG["endpoint"]
KEY = _CFG["key"]
OWNER = _CFG["owner"]
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
# Per-request socket timeout. A single stuck file fails fast instead of stalling a
@@ -125,6 +130,19 @@ _local = threading.local()
MAX_ATTEMPTS = 3
def _require_config() -> None:
"""Fail loudly (not with an opaque DNS/connection error) when this machine has no
usable key file yet — the most common fresh-install state. Also catches an
untouched `config init` template (placeholder endpoint/key)."""
if not echo_config.is_configured(_CFG):
raise EchoError(
"echo: NOT CONFIGURED — no usable ECHO key file on this machine. Ask the "
f"operator for their config (owner/endpoint/key) and install it at "
f"{echo_config.config_path()}: `echo.py config import <file>`, or "
"`echo.py config set --owner ... --endpoint ... --key ...` "
"(or set ECHO_BASE / ECHO_KEY).", 2)
def _new_connection() -> http.client.HTTPConnection:
parts = urllib.parse.urlsplit(BASE)
host = parts.hostname or parts.path
@@ -157,6 +175,7 @@ def request(method: str, url: str, data: bytes | None = None,
status 0 == transport failure (echo_queue relies on this to trigger offline queueing).
Retries: a stale pooled connection reconnects immediately; 5xx and other transport
errors get one bounded sleep-retry, same as before."""
_require_config()
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
parts = urllib.parse.urlsplit(url)
target = parts.path or "/"
@@ -514,6 +533,15 @@ def cmd_scope(subcommand: str, text: str | None = None) -> int:
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)."""
if not echo_config.is_configured(_CFG):
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
print(f"Expected at: {echo_config.config_path()}")
print("ACTION: ask the operator for their echo-memory config file (it holds the")
print(" vault owner, endpoint, and API key), then install it with ONE of:")
print(" python3 echo.py config import <path-to-their-file>")
print(" python3 echo.py config set --owner \"\" --endpoint \"https://…\" --key \"\"")
print("Then re-run load. (Memory is unavailable until configured.)")
return 78 # distinct: configuration required
targets = [
("marker", "_agent/echo-vault.md"),
("preferences", "_agent/memory/semantic/operator-preferences.md"),
@@ -588,6 +616,44 @@ def cmd_load() -> int:
return 0
def cmd_config(args) -> int:
"""show | init | set the machine-local owner/endpoint/key config."""
path = echo_config.config_path()
if args.action == "init":
p, created = echo_config.init_template()
print(f"ok: wrote config template to {p} — edit it with your owner/endpoint/key"
if created else f"config already exists at {p} (left unchanged)")
return 0
if args.action == "import":
if not args.path:
print("config import: pass the path to a config file "
"(JSON with owner/endpoint/key)", file=sys.stderr)
return 2
try:
p = echo_config.import_file(args.path)
except RuntimeError as exc:
print(exc, file=sys.stderr)
return 2
print(f"ok: imported config to {p} (chmod 600 best-effort)")
return 0
if args.action == "set":
if args.owner is None and args.endpoint is None and args.key is None:
print("config set: pass at least one of --owner / --endpoint / --key", file=sys.stderr)
return 2
p = echo_config.write_config(args.owner, args.endpoint, args.key)
print(f"ok: updated {p} (chmod 600 best-effort)")
return 0
# show — never prints the key in the clear
cfg = echo_config.load()
key = cfg["key"]
redacted = (key[:4] + "" + key[-4:]) if len(key) >= 12 else ("set" if key else "")
print(f"config file: {path}" + ("" if path.exists() else " (absent)"))
for field, shown in (("owner", cfg["owner"]), ("endpoint", cfg["endpoint"]), ("key", redacted)):
src = echo_config.source(field)
print(f" {field:9} {shown or '(unset)'} [{src}]")
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)
@@ -609,7 +675,10 @@ def build_parser() -> argparse.ArgumentParser:
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("config") # machine-local owner/endpoint/key (see echo_config)
p.add_argument("action", nargs="?", default="show", choices=["show", "init", "set", "import"])
p.add_argument("path", nargs="?") # for `config import <path>`
p.add_argument("--owner"); p.add_argument("--endpoint"); p.add_argument("--key")
p = sub.add_parser("scope")
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
p.add_argument("text", nargs="?")
@@ -657,10 +726,8 @@ def main(argv: list[str] | None = None) -> int:
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 == "config":
return cmd_config(args)
if args.cmd == "get":
return cmd_get(args.path)
if args.cmd == "map":
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""echo_config.py — resolve the vault owner, endpoint, and API key for ECHO.
The plugin ships NO owner, endpoint, or key — it is fully user-agnostic. On each
machine, a machine-local JSON config supplies them:
~/.claude/echo-memory/config.json
{
"owner": "Full Name (how the agent refers to the vault owner, third person)",
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
"key": "<obsidian-local-rest-api-bearer-token>"
}
This file is per-machine: it must be created on each fresh install, is never
committed, and is never written into a vault note. To create it, run
`python3 echo.py config init` (writes a placeholder template when absent) and edit
it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`.
Per-field resolution (first hit wins):
owner env ECHO_OWNER -> config "owner"
endpoint env ECHO_BASE -> config "endpoint"
key env ECHO_KEY -> config "key"
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
TEMPLATE = {
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
"key": "<obsidian-local-rest-api-bearer-token>",
}
def claude_dir() -> Path:
"""The Claude config directory ($CLAUDE_CONFIG_DIR, else ~/.claude)."""
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude"))
def config_path() -> Path:
"""Absolute path to the machine-local config file."""
override = os.environ.get("ECHO_CONFIG")
if override:
return Path(override).expanduser()
return claude_dir() / "echo-memory" / "config.json"
def _from_file() -> dict:
p = config_path()
if not p.exists():
return {}
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {}
return data if isinstance(data, dict) else {}
def load() -> dict:
"""Return {owner, endpoint, key} with env overriding the file. Missing -> "".
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key(),
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable
even when nothing is configured yet."""
f = _from_file()
endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or "").rstrip("/")
return {
"owner": os.environ.get("ECHO_OWNER") or f.get("owner") or "",
"endpoint": endpoint,
"key": os.environ.get("ECHO_KEY") or f.get("key") or "",
}
def _missing(field: str, env: str) -> RuntimeError:
return RuntimeError(
f"echo: no {field} configured. Set {env}, or create {config_path()} "
f"(run `echo.py config init` to scaffold it, then edit). "
f'Expected JSON keys: "owner", "endpoint", "key".'
)
def resolve_endpoint() -> str:
ep = load()["endpoint"]
if not ep:
raise _missing("endpoint", "ECHO_BASE")
return ep
def resolve_key() -> str:
k = load()["key"]
if not k:
raise _missing("key", "ECHO_KEY")
return k
def resolve_owner() -> str:
"""The owner display name. Descriptive only, so an empty value is tolerated."""
return load()["owner"]
def is_configured(cfg: dict | None = None) -> bool:
"""True only when endpoint AND key are present and NOT the untouched template
placeholders — so a freshly `config init`-ed (but unedited) file reads as not yet
configured, which is what triggers the first-run "ask for the key file" prompt."""
c = cfg if cfg is not None else load()
ep, key = c.get("endpoint", ""), c.get("key", "")
if not ep or not key:
return False
if "your-obsidian-rest-endpoint" in ep or key.startswith("<"):
return False
return True
def source(field: str) -> str:
"""Where a field is currently coming from — for `config`/`doctor` reporting."""
env = {"owner": "ECHO_OWNER", "endpoint": "ECHO_BASE", "key": "ECHO_KEY"}[field]
if os.environ.get(env):
return f"{env} env"
if config_path().exists() and _from_file().get(field):
return "config file"
return "unset"
def template_text() -> str:
return json.dumps(TEMPLATE, indent=2, ensure_ascii=False) + "\n"
def _secure(p: Path) -> None:
try:
os.chmod(p, 0o600) # advisory on filesystems without POSIX mode bits (e.g. Windows)
except OSError:
pass
def write_config(owner: str, endpoint: str, key: str) -> Path:
"""Persist a filled-in config (merging over any existing values), chmod 0600."""
cur = _from_file()
merged = {
"owner": owner if owner is not None else cur.get("owner", ""),
"endpoint": (endpoint if endpoint is not None else cur.get("endpoint", "")).rstrip("/"),
"key": key if key is not None else cur.get("key", ""),
}
p = config_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
_secure(p)
return p
def import_file(src) -> Path:
"""Adopt a config file someone was handed: validate it has a usable endpoint+key,
then copy it into config_path(). The 'provide the file -> plugin installs it' path."""
p = Path(src).expanduser()
if not p.exists():
raise RuntimeError(f"config import: file not found: {p}")
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise RuntimeError(f"config import: {p} is not valid JSON ({exc})")
if not isinstance(data, dict) or not is_configured(data):
raise RuntimeError(
'config import: file must be JSON with a real "endpoint" and "key" '
'(and ideally "owner") — not the template placeholders.')
return write_config(data.get("owner", ""), data["endpoint"], data["key"])
def init_template() -> tuple[Path, bool]:
"""Write the placeholder template to config_path() if absent.
Returns (path, created) — created is False when a config already exists."""
p = config_path()
if p.exists():
return p, False
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(template_text(), encoding="utf-8")
_secure(p)
return p, True
@@ -13,7 +13,6 @@ Exit: 0 all green · 1 a check is red.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
@@ -31,13 +30,35 @@ def run() -> int:
reds += 0 if ok else 1
print(f" [{'OK ' if ok else 'RED'}] {label}" + (f"{detail}" if detail else ""))
print(f"echo doctor — endpoint {echo.BASE}")
import echo_config
cfg = echo_config.load()
print(f"echo doctor — endpoint {cfg['endpoint'] or '(not configured)'}")
# 1. interpreter
line(sys.version_info >= MIN_PY, f"python >= {MIN_PY[0]}.{MIN_PY[1]}",
f"running {sys.version_info.major}.{sys.version_info.minor}")
# 2. reachability + auth + marker, in one GET of the bootstrap marker
# 2. machine-local config (owner/endpoint/key). Without a usable endpoint+key
# nothing else can run, so report it clearly before touching the network.
# A still-placeholder `config init` template counts as not configured.
ep_real = bool(cfg["endpoint"]) and "your-obsidian-rest-endpoint" not in cfg["endpoint"]
key_real = bool(cfg["key"]) and not cfg["key"].startswith("<")
line(ep_real, "endpoint configured",
f"[{echo_config.source('endpoint')}]" if ep_real
else (f"placeholder — edit {echo_config.config_path()}" if cfg["endpoint"]
else f"create {echo_config.config_path()} (run `echo.py config init`)"))
line(key_real, "API key configured",
f"[{echo_config.source('key')}]" if key_real
else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `echo.py config`"))
line(bool(cfg["owner"]), "vault owner set",
cfg["owner"] if cfg["owner"] else "optional, but recommended for third-person writes")
if not echo_config.is_configured(cfg):
print("\ndoctor: not configured — ask the operator for their key file and install it "
"(`echo.py config import <file>`, or `config set --owner … --endpoint … --key …`), "
"then re-run.")
return 1
# 3. reachability + auth + marker, in one GET of the bootstrap marker
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
if status == 0:
line(False, "vault reachable", body.decode(errors="replace")[:120])
@@ -54,13 +75,7 @@ def run() -> int:
else:
line(status < 400, "marker fetch", f"HTTP {status}")
# 3. key source (M1) + invariants pointer.
import echo_secrets
key_src = ("ECHO_KEY env" if os.environ.get("ECHO_KEY")
else "credentials file" if (echo_secrets._state_dir() / "credentials").exists()
else "baked-in fallback (deprecated — see API-KEY-SETUP.md)")
line(os.environ.get("ECHO_KEY") is not None or (echo_secrets._state_dir() / "credentials").exists(),
"API key source", key_src)
# 4. invariants pointer.
print(" [i] invariants — run `/echo-health` (vault_lint.py) for the full check")
print(f"\ndoctor: {'all green' if reds == 0 else f'{reds} issue(s) — see RED above'}")
@@ -21,7 +21,7 @@ import re
MIN_NAME_LEN = 4
_COMMON = frozenset(
"api app data note task user team work code test main repo file plan goal item "
"the and for with mpm".split()
"the and for with from".split()
)
@@ -18,7 +18,7 @@ PROPOSAL_SCHEMA (one per durable item the agent wants to remember):
{
"title": "Bob Smith", # required
"kind": "person", # required unless "inbox": true
"body": "Principal at MPM; ...", # markdown body (optional)
"body": "Principal at Acme; ...", # markdown body (optional)
"aliases": ["bob", "rs"], # optional
"sources": ["_agent/sessions/..."], # optional backward links
"date": "YYYY-MM-DD", # optional (meeting/decision kinds)
@@ -1,83 +0,0 @@
#!/usr/bin/env python3
"""echo_secrets.py — M1: resolve the API bearer token without shipping it in source.
[v1.0 SCAFFOLD — resolve_key() implemented; wiring into echo.py is the integration step]
Problem: the token is hardcoded at echo.py:70 and repeated through api-reference.md /
README — committed to git and shipped inside every .plugin zip, rotatable only by a
rebuild. The plugin's own safety rule is "never store secrets". This resolves the key
from progressively less-trusted sources, with a loud warning if the legacy baked
default is ever reached.
RESOLUTION ORDER (first hit wins):
1. env ECHO_KEY — preferred; CI / per-session override.
2. ECHO_STATE_DIR/credentials — local file, one `key=...` line, chmod 0600.
3. legacy baked default (deprecated) — warns to stderr; remove before 1.0 ship.
Pure stdlib only (no `keyring`); the local file is the cross-platform stand-in for an
OS keychain and keeps the secret OUT of the tracked tree.
INTEGRATION (do at merge time):
echo.py: KEY = echo_secrets.resolve_key()
docs: replace the literal token with `<ECHO_KEY>` placeholders
rotate: document `echo write-key` (writes the credentials file) — see TODO below
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Kept ONLY so a fresh install still works during the migration window; delete for 1.0.
_LEGACY_DEFAULT = "" # intentionally blank in the scaffold — do not re-introduce the literal
def _state_dir() -> Path:
return Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory"))
def _from_file() -> str | None:
p = _state_dir() / "credentials"
if not p.exists():
return None
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("key=") or line.startswith("ECHO_KEY="):
return line.split("=", 1)[1].strip()
return None
def resolve_key(legacy: str | None = None) -> str:
"""Return the bearer token from the most trusted available source:
ECHO_KEY env -> credentials file -> `legacy` baked-in fallback (with a loud, once-per-
process warning that can be silenced via ECHO_KEY_LEGACY_OK=1). Raises if none exist."""
env = os.environ.get("ECHO_KEY")
if env:
return env
from_file = _from_file()
if from_file:
return from_file
legacy = legacy or _LEGACY_DEFAULT
if legacy:
if os.environ.get("ECHO_KEY_LEGACY_OK") != "1":
print("echo_secrets: WARNING — using the deprecated baked-in API key. Run "
"`echo.py write-key <token>` (stores ~/.echo-memory/credentials) or set "
"ECHO_KEY; set ECHO_KEY_LEGACY_OK=1 to silence.", file=sys.stderr)
return legacy
raise RuntimeError(
"echo_secrets: no API key found. Set ECHO_KEY or create "
f"{_state_dir() / 'credentials'} with a 'key=<token>' line."
)
def write_key(token: str) -> Path:
"""Persist the token to the local credentials file (one `key=...` line, chmod 0600).
Best-effort perms — Windows has no POSIX mode bits, so the chmod is advisory there."""
d = _state_dir()
d.mkdir(parents=True, exist_ok=True)
path = d / "credentials"
path.write_text(f"key={token.strip()}\n", encoding="utf-8")
try:
os.chmod(path, 0o600)
except OSError:
pass # platform without POSIX perms (e.g. some Windows filesystems)
return path
@@ -158,9 +158,9 @@ def test_aliases_in_frontmatter_flow_and_block() -> None:
def test_links_add_related_is_idempotent_and_bidirectional_token() -> None:
text = "---\ntype: person\n---\n\n# Bob\n\n## Related\n"
new, changed = echo_links.add_related(text, "resources/companies/mpm.md")
assert changed and "[[resources/companies/mpm]]" in new
again, changed2 = echo_links.add_related(new, "resources/companies/mpm.md")
new, changed = echo_links.add_related(text, "resources/companies/acme.md")
assert changed and "[[resources/companies/acme]]" in new
again, changed2 = echo_links.add_related(new, "resources/companies/acme.md")
assert not changed2 and again == new
@@ -76,19 +76,45 @@ def main() -> int:
check("H5 classify/preview/apply wired",
all(callable(getattr(rf, n, None)) for n in ("classify", "preview", "apply")))
# M1 — secret resolution order is implemented; env wins, then the credentials file.
import echo_secrets as s
os.environ["ECHO_KEY"] = "env-key-123"
check("M1 env ECHO_KEY wins", s.resolve_key("legacy-fallback") == "env-key-123")
with tempfile.TemporaryDirectory() as d:
os.environ["ECHO_STATE_DIR"] = d
os.environ.pop("ECHO_KEY", None)
s.write_key("file-key-xyz")
check("M1 write_key -> credentials -> resolve_key reads it", s.resolve_key() == "file-key-xyz")
os.environ["ECHO_KEY_LEGACY_OK"] = "1" # silence the warning for the legacy-path check
os.environ["ECHO_STATE_DIR"] = os.path.join(d, "empty") # an empty dir: no creds file
check("M1 legacy fallback when env+file absent", s.resolve_key("legacy-fallback") == "legacy-fallback")
os.environ["ECHO_KEY"] = "env-key-123" # restore for any later checks
# Config — owner/endpoint/key resolve from env -> machine-local config.json (no baked
# defaults). Env wins per field; the file fills the rest; missing required -> raises.
import echo_config as cfg
saved_env = {k: os.environ.get(k) for k in ("ECHO_KEY", "ECHO_BASE", "ECHO_OWNER", "ECHO_CONFIG")}
try:
for k in saved_env:
os.environ.pop(k, None)
with tempfile.TemporaryDirectory() as d:
cfgfile = os.path.join(d, "config.json")
os.environ["ECHO_CONFIG"] = cfgfile
# init writes the placeholder template only when absent.
p, created = cfg.init_template()
check("config init scaffolds the template", created and os.path.exists(cfgfile))
p2, created2 = cfg.init_template()
check("config init is idempotent (no clobber)", created2 is False)
# write_config persists a filled-in config; load() reads it back.
cfg.write_config("Owner Name", "https://obsidian.example.com/", "file-key-xyz")
loaded = cfg.load()
check("config file round-trips owner/endpoint/key",
loaded == {"owner": "Owner Name",
"endpoint": "https://obsidian.example.com", # trailing / stripped
"key": "file-key-xyz"})
# env overrides the file, per field.
os.environ["ECHO_KEY"] = "env-key-123"
check("env ECHO_KEY overrides config", cfg.resolve_key() == "env-key-123")
os.environ.pop("ECHO_KEY", None)
# missing required field raises a helpful error.
cfg.write_config("Owner Name", "", "")
try:
cfg.resolve_key()
check("missing key raises", False)
except RuntimeError:
check("missing key raises", True)
finally:
for k, v in saved_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
# M2 — slug collision + link-confidence helpers are implemented.
import echo_quality as qual