#!/usr/bin/env python3 """echo_ops.py — high-level ECHO operations layered on the client + index + links. resolve — map a mention to its canonical note path (or where to create it) recall — search, then expand one hop along links/source_notes for connected context link — create bidirectional `## Related` links between two notes capture — one-call write: route + canonical frontmatter + index + auto-link + agent-log Network goes through echo.py; routing/aliases through echo_index; links through echo_links. """ from __future__ import annotations import json import re import sys import urllib.parse 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 import echo_links as links # noqa: E402 # Existing-entity capture appends a dated bullet under this per-kind heading. LOG_HEADING = { "project": "Session History", "person": "Log", "company": "Log", "semantic": "Observations", "area": "Log", "concept": "Notes", "reference": "Notes", "meeting": "Notes", "decision": "Notes", "skill": "Notes", "episodic": "Notes", "working": "Notes", } # ---------------------------------------------------------------- resolve ----- def resolve(mention: str) -> int: 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)) else: print(json.dumps({"match": False, "mention": mention, "suggest_slug": idx_mod.slugify(mention), "note": "no index entry — derive a path with the right --kind and create it"}, ensure_ascii=False, indent=2)) return 0 # ------------------------------------------------------------------- link ----- def link(a_path: str, b_path: str) -> int: a_changed, b_changed = links.link_bidirectional(a_path, b_path) print(f"ok: linked {a_path} <-> {b_path} " f"(added: {'A' if a_changed else '-'}{'B' if b_changed else '-'})") return 0 # ----------------------------------------------------------------- recall ----- def _resolve_link_to_path(target: str, nmap: dict) -> str | None: target = target.strip() if "/" in target: return target if target.endswith(".md") else target + ".md" return nmap.get(idx_mod.slugify(target)) def _brief(path: str) -> None: text = links.get_text(path) if text is None: return print(f"\n### {path}") fm_type = re.search(r"(?m)^type:\s*(.+)$", text) if fm_type: print(f"_type: {fm_type.group(1).strip()}_") # Prefer a Status paragraph; else the first handful of non-empty body lines. body = text.split("\n---", 2)[-1] if text.startswith("---") else text status = re.search(r"(?ms)^##\s+Status\s*$(.*?)(?=^##\s|\Z)", body) if status and status.group(1).strip(): print(status.group(1).strip()[:400]) return shown = 0 for line in body.splitlines(): if not line.strip() or line.startswith("# "): continue print(line[:200]) shown += 1 if shown >= 8: break def recall(query, limit: int = 6) -> int: q = " ".join(query) if isinstance(query, list) else query index = idx_mod.load() nmap = idx_mod.name_map(index) status, body = echo.request("POST", f"{echo.BASE}/search/simple/?query={urllib.parse.quote_plus(q)}") echo.check(status, body, "recall search") try: results = json.loads(body) except json.JSONDecodeError: results = [] hits = [] for r in results[:limit]: fn = r.get("filename") or r.get("path") if fn and fn not in hits: hits.append(fn) rslug, e = idx_mod.resolve(index, q) if e and e.get("path") and e["path"] not in hits: hits.insert(0, e["path"]) seen = set(hits) neighbors = [] # (via, path) for p in hits: text = links.get_text(p) if text is None: continue targets = set(links.all_wikilinks(text)) | set(links.source_notes(text)) for t in targets: tp = _resolve_link_to_path(t, nmap) if tp and tp not in seen: seen.add(tp) neighbors.append((p, tp)) print(f"== recall: {q} ==") print(f"\n# Primary hits ({len(hits)})") for p in hits: _brief(p) print(f"\n# Linked context ({len(neighbors)})") for via, p in neighbors: print(f"\n(via {via})") _brief(p) return 0 # ----------------------------------------------------------- agent log ------- def ensure_daily_log(line: str) -> None: """Resilient: ensure today's daily note + its `## Agent Log` heading exist, then idempotently append the line. Best-effort — never raises into the caller.""" try: date = echo.today() path = f"journal/daily/{date}.md" status, body = echo.request("GET", echo.vault_url(path)) if status == 404: ts, tb = echo.request("GET", echo.vault_url("journal/templates/daily-note-template.md")) tmpl = tb.decode(errors="replace") if ts == 200 else f"# {date}\n\n## Agent Log\n\n## Related\n" tmpl = tmpl.replace("{{date:YYYY-MM-DD}}", date).replace("{{DATE}}", date) echo.request("PUT", echo.vault_url(path), data=tmpl.encode(), headers={"Content-Type": "text/markdown"}) text = tmpl else: text = body.decode(errors="replace") if not re.search(r"(?m)^## Agent Log\s*$", text): echo.request("POST", echo.vault_url(path), data=b"\n\n## Agent Log\n", headers={"Content-Type": "text/markdown"}) status, body = echo.request("GET", echo.vault_url(path)) if status == 200 and line in body.decode(errors="replace"): return echo.request("PATCH", echo.vault_url(path), data=echo.normalize_patch_body((line + "\n").encode(), "append", "heading"), headers={"Operation": "append", "Target-Type": "heading", "Target": f"{date}::Agent Log", "Content-Type": "text/markdown"}) except Exception as exc: print(f"echo_ops: agent-log skipped ({exc})", file=sys.stderr) # ----------------------------------------------------------------- capture --- def _build_note(fm_type: str, status_v: str, today_s: str, title: str, body_text: str, sources: list[str]) -> str: src = "[" + ", ".join(json.dumps(s) for s in sources) + "]" fm = ["---", f"type: {fm_type}"] if status_v: fm.append(f"status: {status_v}") fm += [f"created: {today_s}", f"updated: {today_s}", "tags: []", "agent_written: true", f"source_notes: {src}", "---", "", f"# {title}", ""] out = "\n".join(fm) if body_text.strip(): out += body_text.strip() + "\n" out += "\n## Related\n" return out def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> None: text = links.get_text(path) or "" h1 = links.first_h1(text) or path.rsplit("/", 1)[-1][:-3] heading = LOG_HEADING.get(kind, "Notes") if not re.search(rf"(?m)^##\s+{re.escape(heading)}\s*$", text): echo.request("POST", echo.vault_url(path), data=f"\n\n## {heading}\n".encode(), headers={"Content-Type": "text/markdown"}) firstline = body_text.strip().splitlines()[0] if body_text.strip() else "(update)" bullet = f"- {today_s}: {firstline}" status, body = echo.request("GET", echo.vault_url(path)) if status == 200 and bullet in body.decode(errors="replace"): return echo.request("PATCH", echo.vault_url(path), data=echo.normalize_patch_body((bullet + "\n").encode(), "append", "heading"), headers={"Operation": "append", "Target-Type": "heading", "Target": f"{h1}::{heading}", "Content-Type": "text/markdown"}) 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, date: str | None = None, domain: str = "business", inbox: bool = False, no_log: bool = False) -> int: 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()] # Unknown home -> defer to the inbox (a single idempotent capture line). if inbox or not kind: line = f"- {today_s}: {title}" if body_text.strip(): line += f" — {body_text.strip().splitlines()[0]}" return echo.cmd_append("inbox/captures/inbox.md", line) slug = idx_mod.slugify(title) index = idx_mod.load() _, existing = idx_mod.resolve(index, title) if existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200: path = existing["path"] kind = existing.get("kind", kind) _append_to_existing(path, kind, today_s, body_text) action = "updated" else: if not status_v and kind == "project": status_v = "active" path = idx_mod.derive_path(kind, slug, date=date, domain=domain) note = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title, body_text, sources) echo.cmd_put(path, echo.temp_file(note.encode())) action = "created" idx_mod.upsert(index, slug, path, kind, title, aliases) idx_mod.save(index) # auto-link: any other known entity whose name/alias appears in the body, matched # on word boundaries (and >=3 chars) so a short alias can't match inside a word. linked = 0 for s2, e2 in index.get("entities", {}).items(): if e2.get("path") in (None, path): continue names = [n for n in [e2.get("title", ""), s2, *e2.get("aliases", [])] if len(n) >= 3] if any(re.search(rf"\b{re.escape(n)}\b", body_text, re.IGNORECASE) for n in names): ca, cb = links.link_bidirectional(path, e2["path"]) linked += int(ca or cb) if not no_log: ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]") print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else "")) return 0