1use anyhow::Context;
10use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
11use sqlx::{Row, SqlitePool};
12use std::path::{Path, PathBuf};
13use std::str::FromStr;
14use uuid::Uuid;
15
16use super::transcripts::{TranscriptLine, TranscriptWriter};
17
18#[derive(Debug, Clone)]
19pub struct IndexedHit {
20 pub session_id: Uuid,
21 pub agent_id: String,
22 pub timestamp_unix: i64,
23 pub role: String,
24 pub source_plugin: String,
25 pub snippet: String,
28}
29
30#[derive(Debug, Clone)]
31pub struct TranscriptsIndex {
32 pool: SqlitePool,
33}
34
35impl TranscriptsIndex {
36 pub async fn open(path: &Path) -> anyhow::Result<Self> {
38 if let Some(parent) = path.parent() {
39 tokio::fs::create_dir_all(parent).await.ok();
40 }
41 let url = format!("sqlite://{}", path.display());
42 let opts = SqliteConnectOptions::from_str(&url)
43 .with_context(|| format!("invalid sqlite url for {}", path.display()))?
44 .create_if_missing(true)
45 .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
46 let pool = SqlitePoolOptions::new()
47 .max_connections(4)
48 .connect_with(opts)
49 .await
50 .with_context(|| format!("opening transcripts index at {}", path.display()))?;
51 sqlx::query(
54 "CREATE VIRTUAL TABLE IF NOT EXISTS transcripts_fts USING fts5(
55 content,
56 agent_id UNINDEXED,
57 session_id UNINDEXED,
58 timestamp_unix UNINDEXED,
59 role UNINDEXED,
60 source_plugin UNINDEXED,
61 tokenize = 'unicode61 remove_diacritics 2'
62 )",
63 )
64 .execute(&pool)
65 .await
66 .context("creating transcripts_fts table")?;
67 Ok(Self { pool })
68 }
69
70 pub async fn insert(
71 &self,
72 agent_id: &str,
73 session_id: Uuid,
74 timestamp_unix: i64,
75 role: &str,
76 source_plugin: &str,
77 content: &str,
78 ) -> anyhow::Result<()> {
79 sqlx::query(
80 "INSERT INTO transcripts_fts (content, agent_id, session_id, timestamp_unix, role, source_plugin) \
81 VALUES (?, ?, ?, ?, ?, ?)",
82 )
83 .bind(content)
84 .bind(agent_id)
85 .bind(session_id.to_string())
86 .bind(timestamp_unix)
87 .bind(role)
88 .bind(source_plugin)
89 .execute(&self.pool)
90 .await
91 .context("inserting into transcripts_fts")?;
92 Ok(())
93 }
94
95 pub async fn search(
99 &self,
100 agent_id: &str,
101 query: &str,
102 limit: usize,
103 ) -> anyhow::Result<Vec<IndexedHit>> {
104 let limit = limit.clamp(1, 500) as i64;
105 let phrase = escape_fts_phrase(query);
106 let rows = sqlx::query(
107 "SELECT session_id, agent_id, timestamp_unix, role, source_plugin, \
108 snippet(transcripts_fts, 0, '[', ']', '...', 12) AS snip \
109 FROM transcripts_fts \
110 WHERE agent_id = ? AND content MATCH ? \
111 ORDER BY rank \
112 LIMIT ?",
113 )
114 .bind(agent_id)
115 .bind(&phrase)
116 .bind(limit)
117 .fetch_all(&self.pool)
118 .await
119 .context("querying transcripts_fts")?;
120 let mut out = Vec::with_capacity(rows.len());
121 for row in rows {
122 let sid: String = row.try_get("session_id")?;
123 let session_id = Uuid::parse_str(&sid).unwrap_or_else(|_| Uuid::nil());
124 out.push(IndexedHit {
125 session_id,
126 agent_id: row.try_get("agent_id")?,
127 timestamp_unix: row.try_get("timestamp_unix")?,
128 role: row.try_get("role")?,
129 source_plugin: row.try_get("source_plugin")?,
130 snippet: row.try_get("snip")?,
131 });
132 }
133 Ok(out)
134 }
135
136 pub async fn count_for_agent(&self, agent_id: &str) -> anyhow::Result<u64> {
137 let row = sqlx::query("SELECT COUNT(*) AS n FROM transcripts_fts WHERE agent_id = ?")
138 .bind(agent_id)
139 .fetch_one(&self.pool)
140 .await
141 .context("counting transcripts_fts")?;
142 let n: i64 = row.try_get("n")?;
143 Ok(n.max(0) as u64)
144 }
145
146 pub async fn rebuild_for_agent(
150 &self,
151 agent_id: &str,
152 transcripts_root: &Path,
153 ) -> anyhow::Result<usize> {
154 let writer = TranscriptWriter::new(PathBuf::from(transcripts_root), agent_id.to_string());
159 let mut entries = match tokio::fs::read_dir(transcripts_root).await {
160 Ok(d) => d,
161 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
162 sqlx::query("DELETE FROM transcripts_fts WHERE agent_id = ?")
164 .bind(agent_id)
165 .execute(&self.pool)
166 .await
167 .context("wiping rows for agent")?;
168 return Ok(0);
169 }
170 Err(e) => {
171 return Err(anyhow::anyhow!(
172 "read transcripts_root {}: {e}",
173 transcripts_root.display()
174 ))
175 }
176 };
177
178 struct Row {
180 sid: Uuid,
181 ts: i64,
182 role: &'static str,
183 source_plugin: String,
184 content: String,
185 }
186 let mut rows: Vec<Row> = Vec::new();
187 while let Some(entry) = entries.next_entry().await? {
188 let p = entry.path();
189 if p.extension().and_then(|s| s.to_str()) != Some("jsonl") {
190 continue;
191 }
192 let stem = p
193 .file_stem()
194 .and_then(|s| s.to_str())
195 .unwrap_or_default()
196 .to_string();
197 let Ok(sid) = Uuid::parse_str(&stem) else {
198 continue;
199 };
200 let lines = writer.read_session(sid).await.unwrap_or_default();
201 for line in lines {
202 if let TranscriptLine::Entry(e) = line {
203 rows.push(Row {
204 sid,
205 ts: e.timestamp.timestamp(),
206 role: role_str(e.role),
207 source_plugin: e.source_plugin,
208 content: e.content,
209 });
210 }
211 }
212 }
213
214 let mut tx = self.pool.begin().await.context("begin tx")?;
215 sqlx::query("DELETE FROM transcripts_fts WHERE agent_id = ?")
216 .bind(agent_id)
217 .execute(&mut *tx)
218 .await
219 .context("wiping rows for agent")?;
220 let mut indexed = 0usize;
221 for row in &rows {
222 sqlx::query(
223 "INSERT INTO transcripts_fts (content, agent_id, session_id, timestamp_unix, role, source_plugin) \
224 VALUES (?, ?, ?, ?, ?, ?)",
225 )
226 .bind(&row.content)
227 .bind(agent_id)
228 .bind(row.sid.to_string())
229 .bind(row.ts)
230 .bind(row.role)
231 .bind(&row.source_plugin)
232 .execute(&mut *tx)
233 .await
234 .context("insert row in rebuild")?;
235 indexed += 1;
236 }
237 tx.commit().await.context("commit rebuild tx")?;
238 Ok(indexed)
239 }
240}
241
242fn role_str(role: super::transcripts::TranscriptRole) -> &'static str {
243 use super::transcripts::TranscriptRole as R;
244 match role {
245 R::User => "user",
246 R::Assistant => "assistant",
247 R::Tool => "tool",
248 R::System => "system",
249 }
250}
251
252fn escape_fts_phrase(q: &str) -> String {
257 let mut out = String::with_capacity(q.len() + 2);
258 out.push('"');
259 for ch in q.chars() {
260 if ch == '"' {
261 out.push('"');
262 out.push('"');
263 } else {
264 out.push(ch);
265 }
266 }
267 out.push('"');
268 out
269}
270
271#[cfg(test)]
272mod tests {
273 use super::super::transcripts::{TranscriptEntry, TranscriptRole, TranscriptWriter};
274 use super::*;
275 use chrono::Utc;
276 use tempfile::tempdir;
277
278 async fn fresh() -> TranscriptsIndex {
279 let dir = tempdir().unwrap().keep();
280 let path = dir.join("transcripts.db");
281 TranscriptsIndex::open(&path).await.unwrap()
282 }
283
284 #[tokio::test]
285 async fn open_is_idempotent() {
286 let dir = tempdir().unwrap().keep();
287 let path = dir.join("transcripts.db");
288 TranscriptsIndex::open(&path).await.unwrap();
289 TranscriptsIndex::open(&path).await.unwrap();
290 }
291
292 #[tokio::test]
293 async fn insert_then_search_round_trip() {
294 let idx = fresh().await;
295 let sid = Uuid::new_v4();
296 idx.insert("kate", sid, 1000, "user", "wa", "hola codigo de seguridad")
297 .await
298 .unwrap();
299 idx.insert("kate", sid, 1001, "assistant", "wa", "no comparto eso")
300 .await
301 .unwrap();
302 let hits = idx.search("kate", "codigo", 10).await.unwrap();
303 assert_eq!(hits.len(), 1);
304 assert_eq!(hits[0].session_id, sid);
305 assert!(hits[0].snippet.contains("[codigo]"));
306 }
307
308 #[tokio::test]
309 async fn search_filters_by_agent() {
310 let idx = fresh().await;
311 let s1 = Uuid::new_v4();
312 let s2 = Uuid::new_v4();
313 idx.insert("kate", s1, 1, "user", "wa", "shared word here")
314 .await
315 .unwrap();
316 idx.insert("ana", s2, 2, "user", "wa", "shared word here too")
317 .await
318 .unwrap();
319 let kate_hits = idx.search("kate", "shared", 10).await.unwrap();
320 assert_eq!(kate_hits.len(), 1);
321 assert_eq!(kate_hits[0].agent_id, "kate");
322 let ana_hits = idx.search("ana", "shared", 10).await.unwrap();
323 assert_eq!(ana_hits.len(), 1);
324 assert_eq!(ana_hits[0].agent_id, "ana");
325 }
326
327 #[tokio::test]
328 async fn count_for_agent_returns_inserted_count() {
329 let idx = fresh().await;
330 let sid = Uuid::new_v4();
331 for i in 0..3 {
332 idx.insert("kate", sid, 100 + i, "user", "wa", "row")
333 .await
334 .unwrap();
335 }
336 idx.insert("ana", sid, 200, "user", "wa", "row")
337 .await
338 .unwrap();
339 assert_eq!(idx.count_for_agent("kate").await.unwrap(), 3);
340 assert_eq!(idx.count_for_agent("ana").await.unwrap(), 1);
341 }
342
343 #[tokio::test]
344 async fn search_user_input_with_quotes_is_safe() {
345 let idx = fresh().await;
346 let sid = Uuid::new_v4();
347 idx.insert("kate", sid, 1, "user", "wa", "an OR b")
348 .await
349 .unwrap();
350 let hits = idx.search("kate", "OR", 10).await.unwrap();
353 assert!(hits.iter().all(|h| h.session_id == sid));
354 }
355
356 #[tokio::test]
357 async fn search_user_input_with_fts_operators_is_safe() {
358 let idx = fresh().await;
367 let sid = Uuid::new_v4();
368 idx.insert("kate", sid, 1, "user", "wa", "title: hello world")
369 .await
370 .unwrap();
371 idx.insert("ana", Uuid::new_v4(), 2, "user", "wa", "agent_id: leak")
372 .await
373 .unwrap();
374 for adversarial in [
375 "field:value",
376 "agent_id:ana",
377 "OR NOT AND NEAR",
378 "\"injection\"",
379 "(hello)",
380 "^prefix",
381 ] {
382 let hits = idx.search("kate", adversarial, 10).await.unwrap();
383 for h in &hits {
384 assert_eq!(
385 h.agent_id, "kate",
386 "input `{adversarial}` leaked rows from another agent"
387 );
388 }
389 }
390 }
391
392 #[tokio::test]
393 async fn rebuild_for_agent_indexes_jsonl() {
394 let dir = tempdir().unwrap().keep();
395 let writer = TranscriptWriter::new(&dir, "kate");
396 let sid = Uuid::new_v4();
397 for content in ["hola", "que tal", "todo bien"] {
398 writer
399 .append_entry(
400 sid,
401 TranscriptEntry {
402 timestamp: Utc::now(),
403 role: TranscriptRole::User,
404 content: content.to_string(),
405 message_id: None,
406 source_plugin: "wa".into(),
407 sender_id: None,
408 },
409 )
410 .await
411 .unwrap();
412 }
413 let idx_path = dir.join("idx.db");
414 let idx = TranscriptsIndex::open(&idx_path).await.unwrap();
415 let n = idx.rebuild_for_agent("kate", &dir).await.unwrap();
416 assert_eq!(n, 3);
417 assert_eq!(idx.count_for_agent("kate").await.unwrap(), 3);
418 let hits = idx.search("kate", "tal", 10).await.unwrap();
419 assert_eq!(hits.len(), 1);
420 }
421}