diff --git a/packages/core/src/agent_memory/core/recall.py b/packages/core/src/agent_memory/core/recall.py index c56eec46..bc368eb5 100644 --- a/packages/core/src/agent_memory/core/recall.py +++ b/packages/core/src/agent_memory/core/recall.py @@ -113,7 +113,8 @@ def _eligible( return eligible def _in_scope(self, path: str, scope: str) -> bool: - return path.startswith(scope.strip("/")) + scope = scope.strip("/") + return path == scope or path.startswith(scope + "/") def _current_at(self, row: sqlite3.Row, moment: dt.datetime) -> bool: if timestamp.parse(str(row["valid_from"])) > moment: diff --git a/packages/core/src/agent_memory/core/slug.py b/packages/core/src/agent_memory/core/slug.py index de2f6345..ec88613c 100644 --- a/packages/core/src/agent_memory/core/slug.py +++ b/packages/core/src/agent_memory/core/slug.py @@ -15,7 +15,12 @@ def slugify(text: str, max_length: int) -> str: folded = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") lowered = _SEPARATORS.sub("-", folded.strip().lower()) cleaned = _RUNS.sub("-", _ILLEGAL.sub("-", lowered)).strip("-") - return cleaned[:max_length].strip("-") + if cleaned: + return cleaned[:max_length].strip("-") + # Non-ASCII-only input produces an empty fold. Fall back to a stable + # digest so the slug is non-empty and deterministic across calls. + import hashlib + return hashlib.sha256(text.encode("utf-8")).hexdigest()[:max_length] def is_valid_slug(text: str) -> bool: diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 5459efce..d7789946 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -163,3 +163,13 @@ def test_slugify_is_idempotent_and_produces_valid_slugs(config): if once: assert is_valid_slug(once) assert slugify(once, config.storage.slug_max_length) == once + + +def test_scope_filter_does_not_match_similar_prefixes(store): + """Regression: scope=user must not match username/, user-notes/, etc. (#17).""" + for name in ("user/preferences/a.md", "username/settings.md", "user-notes/x.md", "user2/p.md"): + seeded.record(abstract=f"Entry in {name}", type="fact", name=name.replace("/", "-")) + + results = seeded.recall(scope="user") + paths = [r["path"] for r in results] + assert all(p == "user" or p.startswith("user/") for p in paths), paths