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>
This commit is contained in:
@@ -83,8 +83,13 @@ def recall(query, limit: int = 8, as_json: bool = False) -> int:
|
||||
# ----------------------------------------------------------- agent log -------
|
||||
def ensure_daily_log(line: str) -> None:
|
||||
"""Resilient: ensure today's daily note + its `## Agent Log` heading exist, then
|
||||
idempotently append the line. Best-effort — never raises into the caller."""
|
||||
idempotently append the line. Best-effort — never raises into the caller. Writes go
|
||||
through the offline queue (2.1.0), so a mid-session outage queues the line instead
|
||||
of silently dropping it. Offline edge accepted: if the daily note still doesn't
|
||||
exist at replay time, the queued PATCH 400-drops with a loud warning — the agent-log
|
||||
line is a convenience trace, not the memory itself."""
|
||||
try:
|
||||
import echo_queue
|
||||
date = echo.today()
|
||||
path = f"journal/daily/{date}.md"
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
@@ -92,21 +97,22 @@ def ensure_daily_log(line: str) -> None:
|
||||
ts, tb = echo.request("GET", echo.vault_url("journal/templates/daily-note-template.md"))
|
||||
tmpl = tb.decode(errors="replace") if ts == 200 else f"# {date}\n\n## Agent Log\n\n## Related\n"
|
||||
tmpl = tmpl.replace("{{date:YYYY-MM-DD}}", date).replace("{{DATE}}", date)
|
||||
echo.request("PUT", echo.vault_url(path), data=tmpl.encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
echo_queue.safe_request("PUT", echo.vault_url(path), data=tmpl.encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
text = tmpl
|
||||
else:
|
||||
text = body.decode(errors="replace")
|
||||
if not re.search(r"(?m)^## Agent Log\s*$", text):
|
||||
echo.request("POST", echo.vault_url(path), data=b"\n\n## Agent Log\n",
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 200 and line in body.decode(errors="replace"):
|
||||
text = body.decode(errors="replace") if status == 200 else ""
|
||||
if status == 200 and not re.search(r"(?m)^## Agent Log\s*$", text):
|
||||
echo_queue.safe_request("POST", echo.vault_url(path), data=b"\n\n## Agent Log\n",
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
st2, body2 = echo.request("GET", echo.vault_url(path))
|
||||
if st2 == 200 and line in body2.decode(errors="replace"):
|
||||
return
|
||||
echo.request("PATCH", echo.vault_url(path),
|
||||
data=echo.normalize_patch_body((line + "\n").encode(), "append", "heading"),
|
||||
headers={"Operation": "append", "Target-Type": "heading",
|
||||
"Target": f"{date}::Agent Log", "Content-Type": "text/markdown"})
|
||||
echo_queue.safe_request(
|
||||
"PATCH", echo.vault_url(path),
|
||||
data=echo.normalize_patch_body((line + "\n").encode(), "append", "heading"),
|
||||
headers={"Operation": "append", "Target-Type": "heading",
|
||||
"Target": f"{date}::Agent Log", "Content-Type": "text/markdown"})
|
||||
except Exception as exc:
|
||||
print(f"echo_ops: agent-log skipped ({exc})", file=sys.stderr)
|
||||
|
||||
@@ -148,20 +154,25 @@ def _dated_block(today_s: str, body_text: str) -> tuple[str, str]:
|
||||
|
||||
|
||||
def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> None:
|
||||
# Writes route through the offline queue (2.1.0) so a mid-capture outage queues the
|
||||
# update instead of silently losing it. (A fully-offline capture never reaches here —
|
||||
# the short-circuit in capture() queues the whole op as one semantic record.)
|
||||
import echo_queue
|
||||
text = links.get_text(path) or ""
|
||||
h1 = links.first_h1(text) or path.rsplit("/", 1)[-1][:-3]
|
||||
heading = LOG_HEADING.get(kind, "Notes")
|
||||
if not re.search(rf"(?m)^##\s+{re.escape(heading)}\s*$", text):
|
||||
echo.request("POST", echo.vault_url(path), data=f"\n\n## {heading}\n".encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
echo_queue.safe_request("POST", echo.vault_url(path), data=f"\n\n## {heading}\n".encode(),
|
||||
headers={"Content-Type": "text/markdown"})
|
||||
bullet, block = _dated_block(today_s, body_text)
|
||||
status, body = echo.request("GET", echo.vault_url(path))
|
||||
if status == 200 and bullet in body.decode(errors="replace"):
|
||||
return
|
||||
echo.request("PATCH", echo.vault_url(path),
|
||||
data=echo.normalize_patch_body((block + "\n").encode(), "append", "heading"),
|
||||
headers={"Operation": "append", "Target-Type": "heading",
|
||||
"Target": f"{h1}::{heading}", "Content-Type": "text/markdown"})
|
||||
echo_queue.safe_request(
|
||||
"PATCH", echo.vault_url(path),
|
||||
data=echo.normalize_patch_body((block + "\n").encode(), "append", "heading"),
|
||||
headers={"Operation": "append", "Target-Type": "heading",
|
||||
"Target": f"{h1}::{heading}", "Content-Type": "text/markdown"})
|
||||
echo.cmd_fm(path, "updated", json.dumps(today_s))
|
||||
|
||||
|
||||
@@ -213,7 +224,35 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
|
||||
return done("inbox", "inbox/captures/inbox.md", ok=rc == 0)
|
||||
|
||||
slug = idx_mod.slugify(title)
|
||||
index = idx_mod.load()
|
||||
# Offline short-circuit (2.1.0). The routed path needs the entity index and several
|
||||
# round-trips; when the vault is unreachable, queue the WHOLE capture as one semantic
|
||||
# record — flush replays it back through this function against the index as it is at
|
||||
# replay time, so routing, the duplicate gate, and aliasing re-run with fresh state.
|
||||
# (Byte-level request replay would freeze a create-vs-update decision made blind.)
|
||||
try:
|
||||
index = idx_mod.load()
|
||||
except echo.EchoError as exc:
|
||||
if "unreachable" not in str(exc):
|
||||
raise
|
||||
if dry_run:
|
||||
print("offline: vault unreachable — a real run would queue this capture "
|
||||
"for replay on the next reachable session.", file=real_stdout)
|
||||
return 0
|
||||
import echo_queue
|
||||
echo_queue.enqueue_capture({
|
||||
"kind": kind, "title": title, "body_text": body_text, "status_v": status_v,
|
||||
"aliases": aliases, "sources": sources, "tags": tags, "date": date,
|
||||
"domain": domain, "inbox": False, "no_log": no_log,
|
||||
"force": force, "merge_into": merge_into, "today": today_s,
|
||||
})
|
||||
if as_json:
|
||||
env = echo_output.envelope("queued:capture",
|
||||
{"kind": kind, "title": title, "queued": True})
|
||||
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
|
||||
else:
|
||||
print(f"queued (offline): capture {kind} '{title}' — will replay through "
|
||||
"capture on the next reachable session", file=real_stdout)
|
||||
return 0
|
||||
match_slug, existing = idx_mod.resolve(index, title)
|
||||
# --merge-into: the operator has already identified the canonical entity (e.g. after
|
||||
# a duplicate-gate stop) — route this capture as an UPDATE to it, whatever the title.
|
||||
|
||||
Reference in New Issue
Block a user