ver 1.0
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user