diff --git a/CHANGELOG.md b/CHANGELOG.md index b5075bf5..b427d2eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,11 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). ## [Unreleased] ### Fixed -- **Loop `/kill` wakes the inter-iteration pause.** After each turn, - `run_loop` slept 2s with uninterruptible `sleep_for`, so `/kill` join, - `/inject`, and `/suspend` waited out the delay even though `kill()` - already notified the entry `cv`. Wait on that condition variable - instead; `suspend()` now notifies so the loop parks immediately. -- **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. +- **Lesson search is a literal substring.** `search_lessons` (`GET /v1/lessons?q=` + and `/lesson search`) wrapped the query in `LIKE %q%` without escaping `%` / + `_`, so those characters acted as SQL wildcards. A search for `100%` matched + `1000`, and `foo_bar` matched `fooXbar`. Patterns now escape LIKE + metacharacters (`ESCAPE '\'`). - **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 @@ -22,17 +20,20 @@ loosely while pre-1.0 (breaking changes can land on minor bumps). - **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` -- **Memory `tag=` LIKE wildcards are literals.** `/mem entries tag=` and -- **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 -- **`/schedule` calendar dates.** `on YYYY-MM-DD` now rejects impossible +- **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. - **MCP registry writes do not follow a planted `.tmp` symlink.** -- **Dispatch of stored agents past newest-200.** `GET /v1/agents/:id` already -- **`/fetch` uses the same SSRF hostname preflight as `/browse`.** +- **`/schedule` calendar dates.** `on YYYY-MM-DD` now rejects impossible - **`/schedule` time math fail-closed.** `every hour` / `hourly` now use the +- **JSON numbers require a complete fraction and exponent.** `json_parse` +- **`/fetch` uses the same SSRF hostname preflight as `/browse`.** +- **Loop `/kill` wakes the inter-iteration pause.** After each turn, +- **Memory `tag=` LIKE wildcards are literals.** `/mem entries tag=` and +- **Dispatch of stored agents past newest-200.** `GET /v1/agents/:id` already ## [0.13.7] — 2026-09-21 diff --git a/docs/api/lessons/list.md b/docs/api/lessons/list.md index dec10ea4..de3c7b18 100644 --- a/docs/api/lessons/list.md +++ b/docs/api/lessons/list.md @@ -11,7 +11,7 @@ List the calling tenant's lessons. Newest `last_seen_at` first by default; with | Param | Description | |------------|-------------| | `agent_id` | Filter to one agent's lessons. Omit for the tenant's full set. | -| `q` | Substring match against signature OR lesson_text (case-insensitive). | +| `q` | Literal substring match against signature OR lesson_text (case-insensitive). `%` and `_` are literals, not wildcards. | | `limit` | Cap; max 200 for browse, 50 for search. | ```bash diff --git a/src/tenant_store.cpp b/src/tenant_store.cpp index 4d70592f..35590064 100644 --- a/src/tenant_store.cpp +++ b/src/tenant_store.cpp @@ -189,6 +189,19 @@ std::string fts5_escape(const std::string& q) { return out; } +// Neutralise SQLite LIKE wildcards so a user query is a true substring. +// Paired with `ESCAPE '\'` at the call site. Backslash itself is escaped +// so a search for `\` does not swallow the next character. +std::string like_literal(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 bytes_to_hex(const unsigned char* data, size_t len) { std::ostringstream ss; ss << std::hex << std::setfill('0'); @@ -4259,20 +4272,25 @@ TenantStore::search_lessons(int64_t tenant_id, if (limit <= 0 || limit > 50) limit = 20; // Substring match — case-insensitive on lesson_text + signature. At // tens-to-hundreds of rows per agent this is fine; an FTS index - // would be premature. + // would be premature. Escape LIKE metacharacters so `100%` / `foo_bar` + // are literals (`GET /v1/lessons?q=` and `/lesson search`). std::string sql = std::string("SELECT ") + kLessonCols + " FROM lessons WHERE tenant_id = ?"; if (!agent_id.empty()) sql += " AND agent_id = ?"; - sql += " AND (lower(signature) LIKE ? OR lower(lesson_text) LIKE ?)" + sql += " AND (lower(signature) LIKE ? ESCAPE '\\' " + "OR lower(lesson_text) LIKE ? ESCAPE '\\')" " ORDER BY hit_count DESC, last_seen_at DESC LIMIT ?;"; Stmt q(db_, sql.c_str()); int idx = 1; q.bind(idx++, tenant_id); if (!agent_id.empty()) q.bind(idx++, agent_id); - std::string pat = "%"; - for (char c : query) pat.push_back(static_cast(std::tolower( - static_cast(c)))); - pat.push_back('%'); + std::string lowered; + lowered.reserve(query.size()); + for (char c : query) { + lowered.push_back(static_cast(std::tolower( + static_cast(c)))); + } + const std::string pat = "%" + like_literal(lowered) + "%"; q.bind(idx++, pat); q.bind(idx++, pat); q.bind(idx, static_cast(limit)); diff --git a/tests/test_lessons.cpp b/tests/test_lessons.cpp index a15fc976..ce2521c7 100644 --- a/tests/test_lessons.cpp +++ b/tests/test_lessons.cpp @@ -117,6 +117,39 @@ TEST_CASE("substring search hits signature OR lesson_text, case-insensitive") { CHECK(d.empty()); } +TEST_CASE("substring search treats LIKE wildcards as literals") { + TempDb db; TenantStore s; s.open(db.path.string()); + const int64_t tid = make_tenant(s, "acme"); + + s.create_lesson(tid, "x", "use 100% CPU for encode", + "cap ffmpeg threads"); + s.create_lesson(tid, "x", "use 1000 CPU for encode", + "percent sibling without the sign"); + s.create_lesson(tid, "x", "foo_bar retry", + "underscore signature"); + s.create_lesson(tid, "x", "fooXbar retry", + "single-char sibling"); + s.create_lesson(tid, "x", "path\\windows", + "backslash in signature"); + + auto pct = s.search_lessons(tid, "x", "100%", 50); + REQUIRE(pct.size() == 1); + CHECK(pct[0].signature == "use 100% CPU for encode"); + + auto us = s.search_lessons(tid, "x", "foo_bar", 50); + REQUIRE(us.size() == 1); + CHECK(us[0].signature == "foo_bar retry"); + + auto bs = s.search_lessons(tid, "x", "path\\windows", 50); + REQUIRE(bs.size() == 1); + CHECK(bs[0].signature == "path\\windows"); + + // Still a substring, not an anchored match. + auto sub = s.search_lessons(tid, "x", "100% CPU", 50); + REQUIRE(sub.size() == 1); + CHECK(sub[0].signature == "use 100% CPU for encode"); +} + TEST_CASE("hit bump tracks last_seen_at and ranks by hit_count") { TempDb db; TenantStore s; s.open(db.path.string()); const int64_t tid = make_tenant(s, "acme");