2026-07-28 22:34:42 -05:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""echo_session.py — the session-end bundle: one call ends a session correctly. [2.1.0]
|
|
|
|
|
|
|
|
|
|
Ending a substantive session used to take four-plus separate invocations (session-log
|
|
|
|
|
PUT, Agent Log append, heartbeat PUT, optional scope set, optional reflect apply) —
|
|
|
|
|
a cost per session, and a partial failure left broken orientation state (log written
|
|
|
|
|
but heartbeat stale). This verb does all of it in one call, under ONE advisory-lock
|
|
|
|
|
acquisition, in a fixed order with the heartbeat written LAST as the commit marker:
|
|
|
|
|
a failure part-way leaves the previous pointer intact, never a dangling one.
|
|
|
|
|
|
|
|
|
|
Bundle shape (JSON object):
|
|
|
|
|
{
|
|
|
|
|
"slug": "echo-mcp-spec", # required, kebab-case
|
|
|
|
|
"log_body": "<full session-log markdown, frontmatter included>", # required
|
|
|
|
|
"agent_log_line": "- 2026-07-28: ...", # optional; derived when omitted
|
|
|
|
|
"scope": "new scope text", # optional -> scope set
|
|
|
|
|
"reflect": [ { ...PROPOSAL_SCHEMA... } ] # optional -> routed via capture
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Dry-run by default (previews the whole plan, reflect included); --apply writes.
|
|
|
|
|
Times: the filename HHMM comes from $ECHO_NOW (HHMM) else the local clock; the date
|
|
|
|
|
from ECHO_TODAY via echo.today(). Offline: every step rides the offline queue, so a
|
|
|
|
|
session end during an outage queues durably instead of failing.
|
|
|
|
|
|
|
|
|
|
CLI: echo.py session-end <bundle.json> [--apply]
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import datetime as dt
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
|
import echo # noqa: E402
|
|
|
|
|
|
|
|
|
|
SESSION_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-\d{4}-[a-z0-9-]+\.md$")
|
|
|
|
|
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hhmm() -> str:
|
|
|
|
|
v = os.environ.get("ECHO_NOW", "").strip()
|
|
|
|
|
if v:
|
|
|
|
|
if not re.match(r"^\d{4}$", v):
|
|
|
|
|
raise echo.EchoError(f"session-end: $ECHO_NOW must be HHMM, got '{v}'", 2)
|
|
|
|
|
return v
|
|
|
|
|
return dt.datetime.now().strftime("%H%M")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def validate(bundle: dict) -> tuple[str, str]:
|
|
|
|
|
"""Return (session_path, agent_log_line) or raise EchoError(2). Validation runs
|
|
|
|
|
BEFORE any write — a bad bundle writes nothing at all."""
|
|
|
|
|
if not isinstance(bundle, dict):
|
|
|
|
|
raise echo.EchoError("session-end: bundle must be a JSON object", 2)
|
|
|
|
|
slug = str(bundle.get("slug", "")).strip()
|
|
|
|
|
if not SLUG_RE.match(slug):
|
|
|
|
|
raise echo.EchoError(f"session-end: slug must be kebab-case, got '{slug}'", 2)
|
|
|
|
|
if not str(bundle.get("log_body", "")).strip():
|
|
|
|
|
raise echo.EchoError("session-end: log_body is required (the full session-log markdown)", 2)
|
|
|
|
|
reflect = bundle.get("reflect")
|
|
|
|
|
if reflect is not None and not isinstance(reflect, list):
|
|
|
|
|
raise echo.EchoError("session-end: reflect must be a JSON array of proposals", 2)
|
|
|
|
|
fname = f"{echo.today()}-{_hhmm()}-{slug}.md"
|
|
|
|
|
if not SESSION_RE.match(fname):
|
|
|
|
|
raise echo.EchoError(f"session-end: derived filename '{fname}' violates the "
|
|
|
|
|
"canonical YYYY-MM-DD-HHMM-<slug>.md form", 2)
|
|
|
|
|
path = f"_agent/sessions/{fname}"
|
|
|
|
|
line = str(bundle.get("agent_log_line") or "").strip() \
|
|
|
|
|
or f"- {echo.today()}: session logged [[{path[:-3]}]]"
|
|
|
|
|
return path, line
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 22:51:33 -05:00
|
|
|
def session_end_op(bundle: dict, apply: bool = False) -> dict:
|
|
|
|
|
"""Core session-end (Phase 0): validate -> plan -> (apply). Returns an envelope
|
|
|
|
|
(plan rows + per-step results); never prints to stdout — helper chatter from the
|
|
|
|
|
underlying verbs routes to stderr. Raises echo.EchoError(2) on a bad bundle,
|
|
|
|
|
BEFORE any write."""
|
|
|
|
|
import contextlib
|
|
|
|
|
import echo_output
|
2026-07-28 22:34:42 -05:00
|
|
|
import echo_reflect
|
|
|
|
|
path, line = validate(bundle)
|
|
|
|
|
scope = str(bundle.get("scope") or "").strip()
|
|
|
|
|
proposals = bundle.get("reflect") or []
|
|
|
|
|
|
|
|
|
|
valid, errors = echo_reflect.validate(proposals)
|
2026-07-28 22:51:33 -05:00
|
|
|
rows: list[dict] = []
|
2026-07-28 22:34:42 -05:00
|
|
|
if valid:
|
|
|
|
|
echo_reflect.classify(valid)
|
2026-07-28 22:51:33 -05:00
|
|
|
rows = [{"action": p.get("_action"), "kind": p.get("kind"),
|
|
|
|
|
"title": p["title"], "path": p.get("_path")} for p in valid]
|
|
|
|
|
data = {"path": path, "agent_log_line": line, "scope": scope or None,
|
|
|
|
|
"reflect_rows": rows, "reflect_errors": errors,
|
|
|
|
|
"dry_run": not apply, "steps": {}}
|
2026-07-28 22:34:42 -05:00
|
|
|
if not apply:
|
2026-07-28 22:51:33 -05:00
|
|
|
return echo_output.envelope("session-end", data)
|
2026-07-28 22:34:42 -05:00
|
|
|
|
|
|
|
|
import echo_concurrency
|
|
|
|
|
import echo_ops
|
|
|
|
|
steps: dict[str, str] = {}
|
2026-07-28 22:51:33 -05:00
|
|
|
with echo_concurrency.vault_lock(), contextlib.redirect_stdout(sys.stderr):
|
2026-07-28 22:34:42 -05:00
|
|
|
# 1. The session log itself. A hard failure here aborts the whole bundle —
|
|
|
|
|
# nothing after it (including the heartbeat) runs, so orientation state
|
|
|
|
|
# can never point at a log that was never written.
|
|
|
|
|
echo.cmd_put(path, echo.temp_file(str(bundle["log_body"]).encode("utf-8")))
|
|
|
|
|
steps["session_log"] = "ok"
|
|
|
|
|
# 2. Agent-log line (best-effort by contract; queues itself when offline).
|
|
|
|
|
echo_ops.ensure_daily_log(line)
|
|
|
|
|
steps["agent_log"] = "ok"
|
|
|
|
|
# 3. Reflect proposals through the normal capture path (gate-aware).
|
|
|
|
|
if valid:
|
|
|
|
|
gated = 0
|
|
|
|
|
for p in valid:
|
|
|
|
|
if p.get("_action") == "error":
|
|
|
|
|
continue
|
2026-07-28 22:51:33 -05:00
|
|
|
env = echo_ops.capture_op(
|
|
|
|
|
p.get("kind"), p["title"], body_text=p.get("body") or "",
|
2026-07-28 22:34:42 -05:00
|
|
|
aliases=p.get("aliases") or [], sources=p.get("sources") or [],
|
|
|
|
|
tags=p.get("tags") or [], date=p.get("date"),
|
|
|
|
|
domain=p.get("domain", "business"),
|
|
|
|
|
inbox=bool(p.get("inbox")) or not p.get("kind"))
|
2026-07-28 22:51:33 -05:00
|
|
|
gated += 1 if env.get("action") == "duplicate-gate" else 0
|
2026-07-28 22:34:42 -05:00
|
|
|
steps["reflect"] = f"{len(valid) - gated}/{len(valid)} applied" \
|
|
|
|
|
+ (f", {gated} gated (re-propose with --merge-into)" if gated else "")
|
|
|
|
|
else:
|
|
|
|
|
steps["reflect"] = "skipped (none)"
|
|
|
|
|
# 4. Scope switch (optional).
|
|
|
|
|
if scope:
|
|
|
|
|
echo.cmd_scope("set", scope)
|
|
|
|
|
steps["scope"] = "ok"
|
|
|
|
|
else:
|
|
|
|
|
steps["scope"] = "unchanged"
|
|
|
|
|
# 5. Heartbeat LAST — the commit marker for the whole bundle.
|
|
|
|
|
echo.cmd_put("_agent/heartbeat/last-session.md",
|
|
|
|
|
echo.temp_file(f"{path} @ {echo.now_iso()}\n".encode("utf-8")))
|
|
|
|
|
steps["heartbeat"] = "ok"
|
2026-07-29 00:08:48 -05:00
|
|
|
# Index maintenance (2.3), NOT part of the bundle contract: push this
|
|
|
|
|
# machine's local recall index to the vault snapshot so other machines
|
|
|
|
|
# can seed/catch up. Best-effort — a failure never taints the bundle.
|
|
|
|
|
try:
|
|
|
|
|
import echo_recall
|
|
|
|
|
rix = echo_recall._load_local()
|
|
|
|
|
if rix and rix.n_docs:
|
|
|
|
|
rix.built = rix.built or echo.now_iso()
|
|
|
|
|
echo_recall.save_snapshot(rix)
|
|
|
|
|
steps["recall_snapshot"] = "ok"
|
|
|
|
|
except Exception: # noqa: BLE001
|
|
|
|
|
steps["recall_snapshot"] = "skipped"
|
2026-07-28 22:34:42 -05:00
|
|
|
|
2026-07-28 22:51:33 -05:00
|
|
|
data["steps"] = steps
|
|
|
|
|
return echo_output.envelope("session-end", data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def session_end(bundle: dict, apply: bool = False) -> int:
|
|
|
|
|
"""CLI wrapper: same plan/summary text as before; envelope logic in session_end_op."""
|
|
|
|
|
env = session_end_op(bundle, apply=apply)
|
|
|
|
|
for e in env["reflect_errors"]:
|
|
|
|
|
print(f"skip: {e}", file=sys.stderr)
|
|
|
|
|
print(f"session-end plan ({'APPLY' if apply else 'dry-run'}):")
|
|
|
|
|
print(f" 1. session log -> {env['path']}")
|
|
|
|
|
print(f" 2. agent-log line: {env['agent_log_line']}")
|
|
|
|
|
rows = env["reflect_rows"]
|
|
|
|
|
if rows:
|
|
|
|
|
print(f" 3. reflect: {len(rows)} proposal(s)")
|
|
|
|
|
print("\n".join(f" {r['action'] or '?':7} | {('-' if r['action'] == 'inbox' else r['kind'] or '?'):9} "
|
|
|
|
|
f"| {r['title']} -> {r['path'] or '?'}" for r in rows))
|
|
|
|
|
else:
|
|
|
|
|
print(" 3. reflect: (none)")
|
|
|
|
|
print(f" 4. scope set: {env['scope']!r}" if env["scope"] else " 4. scope: (unchanged)")
|
|
|
|
|
print(f" 5. heartbeat -> {env['path']} @ <now> (written LAST — the commit marker)")
|
|
|
|
|
if env["dry_run"]:
|
|
|
|
|
print("\nsession-end: dry-run — re-run with --apply to write.")
|
|
|
|
|
return 0
|
2026-07-28 22:34:42 -05:00
|
|
|
print("\nsession-end: done — "
|
2026-07-28 22:51:33 -05:00
|
|
|
+ "; ".join(f"{k}: {v}" for k, v in env["steps"].items()))
|
2026-07-28 22:34:42 -05:00
|
|
|
return 0
|