From 42c2c5adaa4072563e5c954e765765330cf308aa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 12:20:35 +0000 Subject: [PATCH] Treat memory tag filters as literal LIKE substrings EntryFilter.tag was wrapped in LIKE %"tag"% without escaping, so foo_bar also matched fooXbar and 100% also matched 1000 on /mem entries tag= and GET /v1/memory/entries?tag=. Escape \, %, and _ and pair the browse filter and FTS tag-boost predicate with ESCAPE '\'. Co-authored-by: Tyler Reckart --- CHANGELOG.md | 7 ++++++ docs/api/memory/entries/list.md | 2 +- docs/concepts/structured-memory.md | 2 +- include/tenant_store.h | 2 +- src/tenant_store.cpp | 27 +++++++++++++++++---- tests/test_memory_entries.cpp | 38 ++++++++++++++++++++++++++++++ 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae12b43..d5c96624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] +### Fixed +- **Memory `tag=` LIKE wildcards are literals.** `/mem entries tag=` and + `GET /v1/memory/entries?tag=` wrapped the tag in `LIKE %"tag"%` without + escaping, so `foo_bar` also matched `fooXbar` and `100%` also matched + `1000`. Escape `\`, `%`, and `_`, and pair both the browse filter and + the FTS tag-boost predicate with `ESCAPE '\'`. + ## [0.13.4] — 2026-09-10 ### Fixed diff --git a/docs/api/memory/entries/list.md b/docs/api/memory/entries/list.md index da9dd420..a370e237 100644 --- a/docs/api/memory/entries/list.md +++ b/docs/api/memory/entries/list.md @@ -16,7 +16,7 @@ Invalidated rows (those with a non-null `valid_to`) are excluded by default; pas | Name | Type | Description | |---------------------|-------------|-------------| | `type` | csv string | `?type=project,reference` — OR-filter on the enum in browse mode; OR-boost in search mode. Unknown values reject 400. | -| `tag` | string | Single-tag substring match against the serialized JSON. Hard filter in browse mode; boost in search mode. | +| `tag` | string | Single-tag substring match against the serialized JSON (`%"tag"%` around the quoted token). `%`, `_`, and `\` are literals, not SQL wildcards. Hard filter in browse mode; boost in search mode. | | `q` | string | FTS5 query. When set, switches to ranked-search mode; tokens are AND-combined with stemming and case-folding. | | `conversation_id` | int | Scope results to one conversation. Returns rows pinned to this conversation **plus** unscoped rows (`conversation_id IS NULL`). Cross-tenant ids are silently dropped. | | `graduated` | bool-ish | Only meaningful with `q` + `conversation_id`. Routes search through `search_entries_graduated`: runs both a conversation-scoped pass and a tenant-wide pass, then [reciprocal-rank-fuses](../../../concepts/structured-memory.md#retrieval) the rankings (conversation pass weighted at 1.5×, tenant-wide at 1.0×). A strong tenant-wide hit can therefore outrank a weaker conversation-local one, fixing the multi-session case where the answer lives in another conversation. | diff --git a/docs/concepts/structured-memory.md b/docs/concepts/structured-memory.md index 08881768..7e2aa4a1 100644 --- a/docs/concepts/structured-memory.md +++ b/docs/concepts/structured-memory.md @@ -274,7 +274,7 @@ Beyond search, four read commands let agents navigate the graph structurally: ### `/mem entries` -Browse the current active set. Newest-first. Optional `type=foo,bar` and `tag=baz` filters apply as hard `WHERE` clauses (browse mode, not search mode). Use this for "show me my recent project entries" — not for finding a specific fact. +Browse the current active set. Newest-first. Optional `type=foo,bar` and `tag=baz` filters apply as hard `WHERE` clauses (browse mode, not search mode). `tag=` is a literal substring of the serialized tags JSON (`%` / `_` / `\` are not wildcards). Use this for "show me my recent project entries" — not for finding a specific fact. ### `/mem entry ` diff --git a/include/tenant_store.h b/include/tenant_store.h index 8afa1db1..028f7f9a 100644 --- a/include/tenant_store.h +++ b/include/tenant_store.h @@ -1135,7 +1135,7 @@ class TenantStore { // `types` and `tag` become *boost factors* — matching rows score // higher rather than non-matching rows being excluded. std::vector types; // OR-set; boost when q is set - std::string tag; // single-tag substring match + std::string tag; // single-tag JSON substring; LIKE wildcards are literals std::string q; // FTS5 query when set int64_t since = 0; // created_at >= since int64_t before_updated_at = 0; // cursor; 0 = latest diff --git a/src/tenant_store.cpp b/src/tenant_store.cpp index b48878fe..bf186800 100644 --- a/src/tenant_store.cpp +++ b/src/tenant_store.cpp @@ -2337,6 +2337,25 @@ MemoryEntry row_to_entry(Stmt& q) { return e; } +// Neutralise SQLite LIKE wildcards so EntryFilter::tag is a literal +// substring of the serialized tags JSON (`["foo_bar"]`). Without this, +// `_` matches any character and `%` matches any run, so tag=foo_bar +// also hits `"fooXbar"`. Paired with `ESCAPE '\'` at the SQL site. +// Named apart from search_lessons' helper so sibling PRs stay independent. +std::string escape_like_wildcards(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (c == '\\' || c == '%' || c == '_') out.push_back('\\'); + out.push_back(c); + } + return out; +} + +std::string tag_like_pattern(const std::string& tag) { + return "%\"" + escape_like_wildcards(tag) + "\"%"; +} + constexpr const char* kRelationCols = "id, tenant_id, source_id, target_id, relation, created_at"; @@ -2492,7 +2511,7 @@ TenantStore::list_entries(int64_t tenant_id, const EntryFilter& f) const { " ELSE 1.0 END"; } if (!f.tag.empty()) { - sql += " * CASE WHEN e.tags LIKE ? THEN " + + sql += " * CASE WHEN e.tags LIKE ? ESCAPE '\\' THEN " + std::to_string(kTagBoost) + " ELSE 1.0 END"; } // Age-decay multiplier. Multiplying by a fraction <1 makes a @@ -2540,7 +2559,7 @@ TenantStore::list_entries(int64_t tenant_id, const EntryFilter& f) const { for (auto& t : f.types) q.bind(idx++, t); std::string tag_pat; if (!f.tag.empty()) { - tag_pat = "%\"" + f.tag + "\"%"; + tag_pat = tag_like_pattern(f.tag); q.bind(idx++, tag_pat); } if (apply_decay) { @@ -2591,7 +2610,7 @@ TenantStore::list_entries(int64_t tenant_id, const EntryFilter& f) const { } sql += ")"; } - if (!f.tag.empty()) sql += " AND tags LIKE ?"; + if (!f.tag.empty()) sql += " AND tags LIKE ? ESCAPE '\\'"; if (f.as_of > 0) { sql += " AND valid_from <= ? " " AND (valid_to IS NULL OR valid_to > ?)"; @@ -2611,7 +2630,7 @@ TenantStore::list_entries(int64_t tenant_id, const EntryFilter& f) const { for (auto& t : f.types) q.bind(idx++, t); std::string tag_pat; if (!f.tag.empty()) { - tag_pat = "%\"" + f.tag + "\"%"; + tag_pat = tag_like_pattern(f.tag); q.bind(idx++, tag_pat); } if (f.as_of > 0) { diff --git a/tests/test_memory_entries.cpp b/tests/test_memory_entries.cpp index dfec670c..14c96323 100644 --- a/tests/test_memory_entries.cpp +++ b/tests/test_memory_entries.cpp @@ -192,6 +192,44 @@ TEST_CASE("memory entries: type / tag / q filters and cursor") { } } +TEST_CASE("tag filter treats LIKE wildcards as literals") { + TempDb db; + TenantStore s; + s.open(db.path.string()); + const int64_t tid = make_tenant(s, "tag-like"); + + auto snake = s.create_entry(tid, "project", "snake", "s", "", + R"(["foo_bar"])"); + auto sibling = s.create_entry(tid, "project", "sib", "s", "", + R"(["fooXbar"])"); + auto pct = s.create_entry(tid, "project", "pct", "s", "", + R"(["100%"])"); + auto thou = s.create_entry(tid, "project", "thou", "s", "", + R"(["1000"])"); + // Serialized JSON stores a backslash as two backslash chars. + auto slash = s.create_entry(tid, "project", "slash", "s", "", + R"(["foo\\bar"])"); + + TenantStore::EntryFilter f; + f.tag = "foo_bar"; + auto rows = s.list_entries(tid, f); + REQUIRE(rows.size() == 1); + CHECK(rows[0].id == snake.id); + + f.tag = "100%"; + rows = s.list_entries(tid, f); + REQUIRE(rows.size() == 1); + CHECK(rows[0].id == pct.id); + + f.tag = "foo\\\\bar"; // two backslashes, matching the JSON text + rows = s.list_entries(tid, f); + REQUIRE(rows.size() == 1); + CHECK(rows[0].id == slash.id); + + (void)sibling; + (void)thou; +} + TEST_CASE("memory relations: create / list / unique conflict / delete") { TempDb db; TenantStore s;