diff --git a/CHANGELOG.md b/CHANGELOG.md index d70a291f..89425b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,23 +8,25 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed -- **JSON numbers require a complete fraction and exponent.** `json_parse` - no longer accepts `1.`, `1e`, `1e+`, or `-.5`. The tokenizer consumed - those incomplete tokens and `strtod` parsed a prefix (`"1e"` → 1.0), - so MCP / A2A / HTTP bodies silently coerced malformed numbers. +- **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 '\'`. +- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty. +- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no - **Advise-gate cancel is not a bad bearer.** `POST /v1/advise/gate` - **MCP string JSON-RPC ids.** `parse_response` now accepts a decimal-string - **Reconcile rollback no longer wipes the workspace on a failed restore.** - **LaTeX math recursion depth.** `latex_math_to_plain` now stops converting - **Secret key/token writes do not follow a planted dest symlink.** - **Remote `--connect` base URL query/userinfo.** `normalize_api_base_url` +- **JSON numbers require a complete fraction and exponent.** `json_parse` - **MCP registry `env` overrides parent keys.** Subprocess spawn skipped - **Intent reconcile Phase B (JIT ΔS waves).** `POST /v1/reconcile` `mode=ensure` - **A2A unary HTTP errors stay bounded.** `Client::rpc` no longer concatenates - **Remote `--connect` DELETE/PATCH body cap.** Conversation delete and - **Intent LLM prompt text cap.** `build_llm_user_prompt` now truncates -- **Remote TUI: recoverable SSE `error` is not a failed turn.** `RemoteSseTurnConsumer::finish` copied accumulated `error` event text even when the terminal `done` event had `ok: true` (e.g. catalog skip of a stored agent whose JSON failed validation). `done` is authoritative: success clears the result error; failure still prefers `done.error` and falls back to prior `error` events when that field is empty. -- **Unreadable TUI sessions are not empty.** `session_json_is_empty` no ## [0.13.6] — 2026-09-21 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 bee63055..0da73aa2 100644 --- a/include/tenant_store.h +++ b/include/tenant_store.h @@ -1138,7 +1138,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 e9cedd92..de4567a1 100644 --- a/src/tenant_store.cpp +++ b/src/tenant_store.cpp @@ -2361,6 +2361,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"; @@ -2516,7 +2535,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 @@ -2564,7 +2583,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) { @@ -2615,7 +2634,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 > ?)"; @@ -2635,7 +2654,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;