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
25 changes: 13 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,32 @@ 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
- **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`
- **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

Expand Down
2 changes: 1 addition & 1 deletion docs/api/lessons/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions src/tenant_store.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<char>(std::tolower(
static_cast<unsigned char>(c))));
pat.push_back('%');
std::string lowered;
lowered.reserve(query.size());
for (char c : query) {
lowered.push_back(static_cast<char>(std::tolower(
static_cast<unsigned char>(c))));
}
const std::string pat = "%" + like_literal(lowered) + "%";
q.bind(idx++, pat);
q.bind(idx++, pat);
q.bind(idx, static_cast<int64_t>(limit));
Expand Down
33 changes: 33 additions & 0 deletions tests/test_lessons.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading