e7792003a7
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>
163 lines
6.9 KiB
Python
163 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""echo_triage.py — one-tap inbox triage, built on the reflect pipeline.
|
|
|
|
Triage used to be pure prose: the agent hand-routed each inbox line with individual
|
|
PATCH/PUT calls and hand-wrote the processing log — which is why the inbox rots.
|
|
But the machinery it needs already exists in echo_reflect (validate -> classify
|
|
against the entity index -> preview -> apply via capture). This module reuses it
|
|
and adds the two triage-specific pieces:
|
|
|
|
* `list_inbox` — parse `inbox/captures/inbox.md` into structured capture lines
|
|
(date, text, age in days) so the model builds proposals from
|
|
data, not by re-reading prose;
|
|
* `apply` — same contract as reflect (dry-run unless confirm), plus an
|
|
audit line in `inbox/processing-log/YYYY-MM-DD.md` for every
|
|
routed item. Originals are NEVER deleted (operating contract:
|
|
the processing log is the audit trail, deletion is explicit).
|
|
|
|
Proposals are reflect's PROPOSAL_SCHEMA plus an optional `"line"` — the original
|
|
inbox line, echoed into the processing log so the audit trail maps 1:1.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
import echo # noqa: E402
|
|
|
|
INBOX_PATH = "inbox/captures/inbox.md"
|
|
LOG_DIR = "inbox/processing-log"
|
|
|
|
_CAPTURE_LINE = re.compile(r"^\s*-\s*(\d{4}-\d{2}-\d{2})\s*:?\s*(.*\S)\s*$")
|
|
|
|
|
|
def parse_inbox(text: str) -> list[dict]:
|
|
"""Dated capture bullets -> [{line, date, text, age_days}] (age from ECHO_TODAY)."""
|
|
import datetime as dt
|
|
try:
|
|
today = dt.date.fromisoformat(echo.today())
|
|
except ValueError:
|
|
today = dt.date.today()
|
|
items = []
|
|
for raw_line in text.splitlines():
|
|
m = _CAPTURE_LINE.match(raw_line)
|
|
if not m:
|
|
continue
|
|
try:
|
|
age = (today - dt.date.fromisoformat(m.group(1))).days
|
|
except ValueError:
|
|
age = None
|
|
items.append({"line": raw_line.strip(), "date": m.group(1),
|
|
"text": m.group(2), "age_days": age})
|
|
return items
|
|
|
|
|
|
def list_op() -> dict:
|
|
"""Core inbox listing (Phase 0): structured captures envelope, no printing."""
|
|
import echo_output
|
|
status, body = echo.request("GET", echo.vault_url(INBOX_PATH))
|
|
if status == 404:
|
|
items = []
|
|
else:
|
|
echo.check(status, body, f"triage list {INBOX_PATH}")
|
|
items = parse_inbox(body.decode(errors="replace"))
|
|
return echo_output.envelope("triage-list", {"path": INBOX_PATH, "items": items,
|
|
"count": len(items)})
|
|
|
|
|
|
def list_inbox(as_json: bool = False) -> int:
|
|
env = list_op()
|
|
if as_json:
|
|
print(json.dumps(env, ensure_ascii=False))
|
|
return 0
|
|
items = env["items"]
|
|
if not items:
|
|
print("triage: inbox is empty — nothing to route.")
|
|
return 0
|
|
print(f"triage: {len(items)} capture(s) in {INBOX_PATH}")
|
|
for it in items:
|
|
age = f"{it['age_days']}d" if it["age_days"] is not None else "?"
|
|
print(f" [{age:>4}] {it['date']}: {it['text']}")
|
|
print("\nBuild a proposals JSON (reflect schema + optional \"line\") and run "
|
|
"`echo.py triage <file>` to preview, `--apply` to route.")
|
|
return 0
|
|
|
|
|
|
def route_op(proposals: list[dict], confirm: bool = False) -> dict:
|
|
"""Core triage routing (Phase 0): reflect pipeline + processing-log audit lines.
|
|
Returns an envelope (rows = classified preview; results = per-item outcomes);
|
|
never prints to stdout (audit-append chatter routes to stderr)."""
|
|
import contextlib
|
|
import echo_output
|
|
import echo_reflect
|
|
valid, errors = echo_reflect.validate(proposals)
|
|
rows: list[dict] = []
|
|
counts: dict[str, int] = {}
|
|
if valid:
|
|
echo_reflect.classify(valid)
|
|
counts = {a: sum(1 for p in valid if p.get("_action") == a)
|
|
for a in ("create", "update", "inbox", "error")}
|
|
rows = [{"action": p.get("_action"), "kind": p.get("kind"),
|
|
"title": p["title"], "path": p.get("_path")} for p in valid]
|
|
data = {"proposals": len(proposals or []), "valid": len(valid), "errors": errors,
|
|
"counts": counts, "rows": rows, "dry_run": not confirm,
|
|
"routed": 0, "gated": 0, "log": f"{LOG_DIR}/{echo.today()}.md", "results": []}
|
|
if not valid or not confirm:
|
|
return echo_output.envelope("triage", data)
|
|
|
|
import echo_ops
|
|
today_s = echo.today()
|
|
routed = gated = 0
|
|
results = []
|
|
for p in valid:
|
|
if p.get("_action") in ("error", "inbox"):
|
|
continue # routing an inbox line back to the inbox is a no-op, not a move
|
|
env = echo_ops.capture_op(
|
|
p.get("kind"), p["title"], body_text=p.get("body") or "",
|
|
aliases=p.get("aliases") or [], sources=p.get("sources") or [],
|
|
tags=p.get("tags") or [],
|
|
date=p.get("date"), domain=p.get("domain", "business"))
|
|
act = env.get("action", "")
|
|
results.append({"title": p["title"], "action": act,
|
|
"path": env.get("path"), "ok": bool(env.get("ok"))})
|
|
if act == "duplicate-gate":
|
|
gated += 1
|
|
continue
|
|
if not env.get("ok"):
|
|
continue
|
|
routed += 1
|
|
original = (p.get("line") or p["title"]).strip()
|
|
with contextlib.redirect_stdout(sys.stderr):
|
|
echo.cmd_append(f"{LOG_DIR}/{today_s}.md",
|
|
f"- {original} → {p.get('_path', '?')}")
|
|
data.update(routed=routed, gated=gated, results=results)
|
|
return echo_output.envelope("triage", data)
|
|
|
|
|
|
def apply(proposals: list[dict], confirm: bool = False) -> int:
|
|
"""CLI wrapper: same preview/summary text as pre-Phase-0. Mirrors reflect's contract."""
|
|
env = route_op(proposals, confirm=confirm)
|
|
for e in env["errors"]:
|
|
print(f"skip: {e}", file=sys.stderr)
|
|
if not env["valid"]:
|
|
print("triage: no valid proposals to route.")
|
|
return 0
|
|
counts = env["counts"]
|
|
print(f"triage: {env['valid']} proposal(s) — "
|
|
f"{counts['create']} new, {counts['update']} update, {counts['inbox']} stay-in-inbox"
|
|
+ (f", {counts['error']} error" if counts["error"] else ""))
|
|
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 env["rows"]))
|
|
if env["dry_run"]:
|
|
print("\ntriage: dry-run — re-run with --apply to route these and log the moves.")
|
|
return 0
|
|
gated = env["gated"]
|
|
print(f"triage: routed {env['routed']}/{env['valid']} item(s); audit in {env['log']}. "
|
|
"Originals kept in the inbox (deletion is explicit-only)."
|
|
+ (f" {gated} stopped at the duplicate gate — re-propose with the existing "
|
|
f"entity's title or use capture --merge-into." if gated else ""))
|
|
return 0
|