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
14 changes: 8 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/api/memory/entries/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/structured-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`

Expand Down
2 changes: 1 addition & 1 deletion include/tenant_store.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string> 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
Expand Down
27 changes: 23 additions & 4 deletions src/tenant_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 > ?)";
Expand All @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions tests/test_memory_entries.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading