Files
echo/echo-memory.plugin.src/skills/echo-memory/scripts/echo.py
T

724 lines
31 KiB
Python
Raw Normal View History

2026-06-21 11:46:54 -05:00
#!/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.
2026-06-22 23:55:19 -05:00
Network is keep-alive and connection-pooled (one persistent connection per thread,
reused across requests), and `read_many` fans bulk GETs across a thread pool — so
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.
2026-06-21 11:46:54 -05:00
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
2026-06-22 23:55:19 -05:00
ECHO_TIMEOUT default 30 — per-request socket timeout (seconds)
ECHO_WORKERS default 8 — concurrency for read_many bulk reads
2026-06-21 11:46:54 -05:00
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 <path> # print file contents (404 -> exit 44)
echo.py map <path> # document-map JSON (headings/blocks/frontmatter)
echo.py ls <dir> # directory listing JSON
echo.py search <query...> # /search/simple
echo.py put <path> [file] # create/overwrite (body from file or stdin)
echo.py post <path> [file] # raw append (NON-idempotent; prefer `append`)
echo.py append <path> <line> # idempotent append: skips only on an exact whole-line match
echo.py patch <path> <append|prepend|replace> <heading|frontmatter|block> <target> [file]
echo.py fm <path> <field> <json-value> # PATCH a frontmatter scalar
echo.py bump <path> [YYYY-MM-DD] # set frontmatter updated: to today (or given date)
echo.py delete <path> # DELETE (destructive; explicit use only)
echo.py lock <owner-id> # acquire advisory lock (exit 75 if held & fresh, or lost the race)
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)
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
2026-06-22 23:55:19 -05:00
import http.client
2026-06-21 11:46:54 -05:00
import json
import locale
import os
import sys
import tempfile
2026-06-22 23:55:19 -05:00
import threading
2026-06-21 11:46:54 -05:00
import time
import urllib.parse
2026-06-22 23:55:19 -05:00
from concurrent.futures import ThreadPoolExecutor
2026-06-21 11:46:54 -05:00
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("/")
2026-06-22 09:27:36 -05:00
# 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).
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo_secrets # noqa: E402
2026-06-21 11:46:54 -05:00
DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab"
2026-06-22 09:27:36 -05:00
KEY = echo_secrets.resolve_key(DEFAULT_KEY)
2026-06-21 11:46:54 -05:00
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
2026-06-22 23:55:19 -05:00
# Per-request socket timeout. A single stuck file fails fast instead of stalling a
# full-vault pass; with read_many concurrency it only blocks one worker, not the run.
TIMEOUT = int(os.environ.get("ECHO_TIMEOUT", "30"))
# Concurrency for bulk reads (read_many). I/O-bound, so threads bypass the GIL fine.
MAX_WORKERS = max(1, int(os.environ.get("ECHO_WORKERS", "8")))
2026-06-21 11:46:54 -05:00
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}"
2026-06-22 23:55:19 -05:00
# --- persistent connection pool (keep-alive) ---------------------------------
# urllib.urlopen opened a fresh TCP+TLS connection per call; against a remote HTTPS
# endpoint the repeated handshake dominated, so a full-vault pass made hundreds of
# serial handshakes and blew past the tool/sandbox timeout. We keep ONE connection
# per thread (http.client is not thread-safe, so each thread — main + every pool
# worker — owns its own) and reuse it across requests. read_many fans out over a
# thread pool, so the pool's connections stay warm for the duration of a sweep.
_local = threading.local()
MAX_ATTEMPTS = 3
def _new_connection() -> http.client.HTTPConnection:
parts = urllib.parse.urlsplit(BASE)
host = parts.hostname or parts.path
if parts.scheme == "http":
return http.client.HTTPConnection(host, parts.port or 80, timeout=TIMEOUT)
return http.client.HTTPSConnection(host, parts.port or 443, timeout=TIMEOUT)
def _connection() -> http.client.HTTPConnection:
conn = getattr(_local, "conn", None)
if conn is None:
conn = _new_connection()
_local.conn = conn
return conn
def _drop_connection() -> None:
conn = getattr(_local, "conn", None)
if conn is not None:
try:
conn.close()
except Exception:
pass
_local.conn = None
2026-06-21 11:46:54 -05:00
def request(method: str, url: str, data: bytes | None = None,
headers: dict[str, str] | None = None) -> tuple[int, bytes]:
2026-06-22 23:55:19 -05:00
"""Issue one request over this thread's reused connection. Returns (status, body);
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."""
2026-06-21 11:46:54 -05:00
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
2026-06-22 23:55:19 -05:00
parts = urllib.parse.urlsplit(url)
target = parts.path or "/"
if parts.query:
target += "?" + parts.query
2026-06-21 11:46:54 -05:00
last_status, last_body = 0, b""
2026-06-22 23:55:19 -05:00
for attempt in range(MAX_ATTEMPTS):
2026-06-21 11:46:54 -05:00
try:
2026-06-22 23:55:19 -05:00
conn = _connection()
conn.request(method, target, body=data, headers=merged)
resp = conn.getresponse()
body = resp.read() # drain fully so the connection can be reused
status = resp.status
if status >= 500 and attempt < MAX_ATTEMPTS - 1:
_drop_connection()
2026-06-21 11:46:54 -05:00
time.sleep(1)
continue
2026-06-22 23:55:19 -05:00
return status, body
except (http.client.RemoteDisconnected, http.client.BadStatusLine,
ConnectionResetError, BrokenPipeError) as exc:
# The server closed an idle keep-alive connection between requests.
# Expected, not a failure: reconnect and retry without backing off.
_drop_connection()
2026-06-21 11:46:54 -05:00
last_status, last_body = 0, str(exc).encode()
2026-06-22 23:55:19 -05:00
continue
except Exception as exc: # timeout, DNS, refused, TLS, ...
_drop_connection()
last_status, last_body = 0, str(exc).encode()
if attempt < MAX_ATTEMPTS - 1:
2026-06-21 11:46:54 -05:00
time.sleep(1)
continue
return last_status, last_body
2026-06-22 23:55:19 -05:00
def get_text(path: str) -> str | None:
"""GET a vault path -> decoded text, or None on 404/any error. Never raises, so a
single unreadable note can't abort a bulk pass (resilience for sweep/lint)."""
try:
status, body = request("GET", vault_url(path))
except Exception:
return None
return body.decode(errors="replace") if status == 200 else None
def read_many(paths) -> dict[str, str | None]:
"""Concurrently GET many vault paths -> {path: text_or_None}. The single biggest
win for full-vault scripts: it turns hundreds of serial round-trips into MAX_WORKERS
parallel ones over warm keep-alive connections. Resilient — a bad file maps to None."""
unique = list(dict.fromkeys(paths))
if not unique:
return {}
workers = min(MAX_WORKERS, len(unique))
with ThreadPoolExecutor(max_workers=workers) as pool:
return dict(zip(unique, pool.map(get_text, unique)))
2026-06-21 11:46:54 -05:00
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
2026-06-22 09:27:36 -05:00
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)
2026-06-21 11:46:54 -05:00
def cmd_put(path: str, file_arg: str | None) -> int:
data = read_body(file_arg)
2026-06-22 09:27:36 -05:00
status, body = _qwrite("PUT", vault_url(path), data=data,
2026-06-21 11:46:54 -05:00
headers={"Content-Type": "text/markdown"})
2026-06-22 09:27:36 -05:00
if status == 202:
print(f"queued (offline): PUT {path} — will sync on the next reachable session")
return 0
2026-06-21 11:46:54 -05:00
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)
2026-06-22 09:27:36 -05:00
status, body = _qwrite("POST", vault_url(path), data=data,
2026-06-21 11:46:54 -05:00
headers={"Content-Type": "text/markdown"})
2026-06-22 09:27:36 -05:00
if status == 202:
print(f"queued (offline): POST {path} — will sync on the next reachable session")
return 0
2026-06-21 11:46:54 -05:00
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
2026-06-22 09:27:36 -05:00
elif status == 0:
pass # offline: skip the idempotency read; queue the POST (flush dedups by content)
2026-06-21 11:46:54 -05:00
elif status != 404:
check(status, body, f"append(read) {path}")
2026-06-22 09:27:36 -05:00
status, body = _qwrite("POST", vault_url(path), data=f"{line}\n".encode(),
2026-06-21 11:46:54 -05:00
headers={"Content-Type": "text/markdown"})
2026-06-22 09:27:36 -05:00
if status == 202:
print(f"queued (offline): APPEND {path} — will sync on the next reachable session")
return 0
2026-06-21 11:46:54 -05:00
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)
2026-06-22 09:27:36 -05:00
status, body = _qwrite(
2026-06-21 11:46:54 -05:00
"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",
},
)
2026-06-22 09:27:36 -05:00
if status == 202:
print(f"queued (offline): PATCH {op} '{target}' -> {path} — will sync on the next reachable session")
return 0
2026-06-21 11:46:54 -05:00
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:
2026-06-22 09:27:36 -05:00
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
2026-06-21 11:46:54 -05:00
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())
2026-06-22 09:27:36 -05:00
def cmd_lock(owner: str, quiet: bool = False) -> int:
2026-06-21 11:46:54 -05:00
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
2026-06-22 09:27:36 -05:00
if not quiet:
print(f"ok: locked by {owner}")
2026-06-21 11:46:54 -05:00
return 0
2026-06-22 09:27:36 -05:00
def cmd_unlock(owner: str, quiet: bool = False) -> int:
2026-06-21 11:46:54 -05:00
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")
2026-06-22 09:27:36 -05:00
if not quiet:
print("ok: unlocked")
2026-06-21 11:46:54 -05:00
return 0
def extract_heading(markdown: str, heading: str) -> str:
"""Return the body under a `## <heading>` 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 '<missing — drift cannot be detected; run scope set or repair>'}")
# 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 \"<text>\"'", 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"),
]
2026-06-22 09:27:36 -05:00
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)
2026-06-21 11:46:54 -05:00
marker_missing = False
2026-06-22 09:27:36 -05:00
heartbeat_absent = False
offline = False
2026-06-21 11:46:54 -05:00
for label, path in targets:
status, body = request("GET", vault_url(path))
if status == 200:
2026-06-22 09:27:36 -05:00
echo_queue.cache_put(path, body) # refresh last-known-good for offline fallback
print(f"===== {label}: {path} (HTTP 200) =====")
2026-06-21 11:46:54 -05:00
text = body.decode(errors="replace")
sys.stdout.write(text)
if not text.endswith("\n"):
sys.stdout.write("\n")
elif status == 404:
2026-06-22 09:27:36 -05:00
print(f"===== {label}: {path} (HTTP 404) =====")
2026-06-21 11:46:54 -05:00
print("(absent — fine)")
if label == "marker":
marker_missing = True
2026-06-22 09:27:36 -05:00
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)")
2026-06-21 11:46:54 -05:00
else:
2026-06-22 09:27:36 -05:00
print(f"===== {label}: {path} (HTTP {status}) =====")
2026-06-21 11:46:54 -05:00
print(f"(error HTTP {status}: {body.decode(errors='replace')[:200]})")
print()
2026-06-22 09:27:36 -05:00
# 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.")
2026-06-21 11:46:54 -05:00
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")
2026-06-22 09:27:36 -05:00
sub.add_parser("flush") # H2: replay writes queued during a prior outage
sub.add_parser("doctor") # M3: one-call readiness check
2026-06-21 11:46:54 -05:00
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")
2026-06-22 09:27:36 -05:00
sub.add_parser("write-key").add_argument("token") # M1: move the key to ~/.echo-memory/credentials
2026-06-21 11:46:54 -05:00
p = sub.add_parser("scope")
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
p.add_argument("text", nargs="?")
2026-06-22 09:27:36 -05:00
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")
2026-06-21 11:46:54 -05:00
# 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")
2026-06-22 09:27:36 -05:00
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
2026-06-21 11:46:54 -05:00
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
if args.cmd == "load":
return cmd_load()
2026-06-22 09:27:36 -05:00
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
2026-06-21 11:46:54 -05:00
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)
2026-06-22 09:27:36 -05:00
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)
2026-06-21 11:46:54 -05:00
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 [],
2026-06-22 09:27:36 -05:00
date=args.date, domain=args.domain, inbox=args.inbox, no_log=args.no_log,
as_json=args.json, dry_run=args.dry_run)
2026-06-21 11:46:54 -05:00
except EchoError as exc:
print(f"echo.py: {exc}", file=sys.stderr)
return exc.code
return 2
if __name__ == "__main__":
raise SystemExit(main())