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:
@@ -37,12 +37,13 @@ LOG_HEADING = {
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- resolve -----
|
||||
def resolve(mention: str) -> int:
|
||||
def resolve_op(mention: str) -> dict:
|
||||
"""Core resolve: mention -> match/candidates dict. No printing (Phase 0 — the same
|
||||
return value backs the CLI wrapper and the MCP `echo_resolve` tool)."""
|
||||
index = idx_mod.load()
|
||||
slug, e = idx_mod.resolve(index, mention)
|
||||
if e:
|
||||
print(json.dumps({"match": True, "slug": slug, **e}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
return {"match": True, "slug": slug, **e}
|
||||
# No exact match — surface fuzzy candidates so a shortened/expanded name (e.g. "echo
|
||||
# memory" for the project "echo") reveals the existing note instead of looking absent.
|
||||
cands = idx_mod.fuzzy_candidates(index, mention)
|
||||
@@ -54,21 +55,30 @@ def resolve(mention: str) -> int:
|
||||
"these before creating a new note; reuse the right path or `echo.py link`.")
|
||||
else:
|
||||
out["note"] = "no index entry — derive a path with the right --kind and create it"
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
return out
|
||||
|
||||
|
||||
def resolve(mention: str) -> int:
|
||||
print(json.dumps(resolve_op(mention), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- link -----
|
||||
def link(a_path: str, b_path: str, as_json: bool = False) -> int:
|
||||
def link_op(a_path: str, b_path: str) -> dict:
|
||||
"""Core link: reciprocal `## Related` links, returns the change envelope."""
|
||||
import echo_output
|
||||
a_changed, b_changed = links.link_bidirectional(a_path, b_path)
|
||||
return echo_output.envelope("link", {"a": a_path, "b": b_path,
|
||||
"a_changed": a_changed, "b_changed": b_changed})
|
||||
|
||||
|
||||
def link(a_path: str, b_path: str, as_json: bool = False) -> int:
|
||||
env = link_op(a_path, b_path)
|
||||
if as_json:
|
||||
import echo_output
|
||||
env = echo_output.envelope("link", {"a": a_path, "b": b_path,
|
||||
"a_changed": a_changed, "b_changed": b_changed})
|
||||
print(json.dumps(env, ensure_ascii=False))
|
||||
return 0
|
||||
print(f"ok: linked {a_path} <-> {b_path} "
|
||||
f"(added: {'A' if a_changed else '-'}{'B' if b_changed else '-'})")
|
||||
print(f"ok: linked {env['a']} <-> {env['b']} "
|
||||
f"(added: {'A' if env['a_changed'] else '-'}{'B' if env['b_changed'] else '-'})")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -176,52 +186,52 @@ def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> N
|
||||
echo.cmd_fm(path, "updated", json.dumps(today_s))
|
||||
|
||||
|
||||
def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "",
|
||||
aliases=None, sources=None, tags=None, date: str | None = None,
|
||||
domain: str = "business", inbox: bool = False, no_log: bool = False,
|
||||
as_json: bool = False, dry_run: bool = False, force: bool = False,
|
||||
merge_into: str | None = None) -> int:
|
||||
def capture_op(kind: str | None, title: str, file_arg: str | None = None, status_v: str = "",
|
||||
aliases=None, sources=None, tags=None, date: str | None = None,
|
||||
domain: str = "business", inbox: bool = False, no_log: bool = False,
|
||||
dry_run: bool = False, force: bool = False,
|
||||
merge_into: str | None = None, body_text: str | None = None) -> dict:
|
||||
"""Core capture (Phase 0): route + frontmatter + index + auto-link + agent-log,
|
||||
returning an envelope dict — never printing to stdout (helper chatter from the
|
||||
low-level verbs is redirected to stderr). Special outcomes are DATA, not exit
|
||||
codes: `duplicate-gate` (ok=false, candidates), `queued:capture` (queued=true),
|
||||
`dry-run:*`. Raises echo.EchoError on hard failure. `body_text` may be passed
|
||||
directly (MCP path); otherwise it is read from file_arg/stdin."""
|
||||
import contextlib
|
||||
import io
|
||||
import echo_output
|
||||
import echo_quality
|
||||
|
||||
real_stdout = sys.stdout
|
||||
def chatter():
|
||||
# Low-level verbs (cmd_put/cmd_append/cmd_fm) print progress lines; keep
|
||||
# stdout clean for the envelope by routing them to stderr wholesale.
|
||||
return contextlib.redirect_stdout(sys.stderr)
|
||||
|
||||
def quiet():
|
||||
# M4: in --json mode, swallow the helper "ok:" chatter so stdout is clean JSON.
|
||||
return contextlib.redirect_stdout(io.StringIO()) if as_json else contextlib.nullcontext()
|
||||
|
||||
def done(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False,
|
||||
near=None) -> int:
|
||||
if as_json:
|
||||
act = f"dry-run:{action}" if dry else action
|
||||
data = {"kind": kind, "path": path, "title": title, "links_added": links}
|
||||
if near:
|
||||
data["near_duplicates"] = near
|
||||
env = echo_output.envelope(act, data, ok=ok)
|
||||
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
|
||||
elif dry:
|
||||
print(f"would {action} {kind or '-'} -> {path}")
|
||||
# non-dry human output is the helper "ok:" lines + the summary printed by the caller.
|
||||
return 0 if ok else 1
|
||||
|
||||
body_text = echo.read_body(file_arg).decode("utf-8", errors="replace")
|
||||
if body_text is None:
|
||||
body_text = echo.read_body(file_arg).decode("utf-8", errors="replace")
|
||||
today_s = echo.today()
|
||||
aliases = [a.strip() for a in (aliases or []) if a.strip()]
|
||||
sources = [s.strip() for s in (sources or []) if s.strip()]
|
||||
tags = [t.strip() for t in (tags or []) if t.strip()]
|
||||
|
||||
def env_for(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False,
|
||||
near=None, **extra) -> dict:
|
||||
act = f"dry-run:{action}" if dry else action
|
||||
data = {"kind": kind, "path": path, "title": title, "links_added": links}
|
||||
if near:
|
||||
data["near_duplicates"] = near
|
||||
data.update(extra)
|
||||
return echo_output.envelope(act, data, ok=ok)
|
||||
|
||||
# Unknown home -> defer to the inbox (a single idempotent capture line).
|
||||
if inbox or not kind:
|
||||
if dry_run:
|
||||
return done("inbox", "inbox/captures/inbox.md", dry=True)
|
||||
return env_for("inbox", "inbox/captures/inbox.md", dry=True)
|
||||
line = f"- {today_s}: {title}"
|
||||
if body_text.strip():
|
||||
line += f" — {body_text.strip().splitlines()[0]}"
|
||||
with quiet():
|
||||
with chatter():
|
||||
rc = echo.cmd_append("inbox/captures/inbox.md", line)
|
||||
return done("inbox", "inbox/captures/inbox.md", ok=rc == 0)
|
||||
return env_for("inbox", "inbox/captures/inbox.md", ok=rc == 0)
|
||||
|
||||
slug = idx_mod.slugify(title)
|
||||
# Offline short-circuit (2.1.0). The routed path needs the entity index and several
|
||||
@@ -235,9 +245,8 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
|
||||
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
|
||||
return echo_output.envelope("dry-run:queued:capture",
|
||||
{"kind": kind, "title": title, "queued": True})
|
||||
import echo_queue
|
||||
echo_queue.enqueue_capture({
|
||||
"kind": kind, "title": title, "body_text": body_text, "status_v": status_v,
|
||||
@@ -245,23 +254,16 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
|
||||
"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
|
||||
return echo_output.envelope("queued:capture",
|
||||
{"kind": kind, "title": title, "queued": True})
|
||||
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.
|
||||
if merge_into and not existing:
|
||||
match_slug, existing = idx_mod.resolve(index, merge_into)
|
||||
if not existing:
|
||||
print(f"echo_ops: --merge-into '{merge_into}' matches no entity in the index "
|
||||
f"(try `resolve` first)", file=sys.stderr)
|
||||
return 2
|
||||
raise echo.EchoError(f"--merge-into '{merge_into}' matches no entity in the "
|
||||
"index (try `resolve` first)", 2)
|
||||
existing_reachable = bool(existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200)
|
||||
|
||||
# Pre-write duplicate gate: a strong fuzzy candidate means this title is very likely
|
||||
@@ -276,40 +278,26 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
|
||||
for s, c, sc in idx_mod.gate_candidates(index, title, kind=kind,
|
||||
threshold=DUP_GATE)]
|
||||
if gate_hits:
|
||||
if as_json:
|
||||
env = echo_output.envelope("duplicate-gate", {
|
||||
"kind": kind, "title": title, "candidates": gate_hits,
|
||||
"note": "likely duplicate — re-run with --merge-into <slug> to update the "
|
||||
"existing entity, or --force to create anyway"}, ok=False)
|
||||
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
|
||||
else:
|
||||
print(f"STOP: '{title}' likely already exists — not creating a duplicate.",
|
||||
file=real_stdout)
|
||||
for c in gate_hits:
|
||||
print(f" candidate: {c['slug']} -> {c['path']} (score {c['score']})",
|
||||
file=real_stdout)
|
||||
print(" re-run with --merge-into <slug> to update the existing entity, "
|
||||
"or --force to create anyway.", file=real_stdout)
|
||||
return 76 # distinct exit: duplicate gate (cf. 75 lock, 78 config)
|
||||
return echo_output.envelope("duplicate-gate", {
|
||||
"kind": kind, "title": title, "candidates": gate_hits,
|
||||
"note": "likely duplicate — re-run with --merge-into <slug> to update the "
|
||||
"existing entity, or --force to create anyway"}, ok=False)
|
||||
|
||||
if dry_run:
|
||||
if existing_reachable:
|
||||
return done("update", existing["path"], dry=True)
|
||||
return env_for("update", existing["path"], dry=True)
|
||||
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
|
||||
near = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
|
||||
plan = done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
|
||||
dry=True, near=near)
|
||||
if (not as_json and not force
|
||||
and idx_mod.gate_candidates(index, title, kind=kind, threshold=DUP_GATE)):
|
||||
# (in --json mode the near_duplicates field carries this; keep stdout clean)
|
||||
print("note: a real run would STOP at the duplicate gate — use --merge-into "
|
||||
"or --force.", file=real_stdout)
|
||||
return plan
|
||||
would_gate = (not force
|
||||
and bool(idx_mod.gate_candidates(index, title, kind=kind,
|
||||
threshold=DUP_GATE)))
|
||||
return env_for("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
|
||||
dry=True, near=near, would_gate=would_gate)
|
||||
|
||||
near_dupes: list[str] = []
|
||||
index_title = title # title to record in the index entry
|
||||
index_aliases = list(aliases)
|
||||
with quiet():
|
||||
with chatter():
|
||||
if existing_reachable:
|
||||
# Reuse the matched entity's CANONICAL slug — never re-slug from the mention, or
|
||||
# two index slugs end up pointing at one note. Keep its title; learn the mention
|
||||
@@ -374,13 +362,61 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
|
||||
if not no_log:
|
||||
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
|
||||
|
||||
return env_for(action, path, links=linked, near=near_dupes)
|
||||
|
||||
|
||||
def capture(kind: str | None, title: str, file_arg: str | None, status_v: str = "",
|
||||
aliases=None, sources=None, tags=None, date: str | None = None,
|
||||
domain: str = "business", inbox: bool = False, no_log: bool = False,
|
||||
as_json: bool = False, dry_run: bool = False, force: bool = False,
|
||||
merge_into: str | None = None) -> int:
|
||||
"""CLI wrapper around capture_op: prints exactly what pre-Phase-0 capture printed
|
||||
(human prose or the --json envelope) and maps envelope outcomes to exit codes
|
||||
(76 duplicate-gate, 2 usage-class errors, 0 otherwise)."""
|
||||
try:
|
||||
env = capture_op(kind, title, file_arg, status_v=status_v, aliases=aliases,
|
||||
sources=sources, tags=tags, date=date, domain=domain,
|
||||
inbox=inbox, no_log=no_log, dry_run=dry_run, force=force,
|
||||
merge_into=merge_into)
|
||||
except echo.EchoError as exc:
|
||||
print(f"echo_ops: {exc}", file=sys.stderr)
|
||||
return getattr(exc, "code", 1)
|
||||
action = env.get("action", "")
|
||||
|
||||
if as_json:
|
||||
done(action, path, links=linked, near=near_dupes)
|
||||
else:
|
||||
print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else ""))
|
||||
if near_dupes:
|
||||
kind_word = "entity" if len(near_dupes) == 1 else "entities"
|
||||
print(f"WARNING: similar existing {kind_word} ({', '.join(near_dupes)}) — if this is "
|
||||
f"the same thing, merge or `echo.py link` instead of keeping a duplicate.",
|
||||
file=real_stdout)
|
||||
return 0
|
||||
print(json.dumps(env, ensure_ascii=False))
|
||||
if action == "duplicate-gate":
|
||||
return 76
|
||||
return 0 if env.get("ok") else 1
|
||||
|
||||
if action == "duplicate-gate":
|
||||
print(f"STOP: '{title}' likely already exists — not creating a duplicate.")
|
||||
for c in env.get("candidates", []):
|
||||
print(f" candidate: {c['slug']} -> {c['path']} (score {c['score']})")
|
||||
print(" re-run with --merge-into <slug> to update the existing entity, "
|
||||
"or --force to create anyway.")
|
||||
return 76 # distinct exit: duplicate gate (cf. 75 lock, 78 config)
|
||||
if action == "queued:capture":
|
||||
print(f"queued (offline): capture {kind} '{title}' — will replay through "
|
||||
"capture on the next reachable session")
|
||||
return 0
|
||||
if action == "dry-run:queued:capture":
|
||||
print("offline: vault unreachable — a real run would queue this capture "
|
||||
"for replay on the next reachable session.")
|
||||
return 0
|
||||
if action.startswith("dry-run:"):
|
||||
print(f"would {action.split(':', 1)[1]} {kind or '-'} -> {env.get('path')}")
|
||||
if env.get("would_gate"):
|
||||
print("note: a real run would STOP at the duplicate gate — use --merge-into "
|
||||
"or --force.")
|
||||
return 0
|
||||
if action == "inbox":
|
||||
return 0 if env.get("ok") else 1
|
||||
print(f"ok: {action} {kind} -> {env.get('path')}"
|
||||
+ (f"; auto-linked {env['links_added']}" if env.get("links_added") else ""))
|
||||
near = env.get("near_duplicates")
|
||||
if near:
|
||||
kind_word = "entity" if len(near) == 1 else "entities"
|
||||
print(f"WARNING: similar existing {kind_word} ({', '.join(near)}) — if this is "
|
||||
f"the same thing, merge or `echo.py link` instead of keeping a duplicate.")
|
||||
return 0 if env.get("ok") else 1
|
||||
|
||||
Reference in New Issue
Block a user