2026-06-21 11:46:54 -05:00
|
|
|
#!/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
|
|
|
|
|
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 -----
|
2026-06-22 09:27:36 -05:00
|
|
|
def recall(query, limit: int = 8) -> int:
|
|
|
|
|
"""Hybrid lexical (BM25) + graph recall — implemented in echo_recall. Kept here as
|
|
|
|
|
the stable public entrypoint (echo.py and /echo-recall call echo_ops.recall)."""
|
|
|
|
|
import echo_recall # lazy: only pulls the BM25 modules when recall is actually run
|
|
|
|
|
return echo_recall.recall(query, limit=limit)
|
2026-06-21 11:46:54 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ----------------------------------------------------------- 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",
|
2026-06-22 09:27:36 -05:00
|
|
|
inbox: bool = False, no_log: bool = False, as_json: bool = False,
|
|
|
|
|
dry_run: bool = False) -> int:
|
|
|
|
|
import contextlib
|
|
|
|
|
import io
|
|
|
|
|
import echo_output
|
|
|
|
|
import echo_quality
|
|
|
|
|
|
|
|
|
|
real_stdout = sys.stdout
|
|
|
|
|
|
|
|
|
|
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) -> int:
|
|
|
|
|
if as_json:
|
|
|
|
|
act = f"dry-run:{action}" if dry else action
|
|
|
|
|
env = echo_output.envelope(act, {"kind": kind, "path": path, "title": title,
|
|
|
|
|
"links_added": links}, 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
|
|
|
|
|
|
2026-06-21 11:46:54 -05:00
|
|
|
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:
|
2026-06-22 09:27:36 -05:00
|
|
|
if dry_run:
|
|
|
|
|
return done("inbox", "inbox/captures/inbox.md", dry=True)
|
2026-06-21 11:46:54 -05:00
|
|
|
line = f"- {today_s}: {title}"
|
|
|
|
|
if body_text.strip():
|
|
|
|
|
line += f" — {body_text.strip().splitlines()[0]}"
|
2026-06-22 09:27:36 -05:00
|
|
|
with quiet():
|
|
|
|
|
rc = echo.cmd_append("inbox/captures/inbox.md", line)
|
|
|
|
|
return done("inbox", "inbox/captures/inbox.md", ok=rc == 0)
|
2026-06-21 11:46:54 -05:00
|
|
|
|
|
|
|
|
slug = idx_mod.slugify(title)
|
|
|
|
|
index = idx_mod.load()
|
|
|
|
|
_, existing = idx_mod.resolve(index, title)
|
2026-06-22 09:27:36 -05:00
|
|
|
existing_reachable = bool(existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200)
|
|
|
|
|
|
|
|
|
|
if dry_run:
|
|
|
|
|
if existing_reachable:
|
|
|
|
|
return done("update", existing["path"], dry=True)
|
|
|
|
|
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
|
|
|
|
|
return done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain), dry=True)
|
|
|
|
|
|
|
|
|
|
with quiet():
|
|
|
|
|
if existing_reachable:
|
|
|
|
|
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"
|
|
|
|
|
# M2: don't let a 40-char-truncated slug silently collide with a different
|
|
|
|
|
# entity already in the index — disambiguate to slug-2, slug-3, ...
|
|
|
|
|
slug = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
# H3: the entity-index write is the race the review flagged — a bare load->save lets
|
|
|
|
|
# two concurrent captures clobber each other's entry. Route it through a lock-guarded,
|
|
|
|
|
# fresh-re-read transaction. Returns the fresh index (used by auto-link below).
|
|
|
|
|
import echo_concurrency
|
|
|
|
|
index = echo_concurrency.atomic_index_update(
|
|
|
|
|
lambda idx: idx_mod.upsert(idx, slug, path, kind, title, aliases))
|
|
|
|
|
|
|
|
|
|
# auto-link: any other known entity CONFIDENTLY named in the body (M2: is_confident_link
|
|
|
|
|
# rejects short/common tokens so a 3-char alias or a word like "API" can't link everywhere).
|
|
|
|
|
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 n]
|
|
|
|
|
if any(echo_quality.is_confident_link(n, body_text) for n in names):
|
|
|
|
|
ca, cb = links.link_bidirectional(path, e2["path"])
|
|
|
|
|
linked += int(ca or cb)
|
|
|
|
|
|
|
|
|
|
# Keep the BM25 recall index current for this note (best-effort; lock-guarded inside
|
|
|
|
|
# update_note, so it can't clobber a concurrent writer either).
|
|
|
|
|
try:
|
|
|
|
|
import echo_recall
|
|
|
|
|
echo_recall.update_note(path, links.get_text(path) or "")
|
|
|
|
|
except Exception as exc: # never let recall-index upkeep fail a capture
|
|
|
|
|
print(f"echo_ops: recall-index update skipped ({exc})", file=sys.stderr)
|
|
|
|
|
|
|
|
|
|
if not no_log:
|
|
|
|
|
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
|
|
|
|
|
|
|
|
|
|
if as_json:
|
|
|
|
|
done(action, path, links=linked)
|
2026-06-21 11:46:54 -05:00
|
|
|
else:
|
2026-06-22 09:27:36 -05:00
|
|
|
print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else ""))
|
2026-06-21 11:46:54 -05:00
|
|
|
return 0
|