From 5ba6e3b7be1ca8134d78038a97343eae6add8356 Mon Sep 17 00:00:00 2001 From: agent-bot Date: Fri, 3 Jul 2026 19:42:16 +0000 Subject: [PATCH 1/2] Implement issue #26 --- README.md | 2 +- app/storage.py | 8 ++++++-- docs/data_object_schema.md | 2 +- tests/unit/test_storage.py | 17 +++++++++++++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 17f4954..695f747 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ HTTP and MCP expose one deterministic retrieval contract. - Both accept `status`, `memory_type`, `tag`, `q`, `sort`, `limit`, and `offset`. - Both return `items`, `total`, `limit`, `offset`, and `has_more`. - `tag` matches exactly. -- `q` is case-insensitive lexical matching over `content` and tags, not semantic search. +- `q` is case-insensitive lexical matching over `content` and tags, not semantic search; `%` and `_` are treated as literal characters. - Filters compose with `AND`. - Sort keys are `id`, `created_at`, `updated_at`, and `last_accessed_at`; timestamp sorts are descending with `id DESC` as a tie-breaker. - `last_accessed_at` is refreshed only for rows actually returned by `GET /memories/{id}`, `GET /memories`, `search_memories`, and `prime_memory_context`. diff --git a/app/storage.py b/app/storage.py index d4e8dd6..dbfa9a1 100644 --- a/app/storage.py +++ b/app/storage.py @@ -24,6 +24,10 @@ def _serialize_tags(tags: list[str]) -> str: return json.dumps(tags) +def _escape_like_pattern(value: str) -> str: + return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + def _row_to_memory(row) -> Memory: return Memory( id=row["id"], @@ -172,8 +176,8 @@ def _build_memory_filters(query: MemoryListQuery | None) -> tuple[str, list[obje parameters.append(query.tag) if query is not None and query.q is not None: - query_pattern = f"%{query.q.lower()}%" - clauses.append("(LOWER(content) LIKE ? OR LOWER(tags) LIKE ?)") + query_pattern = f"%{_escape_like_pattern(query.q.lower())}%" + clauses.append("(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(tags) LIKE ? ESCAPE '\\')") parameters.extend([query_pattern, query_pattern]) return "WHERE " + " AND ".join(clauses), parameters diff --git a/docs/data_object_schema.md b/docs/data_object_schema.md index 83d0b20..9686f69 100644 --- a/docs/data_object_schema.md +++ b/docs/data_object_schema.md @@ -287,7 +287,7 @@ Both surfaces return the same envelope: - `status` and `memory_type` are exact structured filters. - Retrieval excludes `status=deleted` by default unless the caller explicitly requests `status=deleted`. - `tag` is exact matching against the stored tag list. -- `q` is case-insensitive free-text matching over `content` and stored tags. +- `q` is case-insensitive free-text matching over `content` and stored tags. `%` and `_` are matched literally. - Filters compose with `AND`. This distinction matters because exact filters are predictable and contract-friendly, while `q` provides a lightweight lexical narrowing mechanism without introducing opaque ranking behavior. diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 057b862..5fbacdd 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -37,6 +37,23 @@ def test_get_memories_applies_free_text_filter_case_insensitively(): assert [memory.id for memory in results] == [2] +def test_get_memories_treats_free_text_like_wildcards_literally(): + create_memory(MemoryCreate(content="foo_bar", tags=["literal_under"])) + create_memory(MemoryCreate(content="fooXbar", tags=["wild_under"])) + create_memory(MemoryCreate(content="100% complete", tags=["literal_percent"])) + create_memory(MemoryCreate(content="1000 complete", tags=["wild_percent"])) + create_memory(MemoryCreate(content=r"path\_literal", tags=["literal_escape"])) + create_memory(MemoryCreate(content="pathXliteral", tags=["wild_escape"])) + + underscore_results = get_memories(MemoryListQuery(q="foo_bar")) + percent_results = get_memories(MemoryListQuery(q="100%")) + escape_results = get_memories(MemoryListQuery(q=r"path\_")) + + assert [memory.id for memory in underscore_results] == [1] + assert [memory.id for memory in percent_results] == [3] + assert [memory.id for memory in escape_results] == [5] + + def test_get_memories_applies_structured_filters_with_exact_tag_matching(): create_memory( MemoryCreate( From e2f5545b7e650d5346b1c38a2c1d2e9bbc90338c Mon Sep 17 00:00:00 2001 From: Patrick Lacey Date: Sat, 4 Jul 2026 21:34:12 -0400 Subject: [PATCH 2/2] fix(search): match q against decoded tags --- README.md | 2 +- app/storage.py | 10 +++++++++- docs/data_object_schema.md | 2 +- tests/unit/test_storage.py | 17 +++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 695f747..5cba1fb 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ HTTP and MCP expose one deterministic retrieval contract. - Both accept `status`, `memory_type`, `tag`, `q`, `sort`, `limit`, and `offset`. - Both return `items`, `total`, `limit`, `offset`, and `has_more`. - `tag` matches exactly. -- `q` is case-insensitive lexical matching over `content` and tags, not semantic search; `%` and `_` are treated as literal characters. +- `q` is case-insensitive lexical matching over `content` and tags, not semantic search; `%`, `_`, and `\` are treated as literal characters. - Filters compose with `AND`. - Sort keys are `id`, `created_at`, `updated_at`, and `last_accessed_at`; timestamp sorts are descending with `id DESC` as a tie-breaker. - `last_accessed_at` is refreshed only for rows actually returned by `GET /memories/{id}`, `GET /memories`, `search_memories`, and `prime_memory_context`. diff --git a/app/storage.py b/app/storage.py index dbfa9a1..e1da577 100644 --- a/app/storage.py +++ b/app/storage.py @@ -177,7 +177,15 @@ def _build_memory_filters(query: MemoryListQuery | None) -> tuple[str, list[obje if query is not None and query.q is not None: query_pattern = f"%{_escape_like_pattern(query.q.lower())}%" - clauses.append("(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(tags) LIKE ? ESCAPE '\\')") + clauses.append( + "(" + "LOWER(content) LIKE ? ESCAPE '\\' " + "OR EXISTS (" + "SELECT 1 FROM json_each(memories.tags) " + "WHERE LOWER(json_each.value) LIKE ? ESCAPE '\\'" + ")" + ")" + ) parameters.extend([query_pattern, query_pattern]) return "WHERE " + " AND ".join(clauses), parameters diff --git a/docs/data_object_schema.md b/docs/data_object_schema.md index 9686f69..e634bd3 100644 --- a/docs/data_object_schema.md +++ b/docs/data_object_schema.md @@ -287,7 +287,7 @@ Both surfaces return the same envelope: - `status` and `memory_type` are exact structured filters. - Retrieval excludes `status=deleted` by default unless the caller explicitly requests `status=deleted`. - `tag` is exact matching against the stored tag list. -- `q` is case-insensitive free-text matching over `content` and stored tags. `%` and `_` are matched literally. +- `q` is case-insensitive free-text matching over `content` and stored tags. `%`, `_`, and `\` are matched literally. - Filters compose with `AND`. This distinction matters because exact filters are predictable and contract-friendly, while `q` provides a lightweight lexical narrowing mechanism without introducing opaque ranking behavior. diff --git a/tests/unit/test_storage.py b/tests/unit/test_storage.py index 5fbacdd..6a33655 100644 --- a/tests/unit/test_storage.py +++ b/tests/unit/test_storage.py @@ -54,6 +54,23 @@ def test_get_memories_treats_free_text_like_wildcards_literally(): assert [memory.id for memory in escape_results] == [5] +def test_get_memories_treats_free_text_like_wildcards_literally_in_tags(): + create_memory(MemoryCreate(content="First memory", tags=["foo_bar"])) + create_memory(MemoryCreate(content="Second memory", tags=["fooXbar"])) + create_memory(MemoryCreate(content="Third memory", tags=["100% complete"])) + create_memory(MemoryCreate(content="Fourth memory", tags=["1000 complete"])) + create_memory(MemoryCreate(content="Fifth memory", tags=[r"path\_literal"])) + create_memory(MemoryCreate(content="Sixth memory", tags=["pathXliteral"])) + + underscore_results = get_memories(MemoryListQuery(q="foo_bar")) + percent_results = get_memories(MemoryListQuery(q="100%")) + escape_results = get_memories(MemoryListQuery(q=r"path\_")) + + assert [memory.id for memory in underscore_results] == [1] + assert [memory.id for memory in percent_results] == [3] + assert [memory.id for memory in escape_results] == [5] + + def test_get_memories_applies_structured_filters_with_exact_tag_matching(): create_memory( MemoryCreate(