1
0
forked from jason/echo

ver 1.5.0 — six-improvement pass: retention, routing, self-firing memory

1. capture-update keeps the whole body (was truncating to first line)
2. frontmatter completeness: kind-default status + kind-seeded tags at
   capture, fm create-or-replace, incomplete-frontmatter lint, sweep
   backfill (one data source: KIND_STATUS/KIND_REQUIRED_FM)
3. pre-write duplicate gate (exit 76, --merge-into/--force)
4. recall corpus + ranking: sessions/journal indexed (down-weighted),
   BM25 x freshness x status fusion, recall --json, index schema 2
5. session hooks: SessionStart auto-load, Stop reflection nudge
6. one-tap triage verb with processing-log audit; --json on read verbs

+22 mock end-to-end tests; all suites green. Docs current (README,
CHANGELOG, SKILL.md, commands, references, MAINTENANCE, eval README).
Drops tracked .DS_Store (gitignored); retires README-gretchen.md
(moved to gitignored dist/); adds the field report that drove item 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jason Stedwell
2026-07-03 12:57:52 -05:00
parent e76add6192
commit be3fd36ebf
31 changed files with 1209 additions and 174 deletions
@@ -12,6 +12,7 @@ Network goes through echo.py; routing/aliases through echo_index; links through
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
@@ -21,6 +22,11 @@ import echo # noqa: E402
import echo_index as idx_mod # noqa: E402
import echo_links as links # noqa: E402
# A fuzzy candidate scoring at/above this blocks `capture` from creating what is very
# likely a duplicate (require --force to create anyway, or --merge-into to update the
# existing entity). Below the gate, candidates are surfaced as a warning only.
DUP_GATE = float(os.environ.get("ECHO_DUP_GATE", "0.6"))
# Existing-entity capture appends a dated bullet under this per-kind heading.
LOG_HEADING = {
"project": "Session History", "person": "Log", "company": "Log",
@@ -53,19 +59,25 @@ def resolve(mention: str) -> int:
# ------------------------------------------------------------------- link -----
def link(a_path: str, b_path: str) -> int:
def link(a_path: str, b_path: str, as_json: bool = False) -> int:
a_changed, b_changed = links.link_bidirectional(a_path, b_path)
if as_json:
import echo_output
env = echo_output.envelope("link", {"a": a_path, "b": b_path,
"a_changed": a_changed, "b_changed": b_changed})
print(json.dumps(env, ensure_ascii=False))
return 0
print(f"ok: linked {a_path} <-> {b_path} "
f"(added: {'A' if a_changed else '-'}{'B' if b_changed else '-'})")
return 0
# ----------------------------------------------------------------- recall -----
def recall(query, limit: int = 8) -> int:
def recall(query, limit: int = 8, as_json: bool = False) -> 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)
return echo_recall.recall(query, limit=limit, as_json=as_json)
# ----------------------------------------------------------- agent log -------
@@ -101,12 +113,16 @@ def ensure_daily_log(line: str) -> None:
# ----------------------------------------------------------------- capture ---
def _build_note(fm_type: str, status_v: str, today_s: str, title: str,
body_text: str, sources: list[str], aliases: list[str] | None = None) -> str:
body_text: str, sources: list[str], aliases: list[str] | None = None,
tags: list[str] | None = None) -> 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: []"]
# Never scaffold an empty `tags: []` — an entity note is born complete (the caller
# seeds the kind as a baseline tag), so the incomplete-frontmatter lint stays quiet.
tags_v = "[" + ", ".join(tags or []) + "]"
fm += [f"created: {today_s}", f"updated: {today_s}", f"tags: {tags_v}"]
if aliases:
# Obsidian-native, durable home for aliases; sweep folds these back into the index.
fm.append("aliases: [" + ", ".join(json.dumps(a) for a in aliases) + "]")
@@ -118,6 +134,19 @@ def _build_note(fm_type: str, status_v: str, today_s: str, title: str,
return out
def _dated_block(today_s: str, body_text: str) -> tuple[str, str]:
"""Render a capture body as a dated log entry that PRESERVES the whole body:
the first line rides on the bullet, every further line is indented under it as
a markdown continuation. Returns (bullet, full_block) — the bullet alone is the
idempotency key (same first-line-per-day semantics as before)."""
lines = [ln.rstrip() for ln in body_text.strip().splitlines()]
bullet = f"- {today_s}: {lines[0] if lines else '(update)'}"
block = bullet
for ln in lines[1:]:
block += "\n" + (f" {ln}" if ln else "")
return bullet, block
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]
@@ -125,22 +154,22 @@ def _append_to_existing(path: str, kind: str, today_s: str, body_text: str) -> N
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}"
bullet, block = _dated_block(today_s, body_text)
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"),
data=echo.normalize_patch_body((block + "\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, as_json: bool = False,
dry_run: bool = False) -> int:
aliases=None, sources=None, tags=None, date: str | None = None,
domain: str = "business", inbox: bool = False, no_log: bool = False,
as_json: bool = False, dry_run: bool = False, force: bool = False,
merge_into: str | None = None) -> int:
import contextlib
import io
import echo_output
@@ -170,6 +199,7 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
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()]
tags = [t.strip() for t in (tags or []) if t.strip()]
# Unknown home -> defer to the inbox (a single idempotent capture line).
if inbox or not kind:
@@ -185,15 +215,55 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
slug = idx_mod.slugify(title)
index = idx_mod.load()
match_slug, existing = idx_mod.resolve(index, title)
# --merge-into: the operator has already identified the canonical entity (e.g. after
# a duplicate-gate stop) — route this capture as an UPDATE to it, whatever the title.
if merge_into and not existing:
match_slug, existing = idx_mod.resolve(index, merge_into)
if not existing:
print(f"echo_ops: --merge-into '{merge_into}' matches no entity in the index "
f"(try `resolve` first)", file=sys.stderr)
return 2
existing_reachable = bool(existing and echo.request("GET", echo.vault_url(existing["path"]))[0] == 200)
# Pre-write duplicate gate: a strong fuzzy candidate means this title is very likely
# an existing entity under a different name. STOP before creating the duplicate —
# after the fact, the warning arrives too late (the parallel note already exists).
gate_hits = []
if not existing_reachable and not force and not dry_run:
gate_hits = [{"slug": s, "path": c.get("path"), "title": c.get("title"),
"kind": c.get("kind"), "score": sc}
for s, c, sc in idx_mod.fuzzy_candidates(index, title)
if sc >= DUP_GATE]
if gate_hits:
if as_json:
env = echo_output.envelope("duplicate-gate", {
"kind": kind, "title": title, "candidates": gate_hits,
"note": "likely duplicate — re-run with --merge-into <slug> to update the "
"existing entity, or --force to create anyway"}, ok=False)
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
else:
print(f"STOP: '{title}' likely already exists — not creating a duplicate.",
file=real_stdout)
for c in gate_hits:
print(f" candidate: {c['slug']} -> {c['path']} (score {c['score']})",
file=real_stdout)
print(" re-run with --merge-into <slug> to update the existing entity, "
"or --force to create anyway.", file=real_stdout)
return 76 # distinct exit: duplicate gate (cf. 75 lock, 78 config)
if dry_run:
if existing_reachable:
return done("update", existing["path"], dry=True)
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
near = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
return done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
cands = idx_mod.fuzzy_candidates(index, title)
near = [c.get("path") for _, c, _ in cands][:3]
plan = done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
dry=True, near=near)
if not as_json and not force and any(sc >= DUP_GATE for _, _, sc in cands):
# (in --json mode the near_duplicates field carries this; keep stdout clean)
print("note: a real run would STOP at the duplicate gate (candidate score "
f">= {DUP_GATE}) — use --merge-into or --force.", file=real_stdout)
return plan
near_dupes: list[str] = []
index_title = title # title to record in the index entry
@@ -213,18 +283,24 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
_append_to_existing(path, kind, today_s, body_text)
action = "updated"
else:
if not status_v and kind == "project":
status_v = "active"
if not status_v:
# Every entity note is born with a status — a kind-appropriate default
# (KIND_STATUS) instead of the old project-only special case. Missing
# status was the root cause of the frontmatter-completeness drift.
status_v = idx_mod.KIND_STATUS.get(kind, "active")
# Surface (don't silently create alongside) an existing entity this resembles —
# the duplication trap when a shortened name didn't resolve exactly.
# the duplication trap when a shortened name didn't resolve exactly. (Strong
# candidates already stopped at the duplicate gate above; these are the weak
# ones, surfaced as a warning.)
near_dupes = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
# 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_aliases = sorted((set(index_aliases) | set(idx_mod.derive_aliases(title))) - {slug})
note_tags = sorted({kind, *tags}) # baseline `- <kind>` tag; --tags enriches
note = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title,
body_text, sources, note_aliases)
body_text, sources, note_aliases, tags=note_tags)
echo.cmd_put(path, echo.temp_file(note.encode()))
action = "created"