#!/usr/bin/env python3 """echo_reflect.py — H5: automatic session-reflection capture. [v1.0 — wired] Today memory only accrues when the agent decides to write. This makes accrual the default: at session end the model extracts durable items from the conversation and emits a PROPOSAL set; this module dedups them against the entity index, previews them, and applies the accepted ones via the normal `capture` path — so nothing is written without a go-ahead (honors the "show before large/sensitive writes" safety rule). DIVISION OF LABOR: * Extraction is MODEL-side (only the LLM has the conversation). The agent produces a JSON array of proposals matching PROPOSAL_SCHEMA. * This script is the deterministic spine: validate -> classify (new/update/inbox vs the entity index) -> preview -> apply on confirm. No NL understanding lives here, so it's testable offline. PROPOSAL_SCHEMA (one per durable item the agent wants to remember): { "title": "Bob Smith", # required "kind": "person", # required unless "inbox": true "body": "Principal at MPM; ...", # markdown body (optional) "aliases": ["bob", "rs"], # optional "sources": ["_agent/sessions/..."], # optional backward links "date": "YYYY-MM-DD", # optional (meeting/decision kinds) "domain": "business", # optional (area kind) "inbox": false, # route to inbox when the home is unknown "confidence": 0.0-1.0 # agent's own confidence; gates auto-suggest } CLI (echo.py reflect): dry-run previews; --apply writes — matching bootstrap/migrate/sweep. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import echo # noqa: E402 import echo_index as idx_mod # noqa: E402 MIN_CONFIDENCE = 0.6 def validate(proposals: list[dict]) -> tuple[list[dict], list[str]]: """Return (valid, errors). title always required; kind required unless inbox; a confidence below the floor is routed away (the agent should send it to the inbox).""" valid, errors = [], [] for i, p in enumerate(proposals or []): if not str(p.get("title", "")).strip(): errors.append(f"proposal[{i}]: missing title") continue is_inbox = bool(p.get("inbox")) if not is_inbox: kind = str(p.get("kind", "")).strip() if not kind: errors.append(f"proposal[{i}] '{p['title']}': missing kind (or set inbox:true)") continue if kind not in idx_mod.KIND_FOLDER: errors.append(f"proposal[{i}] '{p['title']}': unknown kind '{kind}'") continue if float(p.get("confidence", 1.0)) < MIN_CONFIDENCE: errors.append(f"proposal[{i}] '{p['title']}': confidence < {MIN_CONFIDENCE} — send to inbox instead") continue valid.append(p) return valid, errors def classify(proposals: list[dict]) -> list[dict]: """Annotate each proposal with `_action` (create / update / inbox) and `_path`, by resolving its title against the entity index (alias-aware), so the preview shows create-vs-append and dedups against what's already in memory.""" index = idx_mod.load() for p in proposals: if p.get("inbox") or not p.get("kind"): p["_action"], p["_path"] = "inbox", "inbox/captures/inbox.md" continue _, e = idx_mod.resolve(index, p["title"]) if e and e.get("path"): p["_action"], p["_path"] = "update", e["path"] else: try: p["_path"] = idx_mod.derive_path( p["kind"], idx_mod.slugify(p["title"]), date=p.get("date"), domain=p.get("domain", "business")) p["_action"] = "create" except echo.EchoError as exc: p["_action"], p["_path"] = "error", str(exc) return proposals def preview(proposals: list[dict]) -> str: """One confirmable line per proposal: action | kind | title -> target path.""" rows = [] for p in proposals: act = p.get("_action", "?") kind = "-" if act == "inbox" else p.get("kind", "?") rows.append(f" {act:7} | {kind:9} | {p['title']} -> {p.get('_path', '?')}") return "\n".join(rows) def apply(proposals: list[dict], confirm: bool = False) -> int: """Validate -> classify -> preview; with confirm=True, apply each via echo_ops.capture (which routes/indexes/links/logs, each self-lock-guarded). Without confirm it is a dry-run that writes nothing — the preview IS the confirmation step.""" valid, errors = validate(proposals) for e in errors: print(f"skip: {e}", file=sys.stderr) if not valid: print("reflect: no valid proposals to apply.") return 0 classify(valid) counts = {a: sum(1 for p in valid if p.get("_action") == a) for a in ("create", "update", "inbox", "error")} print(f"reflect: {len(valid)} proposal(s) — " f"{counts['create']} new, {counts['update']} update, {counts['inbox']} inbox" + (f", {counts['error']} error" if counts["error"] else "")) print(preview(valid)) if not confirm: print("\nreflect: dry-run — re-run with --apply to write these to memory.") return 0 import echo_ops # lazy: the apply path pulls in the capture/index/link stack applied = 0 for p in valid: if p.get("_action") == "error": continue 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 [], date=p.get("date"), domain=p.get("domain", "business"), inbox=bool(p.get("inbox")) or not p.get("kind")) applied += 1 if rc == 0 else 0 print(f"reflect: applied {applied}/{len(valid)} proposal(s).") return 0