Files
echo/echo-memory.plugin.src/skills/echo-memory/scripts/echo_queue.py
T
Jason Stedwell 446cd9a0ff 2.1.0 — quick-wins train: brief load, offline-durable capture, session-end verb
Three independently useful changes (IMPROVEMENT-PLANS #1/#3/#7), no schema changes:

- load --brief: token-budgeted cold-start digest (facts full, last-10 observations,
  scope+freshness, last session's key sections, agent-log lines, inbox count;
  ECHO_LOAD_BUDGET ~8000 chars, lowest-priority-first trimming). SessionStart hook
  injects the brief form; /echo-load keeps the full dump. All load reads (+ the
  sessions listing, which also feeds the heartbeat fallback) fetched in parallel.
- Offline capture durability: a fully-offline capture queues the WHOLE op as one
  semantic record; flush replays it through capture so routing/gate/aliasing re-run
  against the current index; a gate stop on replay is kept + flagged
  (needs_attention, surfaced by flush and load), never landed blind; update-path
  and ensure_daily_log writes ride safe_request; same-args captures dedupe.
- session-end: one call, one lock — session log -> agent-log line -> reflect
  proposals (gate-aware) -> optional scope set -> heartbeat LAST as the commit
  marker; dry-run default; ECHO_NOW pins HHMM; validation runs before any write.
  Stop hook nudge names the command and recognizes it as reflected.
- Fix: main() now catches the __main__ twin-module EchoError (via RuntimeError +
  .code) so helper-module errors exit with their intended codes, not tracebacks.

+19 e2e checks; all suites green. Plans renumbered: MCP container is 2.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 22:34:42 -05:00

272 lines
11 KiB
Python

#!/usr/bin/env python3
"""echo_queue.py — H2: offline write-ahead queue + read cache. [v1.0 — wired]
Today vault-unreachable => "proceed without memory" (SKILL.md), so a 502 (Obsidian not
running — the most common real failure) loses everything said that session. This module
makes explicit writes durable across an outage and lets cold-start `load` degrade to
last-known context instead of nothing.
DESIGN:
* Outbox : append-only NDJSON of intended writes. Replayed in order on the next
reachable session. Replay is idempotent by construction — PUT/DELETE/PATCH-
replace overwrite, and POST / PATCH-append are content-deduped (skip if the
line/block is already present), the same strategy echo.py append uses. Each
record carries an idem_key so the SAME write is never queued twice.
* Cache : last-known-good GET bodies, so `load` has a fallback. Written by cmd_load.
* Location: a LOCAL state dir — it CANNOT live in the vault, because the vault is the
thing that's down. Default ~/.echo-memory/, override ECHO_STATE_DIR.
Integration seam: `safe_request()` — the write verbs (cmd_put/post/append/patch/delete)
call it instead of echo.request; on an outage it enqueues and returns a synthesized
(202, b"queued ...") so the caller reports "queued, will sync" instead of failing.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import sys
import urllib.parse
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import echo # noqa: E402
MUTATING = {"PUT", "POST", "PATCH", "DELETE"}
def state_dir() -> Path:
return Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory"))
def outbox_path() -> Path:
return state_dir() / "outbox.ndjson"
def cache_dir() -> Path:
return state_dir() / "cache"
def _ensure_dirs() -> None:
state_dir().mkdir(parents=True, exist_ok=True)
cache_dir().mkdir(parents=True, exist_ok=True)
def _key(method: str, url: str, data: bytes | None) -> str:
h = hashlib.sha1()
h.update(method.encode())
h.update(b"\0")
h.update(url.encode())
h.update(b"\0")
h.update(data or b"")
return h.hexdigest()
# --------------------------------------------------------------------- outbox
def enqueue(method: str, url: str, data: bytes | None, headers: dict, idem_key: str | None = None) -> None:
"""Append one intended write to the outbox (NDJSON; base64 body for binary safety).
Dedups: if a record with the same idem_key is already pending, do nothing."""
idem_key = idem_key or _key(method, url, data)
if any(rec.get("idem_key") == idem_key for rec in pending()):
return
_ensure_dirs()
rec = {
"method": method.upper(), "url": url,
"body_b64": base64.b64encode(data).decode() if data else None,
"headers": headers or {}, "idem_key": idem_key,
# No wall-clock: replay order is file order, not a timestamp (keeps this testable).
}
with outbox_path().open("a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
def enqueue_capture(args: dict) -> None:
"""Queue a whole capture as ONE semantic record (2.1.0). Byte-level replay is wrong
for the routed capture path: the create-vs-update decision, duplicate gate, and
aliasing must re-run against the index AS IT IS AT REPLAY TIME, so flush re-invokes
echo_ops.capture with the recorded arguments instead of replaying stale requests.
`args` carries body_text inline (never a temp-file path) plus `today` (the capture-
time ECHO_TODAY) so replayed frontmatter/log dates reflect when it was said."""
key = "capture:" + hashlib.sha1(
json.dumps(args, sort_keys=True, ensure_ascii=False).encode("utf-8")).hexdigest()
if any(rec.get("idem_key") == key for rec in pending()):
return
_ensure_dirs()
rec = {"op": "capture", "args": args, "idem_key": key}
with outbox_path().open("a", encoding="utf-8") as fh:
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
def pending() -> list[dict]:
p = outbox_path()
if not p.exists():
return []
return [json.loads(ln) for ln in p.read_text(encoding="utf-8").splitlines() if ln.strip()]
def needs_attention() -> list[dict]:
"""Queued records that replay could not land automatically (e.g. a duplicate-gate
stop) — surfaced by `flush` and `load` so the operator resolves them deliberately."""
return [rec for rec in pending() if rec.get("needs_attention")]
def _rewrite(records: list[dict]) -> None:
p = outbox_path()
if not records:
if p.exists():
p.unlink()
return
_ensure_dirs()
tmp = p.with_suffix(".ndjson.tmp")
tmp.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records), encoding="utf-8")
os.replace(tmp, p) # atomic swap so a crash mid-flush can't corrupt the queue
def _rebase(url: str) -> str:
"""Re-point a queued URL at the CURRENT echo.BASE. Writes are queued with the base
that was active when they failed; on replay the vault may be back at a different base
(e.g. an endpoint migration), so we always reconstruct the origin from echo.BASE."""
parts = urllib.parse.urlsplit(url)
return echo.BASE + parts.path + (("?" + parts.query) if parts.query else "")
def _replay_capture(rec: dict) -> str:
"""Replay a queued semantic capture through echo_ops.capture against the CURRENT
index. Returns 'ok', 'retry' (still offline), or 'gated' (duplicate gate / needs
the operator — the record is kept and flagged, later records still replay)."""
# Cheap reachability probe FIRST: if the vault is still down, echo_ops.capture's
# own offline short-circuit would try to re-enqueue this very record — probe and
# bail as 'retry' instead of recursing into the queue.
st, _ = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
if st == 0:
return "retry"
import echo_ops
a = dict(rec.get("args") or {})
body_text = a.pop("body_text", "") or ""
day = a.pop("today", None)
bodyfile = echo.temp_file(body_text.encode("utf-8")) if body_text else None
prev = os.environ.get("ECHO_TODAY")
try:
if day:
os.environ["ECHO_TODAY"] = day # replayed dates reflect when it was said
rc = echo_ops.capture(
a.get("kind"), a.get("title") or "(untitled)", bodyfile,
status_v=a.get("status_v", ""), aliases=a.get("aliases") or [],
sources=a.get("sources") or [], tags=a.get("tags") or [],
date=a.get("date"), domain=a.get("domain", "business"),
inbox=bool(a.get("inbox")), no_log=bool(a.get("no_log")),
force=bool(a.get("force")), merge_into=a.get("merge_into"))
except Exception as exc: # noqa: BLE001 — a broken record must not wedge the queue
print(f"echo_queue: queued capture '{a.get('title')}' failed on replay ({exc}) "
"— kept and flagged", file=sys.stderr)
return "gated"
finally:
if day:
if prev is None:
os.environ.pop("ECHO_TODAY", None)
else:
os.environ["ECHO_TODAY"] = prev
if rc == 0:
return "ok"
if rc == 76:
print(f"echo_queue: queued capture '{a.get('title')}' stopped at the duplicate "
"gate on replay — resolve with capture --merge-into <slug> or --force, "
"then remove it from the outbox", file=sys.stderr)
return "gated"
def _replay(rec: dict) -> str:
"""Re-issue one queued write idempotently. Returns 'ok' (landed/already-present),
'drop' (permanent 4xx — can never succeed), 'gated' (kept + flagged for the
operator), or 'retry' (still offline / 5xx)."""
if rec.get("op") == "capture":
return _replay_capture(rec)
method = rec["method"]
url = _rebase(rec["url"])
data = base64.b64decode(rec["body_b64"]) if rec.get("body_b64") else None
headers = rec.get("headers") or {}
# Append-style writes: skip if the content is already present (idempotency on replay).
is_append = method == "POST" or (method == "PATCH" and headers.get("Operation") == "append")
if is_append and data:
st, cur = echo.request("GET", url)
if st == 0:
return "retry" # still offline
if st == 200 and data.decode("utf-8", "replace").strip("\n") in cur.decode("utf-8", "replace"):
return "ok" # already landed (or a duplicate we must not re-add)
st, body = echo.request(method, url, data=data, headers=headers)
if st == 0 or 500 <= st < 600:
return "retry"
if 400 <= st < 500:
print(f"echo_queue: dropping a queued {method} {url} — permanent HTTP {st} on replay",
file=sys.stderr)
return "drop"
return "ok"
def flush() -> int:
"""Replay queued writes in order; stop at the first still-unreachable one (keeping it
and everything after). A 'gated' record (duplicate gate / replay error) is kept and
flagged but does NOT block later records — they are independent writes. Returns the
number actually replayed. Safe to call when empty."""
items = pending()
if not items:
return 0
replayed = 0
remaining: list[dict] = []
stopped = False
for rec in items:
if stopped:
remaining.append(rec)
continue
result = _replay(rec)
if result == "ok":
replayed += 1
elif result == "drop":
continue # warned in _replay; remove from queue
elif result == "gated":
rec["needs_attention"] = rec.get("needs_attention") or "replay-gated"
remaining.append(rec)
else: # retry -> still offline; keep this and everything after, in order
remaining.append(rec)
stopped = True
_rewrite(remaining)
return replayed
# --------------------------------------------------------------------- cache
def cache_key(path: str) -> Path:
return cache_dir() / (path.replace("/", "__") + ".cache")
def cache_put(path: str, body: bytes) -> None:
_ensure_dirs()
cache_key(path).write_bytes(body)
def cache_get(path: str) -> bytes | None:
p = cache_key(path)
return p.read_bytes() if p.exists() else None
# ----------------------------------------------------------- integration seam
def safe_request(method: str, url: str, data=None, headers=None, idem_key: str | None = None):
"""Network-first wrapper around echo.request with offline queueing for writes.
- success or a client error (4xx): return (status, body) as-is — 4xx is a bug, fail loud.
- transport failure (status 0) or 5xx (after echo.request's own retry) on a MUTATING
verb: enqueue the write and return (202, b"queued (offline)") so the caller reports it
as durably queued rather than failed.
- otherwise (e.g. a failing GET): return (status, body); the read-cache fallback lives
in cmd_load, which knows which reads are worth serving stale.
"""
method = method.upper()
status, body = echo.request(method, url, data=data, headers=headers)
offline = status == 0 or 500 <= status < 600
if offline and method in MUTATING:
enqueue(method, url, data, headers or {}, idem_key)
return 202, b"queued (offline)"
return status, body