📋 Pre-flight Checks
📝 Bug Description
The interior-double-quote fix from #574 was applied to sanitizeFTS, but not to its sibling sanitizeFTSCandidates — the builder used by match_mode: "any". So the exact bug #574 closed is still reachable, just through the other code path.
main @ 763a6ba4:
internal/store/store.go:6585 — fixed:
func sanitizeFTS(query string) string {
words := strings.Fields(query)
for i, w := range words {
w = strings.Trim(w, `"`)
// Double interior double-quotes: FTS5 escapes a literal " inside a
// quoted phrase by doubling it (""). ... See #574.
w = strings.ReplaceAll(w, `"`, `""`)
words[i] = `"` + w + `"`
}
return strings.Join(words, " ")
}
internal/store/relations.go:1039 — not fixed:
func sanitizeFTSCandidates(title string) string {
words := strings.Fields(title)
// ...
for _, w := range words {
w = strings.Trim(w, `"`) // <-- only trims the ENDS
if w != "" {
quoted = append(quoted, `"`+w+`"`)
}
}
return strings.Join(quoted, " OR ")
}
strings.Trim only removes leading/trailing quotes, so an interior quote survives and produces an unterminated FTS5 string literal.
internal/store/store.go:3167 — where match_mode: "any" selects it:
if opts.MatchMode == "any" {
ftsQuery = sanitizeFTSCandidates(query)
} else {
ftsQuery = sanitizeFTS(query)
}
Given the query hello"world:
| builder |
produced FTS5 query |
result |
sanitizeFTS (match_mode: "all") |
"hello""world" |
✅ valid |
sanitizeFTSCandidates (match_mode: "any") |
"hello"world" |
❌ unterminated string |
sanitizeFTSCandidates is also called from the relation-scan path in relations.go, where observation titles become the query — so any saved title containing a " can trip the same error there.
🔄 Steps to Reproduce
Self-contained proof of the mechanism, no Go needed (any SQLite ≥ 3.9 with FTS5):
CREATE VIRTUAL TABLE t USING fts5(x);
INSERT INTO t VALUES ('hello world');
-- what sanitizeFTSCandidates produces for hello"world
SELECT * FROM t WHERE t MATCH '"hello"world"'; -- Error: unterminated string
-- what sanitizeFTS produces for the same input
SELECT * FROM t WHERE t MATCH '"hello""world"'; -- OK, 1 row
Through the MCP tool on main:
mem_search(query: "hello\"world", match_mode: "any")
- Save any observation.
- Call
mem_search with a query containing an interior double-quote and match_mode: "any".
- Search fails instead of returning results.
✅ Expected Behavior
match_mode: "any" handles interior double-quotes exactly like match_mode: "all" does after #574 — the quote is escaped by doubling, and the search returns results instead of erroring.
❌ Actual Behavior
Search error: search: SQL logic error: unterminated string (1). Try simpler keywords.
(Captured on 1.11.0, which predates #574 and therefore fails on both modes. On main the "all" path is fixed and only the "any" path still fails — that asymmetry is what this issue is about, and it is established from the source above plus the SQLite repro, not from running 1.11.0.)
🖥️ Environment
- OS: Windows
- Engram Version: 1.11.0 locally; bug analysis is against
main @ 763a6ba4
- Agent / Client: Claude Code
💡 Additional Context
The fix looks like one line — reusing the same escaping sanitizeFTS already does:
w = strings.Trim(w, `"`)
w = strings.ReplaceAll(w, `"`, `""`) // same as sanitizeFTS, see #574
Two things worth considering alongside it, entirely your call:
- Factor the escaping into one helper used by both builders, so the two paths can't drift apart again. That drift is what produced this issue.
- A regression test on the shared helper covering
foo"bar, "foo, foo", and " alone would pin it.
Happy to open the PR with the fix plus tests once this is approved — just let me know if you'd prefer the one-line version or the shared-helper refactor.
📋 Pre-flight Checks
status:approvedbefore a PR can be opened📝 Bug Description
The interior-double-quote fix from #574 was applied to
sanitizeFTS, but not to its siblingsanitizeFTSCandidates— the builder used bymatch_mode: "any". So the exact bug #574 closed is still reachable, just through the other code path.main@763a6ba4:internal/store/store.go:6585— fixed:internal/store/relations.go:1039— not fixed:strings.Trimonly removes leading/trailing quotes, so an interior quote survives and produces an unterminated FTS5 string literal.internal/store/store.go:3167— wherematch_mode: "any"selects it:Given the query
hello"world:sanitizeFTS(match_mode: "all")"hello""world"sanitizeFTSCandidates(match_mode: "any")"hello"world"unterminated stringsanitizeFTSCandidatesis also called from the relation-scan path inrelations.go, where observation titles become the query — so any saved title containing a"can trip the same error there.🔄 Steps to Reproduce
Self-contained proof of the mechanism, no Go needed (any SQLite ≥ 3.9 with FTS5):
Through the MCP tool on
main:mem_searchwith a query containing an interior double-quote andmatch_mode: "any".✅ Expected Behavior
match_mode: "any"handles interior double-quotes exactly likematch_mode: "all"does after #574 — the quote is escaped by doubling, and the search returns results instead of erroring.❌ Actual Behavior
(Captured on 1.11.0, which predates #574 and therefore fails on both modes. On
mainthe"all"path is fixed and only the"any"path still fails — that asymmetry is what this issue is about, and it is established from the source above plus the SQLite repro, not from running 1.11.0.)🖥️ Environment
main@763a6ba4💡 Additional Context
The fix looks like one line — reusing the same escaping
sanitizeFTSalready does:Two things worth considering alongside it, entirely your call:
foo"bar,"foo,foo", and"alone would pin it.Happy to open the PR with the fix plus tests once this is approved — just let me know if you'd prefer the one-line version or the shared-helper refactor.