1
0
forked from jason/echo
This commit is contained in:
jason
2026-06-22 09:27:36 -05:00
parent d404f6e96f
commit 1c0c2ea66e
34 changed files with 2530 additions and 149 deletions
@@ -66,9 +66,14 @@ for _stream in (sys.stdout, sys.stderr):
pass
BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/")
# The token stays hardcoded for this personal plugin; ECHO_KEY env still overrides.
# 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
DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab"
KEY = os.environ.get("ECHO_KEY") or DEFAULT_KEY
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"))
@@ -207,10 +212,20 @@ def cmd_search(query: list[str]) -> int:
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 = request("PUT", vault_url(path), data=data,
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))
@@ -222,8 +237,11 @@ def cmd_put(path: str, file_arg: str | None) -> int:
def cmd_post(path: str, file_arg: str | None) -> int:
data = read_body(file_arg)
status, body = request("POST", vault_url(path), data=data,
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
@@ -238,10 +256,15 @@ def cmd_append(path: str, line: str) -> int:
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 = request("POST", vault_url(path), data=f"{line}\n".encode(),
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
@@ -253,7 +276,7 @@ def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str |
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 = request(
status, body = _qwrite(
"PATCH",
vault_url(path),
data=data,
@@ -264,6 +287,9 @@ def cmd_patch(path: str, op: str, target_type: str, target: str, file_arg: str |
"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
@@ -274,7 +300,10 @@ def cmd_fm(path: str, field: str, value: str) -> int:
def cmd_delete(path: str) -> int:
status, body = request("DELETE", vault_url(path))
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
@@ -290,7 +319,7 @@ def parse_lock_time(value: str) -> int:
return int(time.time())
def cmd_lock(owner: str) -> int:
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()
@@ -310,11 +339,12 @@ def cmd_lock(owner: str) -> int:
if held_owner != owner:
print(f"lock race: now held by '{held_owner}', not '{owner}' — backing off", file=sys.stderr)
return 75
print(f"ok: locked by {owner}")
if not quiet:
print(f"ok: locked by {owner}")
return 0
def cmd_unlock(owner: str) -> int:
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()
@@ -324,7 +354,8 @@ def cmd_unlock(owner: str) -> int:
status, body = request("DELETE", vault_url(LOCK_PATH))
if status != 404:
check(status, body, "unlock")
print("ok: unlocked")
if not quiet:
print("ok: unlocked")
return 0
@@ -403,22 +434,67 @@ def cmd_load() -> int:
("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))
print(f"===== {label}: {path} (HTTP {status}) =====")
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
@@ -428,6 +504,8 @@ 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")
@@ -443,9 +521,13 @@ 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("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="+")
@@ -462,6 +544,8 @@ def build_parser() -> argparse.ArgumentParser:
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
@@ -470,6 +554,25 @@ def main(argv: list[str] | None = None) -> int:
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":
@@ -498,6 +601,16 @@ def main(argv: list[str] | None = None) -> int:
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":
@@ -510,7 +623,8 @@ def main(argv: list[str] | None = None) -> int:
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)
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