This commit is contained in:
jason
2026-06-22 23:55:19 -05:00
parent 151b962662
commit d34fbf7626
15 changed files with 571 additions and 55 deletions
@@ -36,11 +36,19 @@ def resolve(mention: str) -> int:
slug, e = idx_mod.resolve(index, mention)
if e:
print(json.dumps({"match": True, "slug": slug, **e}, ensure_ascii=False, indent=2))
return 0
# No exact match — surface fuzzy candidates so a shortened/expanded name (e.g. "echo
# memory" for the project "echo") reveals the existing note instead of looking absent.
cands = idx_mod.fuzzy_candidates(index, mention)
out = {"match": False, "mention": mention, "suggest_slug": idx_mod.slugify(mention)}
if cands:
out["candidates"] = [{"slug": s, "path": c.get("path"), "title": c.get("title"),
"kind": c.get("kind"), "score": sc} for s, c, sc in cands]
out["note"] = ("no exact match, but similar entities exist (see candidates) — check "
"these before creating a new note; reuse the right path or `echo.py link`.")
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))
out["note"] = "no index entry — derive a path with the right --kind and create it"
print(json.dumps(out, ensure_ascii=False, indent=2))
return 0
@@ -93,13 +101,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]) -> str:
body_text: str, sources: list[str], aliases: 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: []",
"agent_written: true", f"source_notes: {src}", "---", "", f"# {title}", ""]
fm += [f"created: {today_s}", f"updated: {today_s}", "tags: []"]
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) + "]")
fm += ["agent_written: true", f"source_notes: {src}", "---", "", f"# {title}", ""]
out = "\n".join(fm)
if body_text.strip():
out += body_text.strip() + "\n"
@@ -141,11 +152,14 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
# 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:
def done(action: str, path: str, links: int = 0, ok: bool = True, dry: bool = False,
near=None) -> 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)
data = {"kind": kind, "path": path, "title": title, "links_added": links}
if near:
data["near_duplicates"] = near
env = echo_output.envelope(act, data, ok=ok)
print(json.dumps(env, ensure_ascii=False), file=real_stdout)
elif dry:
print(f"would {action} {kind or '-'} -> {path}")
@@ -170,29 +184,47 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
slug = idx_mod.slugify(title)
index = idx_mod.load()
_, existing = idx_mod.resolve(index, title)
match_slug, existing = idx_mod.resolve(index, title)
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)
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),
dry=True, near=near)
near_dupes: list[str] = []
index_title = title # title to record in the index entry
index_aliases = list(aliases)
with quiet():
if existing_reachable:
# Reuse the matched entity's CANONICAL slug — never re-slug from the mention, or
# two index slugs end up pointing at one note. Keep its title; learn the mention
# as an alias when it's a new distinctive form, so this name resolves next time.
slug = match_slug or slug
path = existing["path"]
kind = existing.get("kind", kind)
index_title = existing.get("title") or title
known = {idx_mod._norm(a) for a in existing.get("aliases", [])} | {match_slug}
if idx_mod.slugify(title) not in known and len(title.strip()) >= echo_quality.MIN_NAME_LEN:
index_aliases.append(title)
_append_to_existing(path, kind, today_s, body_text)
action = "updated"
else:
if not status_v and kind == "project":
status_v = "active"
# Surface (don't silently create alongside) an existing entity this resembles —
# the duplication trap when a shortened name didn't resolve exactly.
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 = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title, body_text, sources)
note_aliases = sorted((set(index_aliases) | set(idx_mod.derive_aliases(title))) - {slug})
note = _build_note(idx_mod.KIND_TYPE.get(kind, kind), status_v, today_s, title,
body_text, sources, note_aliases)
echo.cmd_put(path, echo.temp_file(note.encode()))
action = "created"
@@ -201,7 +233,7 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
# 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))
lambda idx: idx_mod.upsert(idx, slug, path, kind, index_title, index_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).
@@ -226,7 +258,12 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
ensure_daily_log(f"- {today_s}: {action} {kind} [[{links.link_token(path)}]]")
if as_json:
done(action, path, links=linked)
done(action, path, links=linked, near=near_dupes)
else:
print(f"ok: {action} {kind} -> {path}" + (f"; auto-linked {linked}" if linked else ""))
if near_dupes:
kind_word = "entity" if len(near_dupes) == 1 else "entities"
print(f"WARNING: similar existing {kind_word} ({', '.join(near_dupes)}) — if this is "
f"the same thing, merge or `echo.py link` instead of keeping a duplicate.",
file=real_stdout)
return 0