2.1.1 — MCP Phase 0: every high-level op returns an envelope (return-not-print)
Internal refactor per docs/MCP-SERVER-SPEC.md §4; CLI output and exit codes unchanged (wrappers reproduce them from the envelopes): - echo_ops: capture_op/resolve_op/link_op — duplicate gate and offline queueing are data (action: duplicate-gate + candidates; queued: true); the --json stdout-swallow hack is deleted (helper chatter -> stderr inside the cores); capture_op takes body_text= directly (the MCP path, no temp file). - echo_recall.recall_op — one structured result backs prose and --json. - echo_triage.list_op/route_op, echo_reflect.apply_op, echo_session.session_end_op — call capture_op internally, count gate outcomes from envelopes. - echo.scope_show_op/scope_set_op/load_op (+ shared _load_gather), and echo_doctor.run_op (checks as data). New eval/test_ops_api.py asserts the contract for every core: envelope shape AND stdout purity (a stray print would corrupt an MCP response stream). All six suites green. The 2.2 server build now starts directly at the app layer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -549,59 +549,76 @@ def extract_heading(markdown: str, heading: str) -> str:
|
||||
return "\n".join(out).strip()
|
||||
|
||||
|
||||
def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -> int:
|
||||
def scope_show_op() -> dict:
|
||||
"""Core scope-show (Phase 0): active scope + freshness + sessions-since, as data."""
|
||||
path = "_agent/context/current-context.md"
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, f"scope {subcommand}")
|
||||
check(status, body, "scope show")
|
||||
current = body.decode(errors="replace")
|
||||
scope_text = extract_heading(current, "Scope")
|
||||
scope_updated = ""
|
||||
for line in current.splitlines():
|
||||
if line.startswith("scope_updated:"):
|
||||
scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
sessions_since = None
|
||||
# Count session logs dated after scope_updated (the drift signal).
|
||||
if scope_updated:
|
||||
status, body = request("GET", vault_url("_agent/sessions/"))
|
||||
if status == 200:
|
||||
try:
|
||||
files = json.loads(body).get("files", [])
|
||||
sessions_since = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"ok": True, "action": "scope-show", "scope": scope_text,
|
||||
"scope_updated": scope_updated or None, "sessions_since": sessions_since}
|
||||
|
||||
if subcommand == "show":
|
||||
scope_text = extract_heading(current, "Scope")
|
||||
scope_updated = ""
|
||||
for line in current.splitlines():
|
||||
if line.startswith("scope_updated:"):
|
||||
scope_updated = line.split(":", 1)[1].strip().strip('"').strip("'")
|
||||
break
|
||||
sessions_since = None
|
||||
# Count session logs dated after scope_updated (the drift signal).
|
||||
if scope_updated:
|
||||
status, body = request("GET", vault_url("_agent/sessions/"))
|
||||
if status == 200:
|
||||
try:
|
||||
files = json.loads(body).get("files", [])
|
||||
sessions_since = sum(1 for f in files if f.endswith(".md") and f[:10] > scope_updated)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if as_json:
|
||||
print(json.dumps({"ok": True, "action": "scope-show", "scope": scope_text,
|
||||
"scope_updated": scope_updated or None,
|
||||
"sessions_since": sessions_since}, ensure_ascii=False))
|
||||
return 0
|
||||
print("-- Active scope --")
|
||||
print(scope_text)
|
||||
print(f"scope_updated: {scope_updated or '<missing — drift cannot be detected; run scope set or repair>'}")
|
||||
if sessions_since is not None:
|
||||
print(f"sessions logged since: {sessions_since}")
|
||||
return 0
|
||||
|
||||
if subcommand != "set":
|
||||
raise EchoError("scope: use 'show' or 'set \"<text>\"'", 2)
|
||||
def scope_set_op(text: str) -> dict:
|
||||
"""Core scope-set (Phase 0): atomic switch (history + replace + stamp). Chatter
|
||||
from the underlying PATCHes routes to stderr; returns the switch envelope."""
|
||||
import contextlib
|
||||
if not text:
|
||||
raise EchoError("scope set needs the new scope text", 2)
|
||||
path = "_agent/context/current-context.md"
|
||||
status, body = request("GET", vault_url(path))
|
||||
check(status, body, "scope set")
|
||||
current = body.decode(errors="replace")
|
||||
prior = extract_heading(current, "Scope").replace("\n", " ").strip()[:140] or "(prior scope)"
|
||||
cmd_patch(path, "prepend", "heading", "Current Context::Scope History",
|
||||
temp_file(f"- {today()}: {prior}\n".encode()))
|
||||
cmd_patch(path, "replace", "heading", "Current Context::Scope",
|
||||
temp_file(f"{text}\n".encode()))
|
||||
try:
|
||||
cmd_patch(path, "replace", "frontmatter", "scope_updated",
|
||||
temp_file(json.dumps(today()).encode()))
|
||||
except EchoError as exc:
|
||||
raise EchoError(
|
||||
"scope set: body switched, but scope_updated frontmatter is missing "
|
||||
f"(run bootstrap.py to add it) [{exc}]"
|
||||
)
|
||||
print(f"ok: scope switched (prior archived to Scope History; scope_updated={today()})")
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
cmd_patch(path, "prepend", "heading", "Current Context::Scope History",
|
||||
temp_file(f"- {today()}: {prior}\n".encode()))
|
||||
cmd_patch(path, "replace", "heading", "Current Context::Scope",
|
||||
temp_file(f"{text}\n".encode()))
|
||||
try:
|
||||
cmd_patch(path, "replace", "frontmatter", "scope_updated",
|
||||
temp_file(json.dumps(today()).encode()))
|
||||
except EchoError as exc:
|
||||
raise EchoError(
|
||||
"scope set: body switched, but scope_updated frontmatter is missing "
|
||||
f"(run bootstrap.py to add it) [{exc}]"
|
||||
)
|
||||
return {"ok": True, "action": "scope-set", "scope": text, "prior": prior,
|
||||
"scope_updated": today()}
|
||||
|
||||
|
||||
def cmd_scope(subcommand: str, text: str | None = None, as_json: bool = False) -> int:
|
||||
if subcommand == "show":
|
||||
env = scope_show_op()
|
||||
if as_json:
|
||||
print(json.dumps(env, ensure_ascii=False))
|
||||
return 0
|
||||
print("-- Active scope --")
|
||||
print(env["scope"])
|
||||
print(f"scope_updated: {env['scope_updated'] or '<missing — drift cannot be detected; run scope set or repair>'}")
|
||||
if env["sessions_since"] is not None:
|
||||
print(f"sessions logged since: {env['sessions_since']}")
|
||||
return 0
|
||||
if subcommand != "set":
|
||||
raise EchoError("scope: use 'show' or 'set \"<text>\"'", 2)
|
||||
env = scope_set_op(text or "")
|
||||
print(f"ok: scope switched (prior archived to Scope History; scope_updated={env['scope_updated']})")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -730,51 +747,22 @@ def _render_brief(texts: dict[str, str | None], listing_files: list[str],
|
||||
return out
|
||||
|
||||
|
||||
def cmd_load(brief: bool = False) -> int:
|
||||
"""Cold-start orientation: the canonical 6 reads in one call (fetched in parallel).
|
||||
404s on today's daily note and the inbox are normal (printed as absent, not errors).
|
||||
`brief` renders the token-budgeted digest the SessionStart hook injects; the default
|
||||
full mode (and /echo-load) prints the raw sections unchanged."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
|
||||
print(f"Expected at: {echo_config.config_path()}")
|
||||
print("ACTION: ask the operator for their echo-memory config file (it holds the")
|
||||
print(" vault owner, endpoint, and API key), then install it with ONE of:")
|
||||
print(" python3 echo.py config import <path-to-their-file>")
|
||||
print(" python3 echo.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"")
|
||||
print("Then re-run load. (Memory is unavailable until configured.)")
|
||||
return 78 # distinct: configuration required
|
||||
targets = [
|
||||
("marker", "_agent/echo-vault.md"),
|
||||
("preferences", "_agent/memory/semantic/operator-preferences.md"),
|
||||
("context", "_agent/context/current-context.md"),
|
||||
("heartbeat", "_agent/heartbeat/last-session.md"),
|
||||
("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")
|
||||
flagged = echo_queue.needs_attention()
|
||||
if flagged:
|
||||
print(f"NOTE: {len(flagged)} queued write(s) need attention (e.g. a capture "
|
||||
"stopped at the duplicate gate on replay) — resolve with capture "
|
||||
"--merge-into/--force; `echo.py flush` re-lists them.\n")
|
||||
except Exception as exc: # noqa: BLE001 — never let queue upkeep block a load
|
||||
print(f"(queue flush skipped: {exc})\n", file=sys.stderr)
|
||||
LOAD_TARGETS = [
|
||||
("marker", "_agent/echo-vault.md"),
|
||||
("preferences", "_agent/memory/semantic/operator-preferences.md"),
|
||||
("context", "_agent/context/current-context.md"),
|
||||
("heartbeat", "_agent/heartbeat/last-session.md"),
|
||||
]
|
||||
|
||||
# All orientation reads (+ the sessions listing) fetched in parallel over the
|
||||
# warm connection pool — the listing feeds both the brief digest's scope-freshness
|
||||
# count and the heartbeat-absent fallback, so it's no longer a second round-trip.
|
||||
|
||||
def _load_gather(targets):
|
||||
"""Fetch the orientation reads (+ the sessions listing) in parallel and resolve
|
||||
each to text via status/cache. Shared by cmd_load and load_op. Returns
|
||||
(results, texts, listing_files, offline, marker_missing, heartbeat_absent)."""
|
||||
import echo_queue
|
||||
listing_path = "_agent/sessions/"
|
||||
results = _fetch_statuses([p for _, p in targets] + [listing_path])
|
||||
|
||||
marker_missing = False
|
||||
heartbeat_absent = False
|
||||
offline = False
|
||||
marker_missing = heartbeat_absent = offline = False
|
||||
texts: dict[str, str | None] = {}
|
||||
for label, path in targets:
|
||||
status, body = results[path]
|
||||
@@ -792,7 +780,6 @@ def cmd_load(brief: bool = False) -> int:
|
||||
marker_missing = True
|
||||
if label == "heartbeat":
|
||||
heartbeat_absent = True
|
||||
|
||||
lst_status, lst_body = results[listing_path]
|
||||
listing_files: list[str] = []
|
||||
if lst_status == 200:
|
||||
@@ -800,15 +787,84 @@ def cmd_load(brief: bool = False) -> int:
|
||||
listing_files = list(json.loads(lst_body).get("files", []))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return results, texts, listing_files, offline, marker_missing, heartbeat_absent
|
||||
|
||||
|
||||
def _fetch_pointed_session(texts: dict) -> None:
|
||||
"""Follow the heartbeat pointer and stash the pointed session log for the digest."""
|
||||
hb = texts.get("heartbeat")
|
||||
if hb and hb.strip():
|
||||
sess_path = hb.strip().splitlines()[0].split(" @ ", 1)[0].strip()
|
||||
if sess_path:
|
||||
st2, b2 = request("GET", vault_url(sess_path))
|
||||
if st2 == 200:
|
||||
texts["_pointed_session"] = b2.decode(errors="replace")
|
||||
|
||||
|
||||
def load_op(brief: bool = True) -> dict:
|
||||
"""Core load (Phase 0): the orientation reads as data — sections keyed by label,
|
||||
plus offline/bootstrap flags, queue counts, recent sessions, and (with brief) the
|
||||
rendered digest. Raises EchoError(78) when the machine is not configured."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
raise EchoError("NOT CONFIGURED — no usable ECHO key file on this machine "
|
||||
f"(expected at {echo_config.config_path()})", 78)
|
||||
import echo_queue
|
||||
synced = flagged = 0
|
||||
try:
|
||||
synced = echo_queue.flush()
|
||||
flagged = len(echo_queue.needs_attention())
|
||||
except Exception: # noqa: BLE001 — queue upkeep must never block a load
|
||||
pass
|
||||
targets = LOAD_TARGETS + [("today", f"journal/daily/{today()}.md"),
|
||||
("inbox", "inbox/captures/inbox.md")]
|
||||
_, texts, listing_files, offline, marker_missing, _ = _load_gather(targets)
|
||||
data = {"ok": True, "action": "load", "offline": offline,
|
||||
"marker_missing": marker_missing, "synced": synced,
|
||||
"needs_attention": flagged,
|
||||
"sections": {k: v for k, v in texts.items() if not k.startswith("_")},
|
||||
"recent_sessions": sorted((f for f in listing_files if f.endswith(".md")),
|
||||
reverse=True)[:5]}
|
||||
if brief:
|
||||
_fetch_pointed_session(texts)
|
||||
data["brief"] = _render_brief(texts, listing_files, offline)
|
||||
return data
|
||||
|
||||
|
||||
def cmd_load(brief: bool = False) -> int:
|
||||
"""Cold-start orientation: the canonical 6 reads in one call (fetched in parallel).
|
||||
404s on today's daily note and the inbox are normal (printed as absent, not errors).
|
||||
`brief` renders the token-budgeted digest the SessionStart hook injects; the default
|
||||
full mode (and /echo-load) prints the raw sections unchanged."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
|
||||
print(f"Expected at: {echo_config.config_path()}")
|
||||
print("ACTION: ask the operator for their echo-memory config file (it holds the")
|
||||
print(" vault owner, endpoint, and API key), then install it with ONE of:")
|
||||
print(" python3 echo.py config import <path-to-their-file>")
|
||||
print(" python3 echo.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"")
|
||||
print("Then re-run load. (Memory is unavailable until configured.)")
|
||||
return 78 # distinct: configuration required
|
||||
targets = LOAD_TARGETS + [("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")
|
||||
flagged = echo_queue.needs_attention()
|
||||
if flagged:
|
||||
print(f"NOTE: {len(flagged)} queued write(s) need attention (e.g. a capture "
|
||||
"stopped at the duplicate gate on replay) — resolve with capture "
|
||||
"--merge-into/--force; `echo.py flush` re-lists them.\n")
|
||||
except Exception as exc: # noqa: BLE001 — never let queue upkeep block a load
|
||||
print(f"(queue flush skipped: {exc})\n", file=sys.stderr)
|
||||
|
||||
results, texts, listing_files, offline, marker_missing, heartbeat_absent = \
|
||||
_load_gather(targets)
|
||||
|
||||
if brief:
|
||||
hb = texts.get("heartbeat")
|
||||
if hb and hb.strip(): # follow-up: the pointed session log's key sections
|
||||
sess_path = hb.strip().splitlines()[0].split(" @ ", 1)[0].strip()
|
||||
if sess_path:
|
||||
st2, b2 = request("GET", vault_url(sess_path))
|
||||
if st2 == 200:
|
||||
texts["_pointed_session"] = b2.decode(errors="replace")
|
||||
_fetch_pointed_session(texts)
|
||||
print(_render_brief(texts, listing_files, offline))
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user