Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
16 changes: 14 additions & 2 deletions app/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -172,8 +176,16 @@ 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 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
Expand Down
2 changes: 1 addition & 1 deletion docs/data_object_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,40 @@ 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_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(
Expand Down
Loading