forked from jason/echo
249 lines
10 KiB
Python
249 lines
10 KiB
Python
|
|
#!/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
|