#!/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_inbox(as_json: bool = False) -> int: 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")) if as_json: import echo_output env = echo_output.envelope("triage-list", {"path": INBOX_PATH, "items": items, "count": len(items)}) print(json.dumps(env, ensure_ascii=False)) return 0 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 ` to preview, `--apply` to route.") return 0 def apply(proposals: list[dict], confirm: bool = False) -> int: """Validate -> classify -> preview; with confirm, route each via capture AND write the processing-log audit line. Mirrors echo_reflect.apply's contract exactly.""" import echo_reflect valid, errors = echo_reflect.validate(proposals) for e in errors: print(f"skip: {e}", file=sys.stderr) if not valid: print("triage: no valid proposals to route.") return 0 echo_reflect.classify(valid) counts = {a: sum(1 for p in valid if p.get("_action") == a) for a in ("create", "update", "inbox", "error")} print(f"triage: {len(valid)} proposal(s) — " f"{counts['create']} new, {counts['update']} update, {counts['inbox']} stay-in-inbox" + (f", {counts['error']} error" if counts["error"] else "")) print(echo_reflect.preview(valid)) if not confirm: print("\ntriage: dry-run — re-run with --apply to route these and log the moves.") return 0 import echo_ops today_s = echo.today() applied = gated = 0 for p in valid: if p.get("_action") == "error": continue if p.get("_action") == "inbox": continue # routing an inbox line back to the inbox is a no-op, not a move bodyfile = echo.temp_file((p.get("body") or "").encode()) if p.get("body") else None rc = echo_ops.capture( p.get("kind"), p["title"], bodyfile, aliases=p.get("aliases") or [], sources=p.get("sources") or [], tags=p.get("tags") or [], date=p.get("date"), domain=p.get("domain", "business")) if rc == 76: gated += 1 continue if rc != 0: continue applied += 1 original = (p.get("line") or p["title"]).strip() echo.cmd_append(f"{LOG_DIR}/{today_s}.md", f"- {original} → {p.get('_path', '?')}") print(f"triage: routed {applied}/{len(valid)} item(s); audit in {LOG_DIR}/{today_s}.md. " "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