forked from jason/echo
ver 1.5.1 — duplicate-gate precision (common tokens, cross-kind)
Live 1.5.0 use caught the gate blocking a decision note over the single vault-common token "echo" (min-normalized overlap gives any one-token entity a 1.0 score). New echo_index.gate_candidates() + token_df(): - gate blocks same-kind candidates only; cross-kind name collisions (a decision titled after its project) warn instead of blocking - a lone shared token blocks only when unique to that entity (df == 1); multi-token overlaps still block fuzzy_candidates (advisory warnings) and exit-76/--merge-into/--force semantics unchanged. +3 offline unit tests, gate e2e cases restructured +2 new; verified live both directions. All suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.5.1
|
||||||
|
|
||||||
|
### Fixed — duplicate gate over-firing on vault-common tokens and cross-kind names
|
||||||
|
|
||||||
|
Live 1.5.0 use surfaced a false positive: creating the decision "echo-memory 1.5.0
|
||||||
|
improvement set" was blocked by the archived project `echo-v-05` (score 1.0), because
|
||||||
|
`score = overlap / min(|tokens|)` lets any entity whose single distinctive token
|
||||||
|
appears in the new title score 1.0 — and "echo" appears across many entities in an
|
||||||
|
echo-centric vault.
|
||||||
|
|
||||||
|
New `echo_index.gate_candidates()` (backed by `token_df()`) applies two precision
|
||||||
|
rules before blocking; `capture` gates through it. `fuzzy_candidates()` — the advisory
|
||||||
|
warning list — is unchanged:
|
||||||
|
|
||||||
|
- **Same-kind only.** A cross-kind name collision (a decision titled after its
|
||||||
|
project, a meeting named for a company) is intentional naming, not a duplicate.
|
||||||
|
Cross-kind lookalikes still warn, never block.
|
||||||
|
- **No single-common-token blocks.** A lone shared token gates only when it is unique
|
||||||
|
to that entity (vault df == 1). Multi-token overlaps still gate even when the
|
||||||
|
individual tokens are common.
|
||||||
|
|
||||||
|
Exit-76 / `--merge-into` / `--force` semantics unchanged. Tests: 3 new offline unit
|
||||||
|
tests (`gate_candidates`), end-to-end gate cases restructured + 2 new (cross-kind
|
||||||
|
no-gate, common-token no-gate); verified against the live vault (the original false
|
||||||
|
positive now creates cleanly; a same-kind multi-token lookalike still gates).
|
||||||
|
|
||||||
## 1.5.0
|
## 1.5.0
|
||||||
|
|
||||||
Six improvements from the July 2026 review + the frontmatter-drift field report.
|
Six improvements from the July 2026 review + the frontmatter-drift field report.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# echo-memory — v1.5.0
|
# echo-memory — v1.5.1
|
||||||
|
|
||||||
Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`.
|
Persistent memory for Claude / CoWork sessions via the **ECHO** Obsidian vault, driven over the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api). The skill makes direct REST calls through a bundled validated client (`scripts/echo.py`). The whole toolchain is **pure Python** (stdlib only), so it runs identically on Windows, macOS, and Linux — no bash, no platform-specific `date`.
|
||||||
|
|
||||||
@@ -384,6 +384,7 @@ If the API returns a connection error, timeout, or `502` (usually Obsidian / the
|
|||||||
|
|
||||||
| Version | Highlights |
|
| Version | Highlights |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
|
| **1.5.1** | **Duplicate-gate precision.** Live use caught the gate blocking a decision note because it shared the single vault-common token "echo" with an archived project (min-normalized scoring gives any single-token entity a 1.0 match). New `gate_candidates()` blocks only on **same-kind** candidates (cross-kind name collisions — a decision titled after its project — warn instead), and a **lone shared token blocks only when unique to that entity** (vault df == 1); multi-token overlaps still block. Advisory warnings (`fuzzy_candidates`) unchanged; exit-76/`--merge-into`/`--force` unchanged. +5 tests; verified against the live vault both ways (false positive creates cleanly, true lookalike still gates). |
|
||||||
| **1.5.0** | **Six-improvement pass: retention, routing, and self-firing memory.** (1) **Capture-update keeps the whole body** — the update path previously appended only the first line of a multi-line body (silent data loss); now the full body rides under the dated bullet. (2) **Frontmatter completeness** — capture stamps a kind-default `status` and kind-seeded `tags` (`--tags` enriches); `fm` is create-or-replace (missing keys are surgically inserted instead of 400-failing); `vault_lint` gains a kind-scoped `incomplete-frontmatter` check; `sweep --apply` backfills (fixes the 18/71-notes field-audit drift, one data source: `KIND_STATUS`/`KIND_REQUIRED_FM`). (3) **Pre-write duplicate gate** — a strong fuzzy candidate stops `capture` (exit 76 + candidates) before the duplicate exists; `--merge-into <slug>` updates the canonical entity, `--force` overrides. (4) **Recall corpus + ranking** — sessions and journal notes join the BM25 corpus (down-weighted); scores fuse freshness (half-life on `updated:`) and status (`active` boosted, `archived` demoted); hits show `updated:`/`status:`; `recall --json`; recall-index schema 2 (auto-rebuilds). (5) **Session hooks** — SessionStart auto-loads memory into context, Stop nudges reflection once per substantive session; fail-safe, CoWork-fallback-aware. (6) **One-tap triage** — `echo.py triage` lists the inbox structured, then classifies → previews → routes accepted items via `capture` with automatic processing-log audit lines; `--json` also lands on `link`/`scope show`. +22 mock end-to-end tests; all suites green. |
|
| **1.5.0** | **Six-improvement pass: retention, routing, and self-firing memory.** (1) **Capture-update keeps the whole body** — the update path previously appended only the first line of a multi-line body (silent data loss); now the full body rides under the dated bullet. (2) **Frontmatter completeness** — capture stamps a kind-default `status` and kind-seeded `tags` (`--tags` enriches); `fm` is create-or-replace (missing keys are surgically inserted instead of 400-failing); `vault_lint` gains a kind-scoped `incomplete-frontmatter` check; `sweep --apply` backfills (fixes the 18/71-notes field-audit drift, one data source: `KIND_STATUS`/`KIND_REQUIRED_FM`). (3) **Pre-write duplicate gate** — a strong fuzzy candidate stops `capture` (exit 76 + candidates) before the duplicate exists; `--merge-into <slug>` updates the canonical entity, `--force` overrides. (4) **Recall corpus + ranking** — sessions and journal notes join the BM25 corpus (down-weighted); scores fuse freshness (half-life on `updated:`) and status (`active` boosted, `archived` demoted); hits show `updated:`/`status:`; `recall --json`; recall-index schema 2 (auto-rebuilds). (5) **Session hooks** — SessionStart auto-loads memory into context, Stop nudges reflection once per substantive session; fail-safe, CoWork-fallback-aware. (6) **One-tap triage** — `echo.py triage` lists the inbox structured, then classifies → previews → routes accepted items via `capture` with automatic processing-log audit lines; `--json` also lands on `link`/`scope show`. +22 mock end-to-end tests; all suites green. |
|
||||||
| **1.4.2** | **CoWork sandbox path resilience.** In a remote CoWork sandbox `${CLAUDE_PLUGIN_ROOT}` can point at a host path the sandbox can't reach (`/var/folders/…`) while the plugin is mounted under `…/mnt/.remote-plugins/…`, so script invocations failed until the agent found the real path by hand. SKILL.md and all eight slash commands now resolve the scripts dir with a fallback — prefer `${CLAUDE_PLUGIN_ROOT}`, else locate the mounted copy under `/sessions/*/mnt/.remote-plugins/*/…` — reused via `$ECHO`/`$LINT`/`$SWEEP`/`$SDIR`. On a normal host the primary path always wins (no behaviour change). `echo-health`/`echo-sweep` `allowed-tools` broadened to match the resolved invocation. (The scripts never hardcoded a path — root cause is the harness env var — but the plugin now self-heals.) |
|
| **1.4.2** | **CoWork sandbox path resilience.** In a remote CoWork sandbox `${CLAUDE_PLUGIN_ROOT}` can point at a host path the sandbox can't reach (`/var/folders/…`) while the plugin is mounted under `…/mnt/.remote-plugins/…`, so script invocations failed until the agent found the real path by hand. SKILL.md and all eight slash commands now resolve the scripts dir with a fallback — prefer `${CLAUDE_PLUGIN_ROOT}`, else locate the mounted copy under `/sessions/*/mnt/.remote-plugins/*/…` — reused via `$ECHO`/`$LINT`/`$SWEEP`/`$SDIR`. On a normal host the primary path always wins (no behaviour change). `echo-health`/`echo-sweep` `allowed-tools` broadened to match the resolved invocation. (The scripts never hardcoded a path — root cause is the harness env var — but the plugin now self-heals.) |
|
||||||
| **1.4.1** | **Baked credentials now win unconditionally.** A per-user baked artifact could still be prompted for credentials on install: the baked `DEFAULT_*` tier was lowest priority, so a stale `ECHO_*` env var or a leftover/placeholder `~/.claude/echo-memory/config.json` shadowed the baked key — even an unedited `"<placeholder>"` value beat it, flipping `is_configured()` to false and triggering the first-run "ask the operator" flow despite valid baked creds. `echo_config.load()` now treats a **complete** baked set (endpoint + key both present) as authoritative — it wins over env vars and the config file and cannot be shadowed; the generic (unbaked) plugin keeps its env → file → first-run flow. New `baked_complete()`; `source()` reports `baked-in (plugin)`. Per-user artifacts rebaked; manifest → 1.4.1. |
|
| **1.4.1** | **Baked credentials now win unconditionally.** A per-user baked artifact could still be prompted for credentials on install: the baked `DEFAULT_*` tier was lowest priority, so a stale `ECHO_*` env var or a leftover/placeholder `~/.claude/echo-memory/config.json` shadowed the baked key — even an unedited `"<placeholder>"` value beat it, flipping `is_configured()` to false and triggering the first-run "ask the operator" flow despite valid baked creds. `echo_config.load()` now treats a **complete** baked set (endpoint + key both present) as authoritative — it wins over env vars and the config file and cannot be shadowed; the generic (unbaked) plugin keeps its env → file → first-run flow. New `baked_complete()`; `source()` reports `baked-in (plugin)`. Per-user artifacts rebaked; manifest → 1.4.1. |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "echo-memory",
|
"name": "echo-memory",
|
||||||
"version": "1.5.0",
|
"version": "1.5.1",
|
||||||
"description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. Cross-platform Python client: one-call capture/resolve/recall/link/triage over an entity index, hybrid BM25 + graph recall spanning entities + sessions/journal (recency/status-aware), a pre-write duplicate gate, complete-frontmatter capture, session hooks that self-fire load/reflect, offline write-ahead queue, lock-guarded concurrency, linter-enforced routing, and /echo-* commands.",
|
"description": "Persistent memory via the ECHO Obsidian vault over the Obsidian Local REST API. Cross-platform Python client: one-call capture/resolve/recall/link/triage over an entity index, hybrid BM25 + graph recall spanning entities + sessions/journal (recency/status-aware), a pre-write duplicate gate, complete-frontmatter capture, session hooks that self-fire load/reflect, offline write-ahead queue, lock-guarded concurrency, linter-enforced routing, and /echo-* commands.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Jason Stedwell"
|
"name": "Jason Stedwell"
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ python3 "$ECHO" triage --list --json # structured inbox listing
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **`capture` is the default write.** Given a title + `--kind`, it: resolves the entity via the index (create-vs-update, no manual search-first), derives the canonical path, stamps **complete** canonical frontmatter (`type/status/created/updated/tags/aliases/agent_written/source_notes` — a kind-appropriate default `status` and the kind seeded as a baseline tag, enriched by `--tags`), updates the index, **auto-links** any known entity mentioned in the body (bidirectionally), and appends today's Agent Log line — one call instead of search→put→bump→log. It also **auto-derives aliases** from the title and **learns the mention as an alias** when updating an entity under a different name. Updating an existing entity appends the **whole body** as a dated block (first line on the bullet, the rest indented under it) — nothing is truncated. `--kind` ∈ `person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`. Use `--inbox` (or omit `--kind`) only when the home is genuinely unknown.
|
- **`capture` is the default write.** Given a title + `--kind`, it: resolves the entity via the index (create-vs-update, no manual search-first), derives the canonical path, stamps **complete** canonical frontmatter (`type/status/created/updated/tags/aliases/agent_written/source_notes` — a kind-appropriate default `status` and the kind seeded as a baseline tag, enriched by `--tags`), updates the index, **auto-links** any known entity mentioned in the body (bidirectionally), and appends today's Agent Log line — one call instead of search→put→bump→log. It also **auto-derives aliases** from the title and **learns the mention as an alias** when updating an entity under a different name. Updating an existing entity appends the **whole body** as a dated block (first line on the bullet, the rest indented under it) — nothing is truncated. `--kind` ∈ `person, company, concept, reference, meeting, project, area, semantic, episodic, working, skill, decision`. Use `--inbox` (or omit `--kind`) only when the home is genuinely unknown.
|
||||||
- **Duplicate gate (create path).** When a new title strongly resembles an existing entity (fuzzy score ≥ 0.6, env `ECHO_DUP_GATE`), `capture` **stops before writing** and exits `76` with the candidate list, instead of creating a near-duplicate and warning afterwards. Resolve it deliberately: `--merge-into <slug>` routes the capture as an update to the existing entity (the new name is learned as an alias), or `--force` creates anyway after you've confirmed they're genuinely distinct. Weak resemblances still create + warn as before.
|
- **Duplicate gate (create path).** When a new title strongly resembles an existing entity (fuzzy score ≥ 0.6, env `ECHO_DUP_GATE`), `capture` **stops before writing** and exits `76` with the candidate list, instead of creating a near-duplicate and warning afterwards. Two precision rules (1.5.1) keep it from over-firing: the gate only blocks on a **same-kind** candidate (a decision or meeting named after a project/person is intentional naming — those surface as warnings only), and a **single shared token blocks only when it's unique to that entity** (a vault-common word like a project family name can't gate everything that mentions it; multi-token overlaps still block). Resolve a gate stop deliberately: `--merge-into <slug>` routes the capture as an update to the existing entity (the new name is learned as an alias), or `--force` creates anyway after you've confirmed they're genuinely distinct. Weak resemblances still create + warn as before.
|
||||||
- **`resolve` before constructing any path by hand** — it returns the canonical note (matching slug/title/alias, so *Bob/Robert/RS* converge) or, when nothing matches exactly, a ranked list of **`candidates`** (entities sharing a distinctive token — e.g. "echo memory" surfaces the project `echo`). Always check candidates before creating a new note; a shortened name not matching exactly is the classic way a duplicate gets made. This replaces the two-search "search-first" dance for slug-addressed notes.
|
- **`resolve` before constructing any path by hand** — it returns the canonical note (matching slug/title/alias, so *Bob/Robert/RS* converge) or, when nothing matches exactly, a ranked list of **`candidates`** (entities sharing a distinctive token — e.g. "echo memory" surfaces the project `echo`). Always check candidates before creating a new note; a shortened name not matching exactly is the classic way a duplicate gets made. This replaces the two-search "search-first" dance for slug-addressed notes.
|
||||||
- **`recall` for "what do we know about X"** — it returns the matching notes *and* their one-hop neighbourhood (Related links, `source_notes`), so recall surfaces the web around a topic, not an isolated note. The corpus covers the entity graph **plus session logs and journal notes** (down-weighted, so entity notes win ties) — past discussions and decisions are findable even when nobody promoted them into an entity note. Ranking fuses BM25 with **freshness** (`updated:` half-life decay) and **status** (`active` boosted, `archived` demoted), and each hit prints its `updated:`/`status:` so staleness is visible. `--json` emits structured hits (`path/score/type/updated/status/excerpt`) instead of prose.
|
- **`recall` for "what do we know about X"** — it returns the matching notes *and* their one-hop neighbourhood (Related links, `source_notes`), so recall surfaces the web around a topic, not an isolated note. The corpus covers the entity graph **plus session logs and journal notes** (down-weighted, so entity notes win ties) — past discussions and decisions are findable even when nobody promoted them into an entity note. Ranking fuses BM25 with **freshness** (`updated:` half-life decay) and **status** (`active` boosted, `archived` demoted), and each hit prints its `updated:`/`status:` so staleness is visible. `--json` emits structured hits (`path/score/type/updated/status/excerpt`) instead of prose.
|
||||||
- **`link`** when two existing notes are related but not yet connected; `capture` already auto-links what it detects.
|
- **`link`** when two existing notes are related but not yet connected; `capture` already auto-links what it detects.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from collections import Counter
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
@@ -236,6 +237,56 @@ def fuzzy_candidates(index: dict, mention: str, limit: int = 5):
|
|||||||
return [(slug, e, score) for score, _, slug, e in scored[:limit]]
|
return [(slug, e, score) for score, _, slug, e in scored[:limit]]
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_tokens(slug: str, e: dict) -> set[str]:
|
||||||
|
toks: set[str] = set()
|
||||||
|
for name in (slug, e.get("title", ""), *e.get("aliases", [])):
|
||||||
|
toks |= _sig_tokens(name)
|
||||||
|
return toks
|
||||||
|
|
||||||
|
|
||||||
|
def token_df(index: dict) -> Counter:
|
||||||
|
"""Vault-wide token document frequency: token -> how many entities carry it in their
|
||||||
|
slug/title/aliases. A token shared by several entities ("echo" in an echo-centric
|
||||||
|
vault) is a weak duplicate signal on its own; df lets the gate discount it."""
|
||||||
|
df: Counter = Counter()
|
||||||
|
for slug, e in index.get("entities", {}).items():
|
||||||
|
for t in _entity_tokens(slug, e):
|
||||||
|
df[t] += 1
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def gate_candidates(index: dict, mention: str, kind: str | None = None,
|
||||||
|
threshold: float = 0.6):
|
||||||
|
"""The subset of fuzzy_candidates strong enough to BLOCK a create (capture's
|
||||||
|
duplicate gate). Stricter than the advisory warning list, by two rules learned
|
||||||
|
from live use (1.5.1):
|
||||||
|
|
||||||
|
* same-kind only — a cross-kind name collision (a decision titled after its
|
||||||
|
project, a meeting named for a company) is usually intentional naming, not a
|
||||||
|
duplicate. Cross-kind lookalikes still surface as warnings, never as a block.
|
||||||
|
* no single-common-token blocks — score = overlap/min(|tokens|) means an entity
|
||||||
|
whose ONE distinctive token appears in the mention scores 1.0, so any title
|
||||||
|
containing a vault-common word ("echo") would be blocked by every such entity.
|
||||||
|
A lone shared token only blocks when it is unique to that entity (df == 1).
|
||||||
|
|
||||||
|
Returns [(slug, entry, score)] like fuzzy_candidates."""
|
||||||
|
mtoks = _sig_tokens(mention)
|
||||||
|
if not mtoks:
|
||||||
|
return []
|
||||||
|
df = token_df(index)
|
||||||
|
out = []
|
||||||
|
for slug, e, score in fuzzy_candidates(index, mention):
|
||||||
|
if score < threshold:
|
||||||
|
continue
|
||||||
|
if kind and e.get("kind") and e["kind"] != kind:
|
||||||
|
continue
|
||||||
|
overlap = mtoks & _entity_tokens(slug, e)
|
||||||
|
if len(overlap) == 1 and df[next(iter(overlap))] > 1:
|
||||||
|
continue
|
||||||
|
out.append((slug, e, score))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def upsert(index: dict, slug: str, path: str, kind: str, title: str | None = None,
|
def upsert(index: dict, slug: str, path: str, kind: str, title: str | None = None,
|
||||||
aliases=None) -> dict:
|
aliases=None) -> dict:
|
||||||
ents = index.setdefault("entities", {})
|
ents = index.setdefault("entities", {})
|
||||||
|
|||||||
@@ -228,12 +228,14 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
|
|||||||
# Pre-write duplicate gate: a strong fuzzy candidate means this title is very likely
|
# 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 —
|
# 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).
|
# after the fact, the warning arrives too late (the parallel note already exists).
|
||||||
|
# gate_candidates applies the 1.5.1 precision rules (same-kind only; a lone shared
|
||||||
|
# token blocks only when unique to that entity) so vault-common words can't gate.
|
||||||
gate_hits = []
|
gate_hits = []
|
||||||
if not existing_reachable and not force and not dry_run:
|
if not existing_reachable and not force and not dry_run:
|
||||||
gate_hits = [{"slug": s, "path": c.get("path"), "title": c.get("title"),
|
gate_hits = [{"slug": s, "path": c.get("path"), "title": c.get("title"),
|
||||||
"kind": c.get("kind"), "score": sc}
|
"kind": c.get("kind"), "score": sc}
|
||||||
for s, c, sc in idx_mod.fuzzy_candidates(index, title)
|
for s, c, sc in idx_mod.gate_candidates(index, title, kind=kind,
|
||||||
if sc >= DUP_GATE]
|
threshold=DUP_GATE)]
|
||||||
if gate_hits:
|
if gate_hits:
|
||||||
if as_json:
|
if as_json:
|
||||||
env = echo_output.envelope("duplicate-gate", {
|
env = echo_output.envelope("duplicate-gate", {
|
||||||
@@ -255,14 +257,14 @@ def capture(kind: str | None, title: str, file_arg: str | None, status_v: str =
|
|||||||
if existing_reachable:
|
if existing_reachable:
|
||||||
return done("update", existing["path"], dry=True)
|
return done("update", existing["path"], dry=True)
|
||||||
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
|
s2 = echo_quality.safe_slug(set(index.get("entities", {}).keys()), slug)
|
||||||
cands = idx_mod.fuzzy_candidates(index, title)
|
near = [c.get("path") for _, c, _ in idx_mod.fuzzy_candidates(index, title)][:3]
|
||||||
near = [c.get("path") for _, c, _ in cands][:3]
|
|
||||||
plan = done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
|
plan = done("create", idx_mod.derive_path(kind, s2, date=date, domain=domain),
|
||||||
dry=True, near=near)
|
dry=True, near=near)
|
||||||
if not as_json and not force and any(sc >= DUP_GATE for _, _, sc in cands):
|
if (not as_json and not force
|
||||||
|
and idx_mod.gate_candidates(index, title, kind=kind, threshold=DUP_GATE)):
|
||||||
# (in --json mode the near_duplicates field carries this; keep stdout clean)
|
# (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 "
|
print("note: a real run would STOP at the duplicate gate — use --merge-into "
|
||||||
f">= {DUP_GATE}) — use --merge-into or --force.", file=real_stdout)
|
"or --force.", file=real_stdout)
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
near_dupes: list[str] = []
|
near_dupes: list[str] = []
|
||||||
|
|||||||
@@ -148,6 +148,51 @@ def test_fuzzy_candidates_ignores_common_and_short_tokens() -> None:
|
|||||||
assert echo_index.fuzzy_candidates(index, "data pipeline") == []
|
assert echo_index.fuzzy_candidates(index, "data pipeline") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_candidates_blocks_same_kind_rare_token() -> None:
|
||||||
|
# The blocking case the gate exists for: same kind, and the shared token is unique
|
||||||
|
# to that one entity — "Robert Smith" vs the person bob-smith must gate.
|
||||||
|
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
|
||||||
|
"kind": "person", "title": "Bob Smith",
|
||||||
|
"aliases": []}}}
|
||||||
|
gated = echo_index.gate_candidates(index, "Robert Smith", kind="person")
|
||||||
|
assert gated and gated[0][0] == "bob-smith"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_candidates_skips_cross_kind() -> None:
|
||||||
|
# A company/decision named after a person or project is intentional naming, not a
|
||||||
|
# duplicate — cross-kind lookalikes warn but never block.
|
||||||
|
index = {"entities": {"bob-smith": {"path": "resources/people/bob-smith.md",
|
||||||
|
"kind": "person", "title": "Bob Smith",
|
||||||
|
"aliases": []}}}
|
||||||
|
assert echo_index.gate_candidates(index, "Smith Consulting", kind="company") == []
|
||||||
|
# ...but the advisory candidate list still surfaces it:
|
||||||
|
assert echo_index.fuzzy_candidates(index, "Smith Consulting")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gate_candidates_skips_single_vault_common_token() -> None:
|
||||||
|
# The live 1.5.0 false positive: "echo" appears in many entity names, so any title
|
||||||
|
# containing it scored 1.0 against every single-token "echo" entity. A lone shared
|
||||||
|
# token only blocks when it is unique to that entity (df == 1).
|
||||||
|
ents = {
|
||||||
|
"echo": {"path": "projects/active/echo.md", "kind": "project",
|
||||||
|
"title": "echo", "aliases": []},
|
||||||
|
"echo-v-05": {"path": "projects/archived/echo-v.05.md", "kind": "project",
|
||||||
|
"title": "echo-v.05", "aliases": []},
|
||||||
|
}
|
||||||
|
assert echo_index.gate_candidates({"entities": ents}, "echo handbook",
|
||||||
|
kind="project") == []
|
||||||
|
# multi-token overlap still blocks, even when the individual tokens are common:
|
||||||
|
ents["echo-memory-system"] = {"path": "projects/active/echo-memory-system.md",
|
||||||
|
"kind": "project", "title": "echo memory system",
|
||||||
|
"aliases": []}
|
||||||
|
ents["echo-memory-notes"] = {"path": "resources/concepts/echo-memory-notes.md",
|
||||||
|
"kind": "concept", "title": "echo memory notes",
|
||||||
|
"aliases": []}
|
||||||
|
gated = echo_index.gate_candidates({"entities": ents}, "echo memory platform",
|
||||||
|
kind="project")
|
||||||
|
assert [s for s, _, _ in gated] == ["echo-memory-system"]
|
||||||
|
|
||||||
|
|
||||||
def test_aliases_in_frontmatter_flow_and_block() -> None:
|
def test_aliases_in_frontmatter_flow_and_block() -> None:
|
||||||
flow = '---\ntype: project\naliases: ["echo memory", "echo plugin"]\n---\n# x\n'
|
flow = '---\ntype: project\naliases: ["echo memory", "echo plugin"]\n---\n# x\n'
|
||||||
assert echo_index.aliases_in_frontmatter(flow) == ["echo memory", "echo plugin"]
|
assert echo_index.aliases_in_frontmatter(flow) == ["echo memory", "echo plugin"]
|
||||||
|
|||||||
+20
-5
@@ -190,14 +190,29 @@ def main():
|
|||||||
check("v1.5 update keeps EVERY body line",
|
check("v1.5 update keeps EVERY body line",
|
||||||
" second line survives" in bob3 and " third line too" in bob3, bob3)
|
" second line survives" in bob3 and " third line too" in bob3, bob3)
|
||||||
|
|
||||||
# 12. duplicate gate: a strong fuzzy candidate STOPS creation (exit 76)...
|
# 12. duplicate gate: same-kind + distinctive-token candidate STOPS creation...
|
||||||
r = h.echo(ECHO, "capture", "Smith Consulting", "--kind", "company")
|
r = h.echo(ECHO, "capture", "Robert Smith", "--kind", "person")
|
||||||
check("v1.5 duplicate gate stops the create (exit 76)", r.returncode == 76, str(r.returncode))
|
check("v1.5 duplicate gate stops the create (exit 76)", r.returncode == 76, str(r.returncode))
|
||||||
check("v1.5 gate wrote nothing", h.ground("resources/companies/smith-consulting.md") is None)
|
check("v1.5 gate wrote nothing", h.ground("resources/people/robert-smith.md") is None)
|
||||||
# ... --force overrides ...
|
# ... --force overrides ...
|
||||||
r = h.echo(ECHO, "capture", "Smith Consulting", "--kind", "company", "--force")
|
r = h.echo(ECHO, "capture", "Robert Smith", "--kind", "person", "--force")
|
||||||
check("v1.5 --force creates past the gate",
|
check("v1.5 --force creates past the gate",
|
||||||
h.ground("resources/companies/smith-consulting.md") is not None, r.stdout + r.stderr)
|
h.ground("resources/people/robert-smith.md") is not None, r.stdout + r.stderr)
|
||||||
|
# 12b (1.5.1). cross-kind lookalike does NOT gate — it creates with a warning.
|
||||||
|
r = h.echo(ECHO, "capture", "Smith Consulting", "--kind", "company")
|
||||||
|
check("v1.5.1 cross-kind lookalike is not gated",
|
||||||
|
r.returncode == 0 and h.ground("resources/companies/smith-consulting.md") is not None,
|
||||||
|
str(r.returncode) + r.stdout + r.stderr)
|
||||||
|
check("v1.5.1 cross-kind lookalike still warns",
|
||||||
|
"similar existing" in (r.stdout + r.stderr), r.stdout + r.stderr)
|
||||||
|
# 12c (1.5.1). a single vault-common token does NOT gate: once "acme" appears in
|
||||||
|
# two entities, "Acme Tools" creates (with a warning) instead of blocking.
|
||||||
|
h.echo(ECHO, "capture", "Acme", "--kind", "company")
|
||||||
|
h.echo(ECHO, "capture", "Acme Group", "--kind", "company", "--force")
|
||||||
|
r = h.echo(ECHO, "capture", "Acme Tools", "--kind", "company")
|
||||||
|
check("v1.5.1 single common token does not gate",
|
||||||
|
r.returncode == 0 and h.ground("resources/companies/acme-tools.md") is not None,
|
||||||
|
str(r.returncode) + r.stdout + r.stderr)
|
||||||
# ... and --merge-into routes the capture as an update to the canonical entity.
|
# ... and --merge-into routes the capture as an update to the canonical entity.
|
||||||
r = h.echo(ECHO, "capture", "Bobby The Builder", "--kind", "person", "--merge-into", "bob-smith")
|
r = h.echo(ECHO, "capture", "Bobby The Builder", "--kind", "person", "--merge-into", "bob-smith")
|
||||||
idx4 = h.ground("_agent/index/entities.json") or ""
|
idx4 = h.ground("_agent/index/entities.json") or ""
|
||||||
|
|||||||
Reference in New Issue
Block a user