use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
pub(super) fn events_extract_sql(source: &str, key: &str) -> String {
format!(
"CASE WHEN json_valid({source}) THEN \
(SELECT coalesce(group_concat(json_extract(value, '$.{key}'), ' '), '') \
FROM json_each({source})) \
ELSE '' END"
)
}
pub(super) fn create_fts_index(conn: &Connection) -> anyhow::Result<()> {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Immediate)?;
let have_index = tx
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'turns_fts'",
[],
|_| Ok(()),
)
.optional()?
.is_some();
let view_tools = events_extract_sql("events", "tool");
let view_digests = events_extract_sql("events", "args_digest");
let new_tools = events_extract_sql("new.events", "tool");
let new_digests = events_extract_sql("new.events", "args_digest");
let old_tools = events_extract_sql("old.events", "tool");
let old_digests = events_extract_sql("old.events", "args_digest");
tx.execute_batch(&format!(
"CREATE VIEW IF NOT EXISTS turns_fts_content AS
SELECT rowid,
user,
assistant,
{view_tools} AS tool_names,
{view_digests} AS tool_args_digest
FROM turns;
CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5(
user, assistant, tool_names, tool_args_digest,
content='turns_fts_content',
content_rowid='rowid',
tokenize='unicode61'
);
CREATE TRIGGER IF NOT EXISTS turns_fts_after_insert
AFTER INSERT ON turns BEGIN
INSERT INTO turns_fts(rowid, user, assistant, tool_names, tool_args_digest)
VALUES (new.rowid, new.user, new.assistant, {new_tools}, {new_digests});
END;
-- Fires per cascaded row on conversation delete. The 'delete'
-- command must receive the values that were indexed at insert
-- time — guaranteed by the append-only invariant on turns.
CREATE TRIGGER IF NOT EXISTS turns_fts_after_delete
AFTER DELETE ON turns BEGIN
INSERT INTO turns_fts(turns_fts, rowid, user, assistant, tool_names, tool_args_digest)
VALUES ('delete', old.rowid, old.user, old.assistant, {old_tools}, {old_digests});
END;"
))?;
if !have_index {
tx.execute(
&format!(
"INSERT INTO turns_fts(rowid, user, assistant, tool_names, tool_args_digest)
SELECT rowid, user, assistant, {view_tools}, {view_digests} FROM turns"
),
[],
)?;
tracing::info!("created the FTS5 recall index and backfilled existing turns (17.3)");
}
tx.commit()?;
Ok(())
}
enum QueryPart {
Term(String),
Op(&'static str),
}
pub fn sanitize_fts5_query(raw: &str) -> anyhow::Result<String> {
let mut parts: Vec<QueryPart> = Vec::new();
let mut rest = raw;
loop {
let Some(open) = rest.find('"') else {
push_plain_tokens(rest, &mut parts);
break;
};
push_plain_tokens(&rest[..open], &mut parts);
let after_open = &rest[open + 1..];
match after_open.find('"') {
Some(close) => {
let phrase = after_open[..close].trim();
if phrase.chars().any(char::is_alphanumeric) {
parts.push(QueryPart::Term(format!("\"{phrase}\"")));
}
rest = &after_open[close + 1..];
}
None => {
push_plain_tokens(after_open, &mut parts);
break;
}
}
}
let mut out: Vec<String> = Vec::new();
let mut pending_op: Option<&'static str> = None;
for part in parts {
match part {
QueryPart::Term(term) => {
if let Some(op) = pending_op.take() {
out.push(op.to_string());
}
out.push(term);
}
QueryPart::Op(op) => {
if !out.is_empty() && pending_op.is_none() {
pending_op = Some(op);
}
}
}
}
if out.is_empty() {
anyhow::bail!("search query reduced to nothing after FTS5 sanitizing: {raw:?}");
}
Ok(out.join(" "))
}
fn push_plain_tokens(text: &str, parts: &mut Vec<QueryPart>) {
for token in text.split_whitespace() {
match token {
"AND" => parts.push(QueryPart::Op("AND")),
"OR" => parts.push(QueryPart::Op("OR")),
"NOT" => parts.push(QueryPart::Op("NOT")),
"NEAR" => parts.push(QueryPart::Term("\"NEAR\"".to_string())),
_ => {
if let Some(term) = sanitize_bare_token(token) {
parts.push(QueryPart::Term(term));
}
}
}
}
}
fn sanitize_bare_token(token: &str) -> Option<String> {
let stripped: String = token
.chars()
.filter(|c| !matches!(c, '(' | ')' | '*' | '^' | '"'))
.collect();
if !stripped.chars().any(char::is_alphanumeric) {
return None;
}
let is_bareword = stripped
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || !c.is_ascii());
Some(if is_bareword {
stripped
} else {
format!("\"{stripped}\"")
})
}