forked from jason/echo
ver 1.2
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user