1use super::*;
2
3#[async_trait]
4impl SessionImporter for SqliteSessionStore {
5 async fn import(&self, request: ImportSession) -> StoreResult<ImportResult> {
6 request.validate()?;
7 let mut conn = self.lock();
8 let tx = write_transaction(&mut conn)?;
9 if let Some(existing) = read_import(&tx, &request.source_id)? {
10 if existing.source_digest != request.source_digest {
11 return Err(StoreError::Conflict(format!(
12 "import source '{}' changed digest",
13 request.source_id
14 )));
15 }
16 return Ok(existing);
17 }
18
19 let meta = crate::memory_helpers::meta_for_create(request.session);
20 if tx
21 .query_row(
22 "SELECT 1 FROM sessions WHERE id = ?1",
23 params![meta.id],
24 |_| Ok(()),
25 )
26 .optional()
27 .map_err(map_sql)?
28 .is_some()
29 {
30 return Err(StoreError::AlreadyExists(meta.id));
31 }
32 insert_session(&tx, &meta, 1)?;
33 let event_count = request.events.len();
34 for event in request.events {
35 append_in_tx(&tx, &self.hooks, &meta.id, event)?;
36 }
37 tx.execute(
38 "INSERT INTO session_imports (source_id, source_digest, session_id, event_count)
39 VALUES (?1, ?2, ?3, ?4)",
40 params![
41 request.source_id,
42 request.source_digest,
43 meta.id,
44 event_count as i64
45 ],
46 )
47 .map_err(map_sql)?;
48 tx.commit().map_err(map_sql)?;
49 Ok(ImportResult {
50 source_id: request.source_id,
51 source_digest: request.source_digest,
52 session_id: meta.id,
53 event_count,
54 imported: true,
55 })
56 }
57}
58
59#[async_trait]
60impl SessionStore for SqliteSessionStore {
61 fn hooks(&self) -> &StoreHooks {
62 &self.hooks
63 }
64
65 async fn create(&self, request: CreateSession) -> StoreResult<SessionMeta> {
66 let meta = crate::memory_helpers::meta_for_create(request);
67 let mut conn = self.lock();
68 let tx = write_transaction(&mut conn)?;
69 insert_session(&tx, &meta, 1)?;
70 tx.commit().map_err(map_sql)?;
71 Ok(meta)
72 }
73
74 async fn describe(&self, session_id: &str) -> StoreResult<SessionMeta> {
75 let conn = self.lock();
76 let (meta, _) = read_session_meta(&conn, session_id)?;
77 Ok(meta)
78 }
79
80 async fn update(&self, session_id: &str, request: UpdateSession) -> StoreResult<SessionMeta> {
81 let mut conn = self.lock();
82 let tx = write_transaction(&mut conn)?;
83 let (updated_at_ms, updated_at) = now_ms_and_rfc3339();
84 let (current, _) = read_session_meta(&tx, session_id)?;
89 let (title, title_pinned) = crate::memory_helpers::resolve_title_update(
90 current.title,
91 current.title_pinned,
92 request.title,
93 request.title_pinned,
94 );
95 let changed = tx
96 .execute(
97 "UPDATE sessions SET
98 title = ?1,
99 title_pinned = ?13,
100 cwd = COALESCE(?2, cwd),
101 model = COALESCE(?3, model),
102 parent_session_id = COALESCE(?4, parent_session_id),
103 session_type = COALESCE(?5, session_type),
104 project_scope = COALESCE(?6, project_scope),
105 usage_input = COALESCE(?7, usage_input),
106 usage_output = COALESCE(?8, usage_output),
107 usage_cost_usd_micros = COALESCE(?9, usage_cost_usd_micros),
108 updated_at_ms = ?10,
109 updated_at = ?11
110 WHERE id = ?12",
111 params![
112 title,
113 request.cwd,
114 request.model,
115 request.parent_session_id,
116 request.session_type.map(session_type_to_sql),
117 request.project_scope,
118 request.usage_input.map(|value| value as i64),
119 request.usage_output.map(|value| value as i64),
120 request.usage_cost_usd_micros.map(|value| value as i64),
121 updated_at_ms,
122 updated_at,
123 session_id,
124 title_pinned,
125 ],
126 )
127 .map_err(map_sql)?;
128 if changed == 0 {
129 return Err(StoreError::NotFound(session_id.to_string()));
130 }
131 let (meta, _) = read_session_meta(&tx, session_id)?;
132 let mut events = load_all_events(&tx, session_id)?;
133 redact_stored_events(&self.hooks, &mut events)?;
134 tx.execute(
135 "DELETE FROM session_events_fts WHERE session_id = ?1",
136 params![session_id],
137 )
138 .map_err(map_sql)?;
139 tx.execute(
140 "DELETE FROM session_event_vectors WHERE session_id = ?1",
141 params![session_id],
142 )
143 .map_err(map_sql)?;
144 for event in &events {
145 insert_search_rows(&tx, &self.hooks, &meta, event)?;
146 }
147 tx.commit().map_err(map_sql)?;
148 Ok(meta)
149 }
150
151 async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>> {
152 let conn = self.lock();
153 let limit = filter.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH) as i64;
154 let sort_column = match filter.sort_by {
155 ListSortKey::CreatedAt => "created_at_ms",
156 ListSortKey::UpdatedAt => "updated_at_ms",
157 };
158 let cursor_anchor: Option<(i64, String)> = filter
162 .cursor
163 .as_ref()
164 .map(|id| {
165 conn.query_row(
166 &format!("SELECT {sort_column}, id FROM sessions WHERE id = ?1"),
167 params![id],
168 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
169 )
170 .optional()
171 .map_err(map_sql)
172 })
173 .transpose()?
174 .flatten();
175
176 let mut sql = String::from("SELECT s.id FROM sessions s");
177 if filter.tag.is_some() {
178 sql.push_str(" INNER JOIN session_tags t ON t.session_id = s.id AND t.tag = :tag");
179 }
180 sql.push_str(" WHERE 1=1");
181 let mut args: Vec<(&'static str, rusqlite::types::Value)> = Vec::new();
182 if let Some(tag) = filter.tag {
183 args.push((":tag", tag.into()));
184 }
185 if let Some(tenant) = filter.tenant_id {
186 sql.push_str(" AND s.tenant_id = :tenant");
187 args.push((":tenant", tenant.into()));
188 }
189 if let Some(persona) = filter.persona {
190 sql.push_str(" AND s.persona = :persona");
191 args.push((":persona", persona.into()));
192 }
193 if let Some(status) = filter.status {
194 sql.push_str(" AND s.status = :status");
195 args.push((":status", status_to_sql(status).to_string().into()));
196 }
197 if let Some(parent_session_id) = filter.parent_session_id {
198 sql.push_str(" AND s.parent_session_id = :parent_session_id");
199 args.push((":parent_session_id", parent_session_id.into()));
200 }
201 if let Some(session_type) = filter.session_type {
202 sql.push_str(" AND s.session_type = :session_type");
203 args.push((
204 ":session_type",
205 session_type_to_sql(session_type).to_string().into(),
206 ));
207 }
208 if let Some(project_scope) = filter.project_scope {
209 sql.push_str(" AND s.project_scope = :project_scope");
210 args.push((":project_scope", project_scope.into()));
211 }
212 if let Some(after) = filter.created_after_ms {
213 sql.push_str(" AND s.created_at_ms >= :after");
214 args.push((":after", after.into()));
215 }
216 if let Some(before) = filter.created_before_ms {
217 sql.push_str(" AND s.created_at_ms <= :before");
218 args.push((":before", before.into()));
219 }
220 if let Some((anchor_ms, anchor_id)) = cursor_anchor {
221 let comparison = match filter.order {
222 ListOrder::Ascending => ">",
223 ListOrder::Descending => "<",
224 };
225 sql.push_str(&format!(
226 " AND (s.{sort_column} {comparison} :cursor_ms OR (s.{sort_column} = :cursor_ms AND s.id > :cursor_id))"
227 ));
228 args.push((":cursor_ms", anchor_ms.into()));
229 args.push((":cursor_id", anchor_id.into()));
230 }
231 let direction = match filter.order {
232 ListOrder::Ascending => "ASC",
233 ListOrder::Descending => "DESC",
234 };
235 sql.push_str(&format!(
236 " ORDER BY s.{sort_column} {direction}, s.id ASC LIMIT :limit"
237 ));
238 args.push((":limit", limit.into()));
239
240 let named_args: Vec<(&str, &dyn rusqlite::ToSql)> = args
241 .iter()
242 .map(|(name, value)| (*name, value as &dyn rusqlite::ToSql))
243 .collect();
244 let mut stmt = conn.prepare(&sql).map_err(map_sql)?;
245 let ids: Vec<String> = stmt
246 .query_map(named_args.as_slice(), |row| row.get(0))
247 .map_err(map_sql)?
248 .collect::<Result<_, _>>()
249 .map_err(map_sql)?;
250 let mut metas = Vec::with_capacity(ids.len());
251 for id in ids {
252 let (meta, _) = read_session_meta(&conn, &id)?;
253 metas.push(meta);
254 }
255 Ok(metas)
256 }
257
258 async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent> {
259 let mut conn = self.lock();
260 let tx = write_transaction(&mut conn)?;
261 let stored = append_in_tx(&tx, &self.hooks, session_id, event)?;
262 tx.commit().map_err(map_sql)?;
263 Ok(stored)
264 }
265
266 async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage> {
267 let conn = self.lock();
268 let from = range.from_event_id.unwrap_or(1) as i64;
269 let to = range
273 .to_event_id
274 .map(|value| value as i64)
275 .unwrap_or(i64::MAX);
276 let limit = range.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH) as i64;
277 let mut stmt = conn
278 .prepare(
279 "SELECT session_id, event_id, tenant_id, parent_event_id, actor, kind,
280 custom_kind, payload_json, tags_json, headers_json, ts_ms, ts,
281 record_hash, prev_hash, signature_json
282 FROM session_events
283 WHERE session_id = ?1 AND event_id >= ?2 AND event_id <= ?3
284 ORDER BY event_id ASC LIMIT ?4",
285 )
286 .map_err(map_sql)?;
287 let rows = stmt
288 .query_map(params![session_id, from, to, limit], read_event)
289 .map_err(map_sql)?;
290 let mut events = Vec::new();
291 for row in rows {
292 events.push(row.map_err(map_sql)?);
293 }
294 redact_stored_events(&self.hooks, &mut events)?;
295 let next_cursor = if events.len() as i64 == limit {
296 events.last().map(|tail| tail.event_id + 1)
297 } else {
298 None
299 };
300 Ok(EventPage {
301 events,
302 next_cursor,
303 })
304 }
305
306 async fn fork(
307 &self,
308 session_id: &str,
309 at_event_id: EventId,
310 child_id: Option<SessionId>,
311 ) -> StoreResult<ForkResult> {
312 let mut conn = self.lock();
313 let tx = write_transaction(&mut conn)?;
314 let (parent_meta, _) = read_session_meta(&tx, session_id)?;
315 let parent_events = load_all_events(&tx, session_id)?;
316 if !parent_events
317 .iter()
318 .any(|event| event.event_id == at_event_id)
319 {
320 return Err(StoreError::InvalidInput(format!(
321 "event {at_event_id} not found in session '{session_id}'"
322 )));
323 }
324 let new_id = child_id.unwrap_or_else(|| Uuid::now_v7().to_string());
325 let exists: bool = tx
326 .query_row(
327 "SELECT 1 FROM sessions WHERE id = ?1",
328 params![new_id],
329 |_| Ok(true),
330 )
331 .optional()
332 .map_err(map_sql)?
333 .unwrap_or(false);
334 if exists {
335 return Err(StoreError::AlreadyExists(new_id));
336 }
337 let (ms, text) = now_ms_and_rfc3339();
338 let mut child_meta = parent_meta.clone();
339 child_meta.id = new_id.clone();
340 child_meta.parent_session_id = Some(parent_meta.id);
341 child_meta.created_at_ms = ms;
342 child_meta.created_at = text.clone();
343 child_meta.updated_at_ms = ms;
344 child_meta.updated_at = text;
345 child_meta.status = SessionStatus::Open;
346 child_meta.closed_at_ms = None;
347 child_meta.closed_at = None;
348 child_meta.soft_deleted_at_ms = None;
349 let mut inherited: Vec<StoredEvent> = parent_events
350 .into_iter()
351 .filter(|event| event.event_id <= at_event_id)
352 .collect();
353 prepare_stored_events_for_persistence(&self.hooks, &mut inherited)?;
354 let copied = re_anchor_events(&inherited, &new_id);
355 child_meta.event_count = copied.len();
356 child_meta.last_event_id = copied.last().map(|tail| tail.event_id);
357 child_meta.chain_root_hash = Some(chain_root_hash(&copied));
358 let next_event_id = copied.last().map(|tail| tail.event_id + 1).unwrap_or(1);
359 insert_session(&tx, &child_meta, next_event_id)?;
360 for event in &copied {
361 insert_event(&tx, event)?;
362 insert_search_rows(&tx, &self.hooks, &child_meta, event)?;
363 }
364 tx.commit().map_err(map_sql)?;
365 Ok(ForkResult {
366 child_session_id: new_id,
367 forked_from_event_id: at_event_id,
368 copied_event_count: copied.len(),
369 })
370 }
371
372 async fn truncate(
373 &self,
374 session_id: &str,
375 at_event_id: EventId,
376 ) -> StoreResult<TruncateResult> {
377 let mut conn = self.lock();
378 let tx = write_transaction(&mut conn)?;
379 let (mut meta, _) = read_session_meta(&tx, session_id)?;
380 let exists: bool = tx
381 .query_row(
382 "SELECT 1 FROM session_events WHERE session_id = ?1 AND event_id = ?2",
383 params![session_id, at_event_id as i64],
384 |_| Ok(true),
385 )
386 .optional()
387 .map_err(map_sql)?
388 .unwrap_or(false);
389 if !exists {
390 return Err(StoreError::InvalidInput(format!(
391 "event {at_event_id} not found in session '{session_id}'"
392 )));
393 }
394 let removed: i64 = tx
395 .query_row(
396 "SELECT COUNT(*) FROM session_events
397 WHERE session_id = ?1 AND event_id > ?2",
398 params![session_id, at_event_id as i64],
399 |row| row.get(0),
400 )
401 .map_err(map_sql)?;
402 tx.execute(
403 "DELETE FROM session_events WHERE session_id = ?1 AND event_id > ?2",
404 params![session_id, at_event_id as i64],
405 )
406 .map_err(map_sql)?;
407 tx.execute(
408 "DELETE FROM session_events_fts
409 WHERE session_id = ?1 AND CAST(event_id AS INTEGER) > ?2",
410 params![session_id, at_event_id as i64],
411 )
412 .map_err(map_sql)?;
413 tx.execute(
414 "DELETE FROM session_event_vectors
415 WHERE session_id = ?1 AND event_id > ?2",
416 params![session_id, at_event_id as i64],
417 )
418 .map_err(map_sql)?;
419 let remaining_hashes: Vec<String> = {
420 let mut stmt = tx
421 .prepare(
422 "SELECT record_hash FROM session_events
423 WHERE session_id = ?1 ORDER BY event_id ASC",
424 )
425 .map_err(map_sql)?;
426 let rows = stmt
427 .query_map(params![session_id], |row| row.get::<_, String>(0))
428 .map_err(map_sql)?;
429 let mut out = Vec::new();
430 for row in rows {
431 out.push(row.map_err(map_sql)?);
432 }
433 out
434 };
435 let new_root = remaining_hashes
436 .iter()
437 .fold(chain_root_init(), |root, hash| chain_root_fold(&root, hash));
438 let (ms, text) = now_ms_and_rfc3339();
439 meta.event_count = remaining_hashes.len();
440 meta.last_event_id = Some(at_event_id);
441 meta.chain_root_hash = Some(new_root);
442 meta.updated_at_ms = ms;
443 meta.updated_at = text;
444 tx.execute(
445 "UPDATE sessions SET event_count = ?1, last_event_id = ?2,
446 chain_root_hash = ?3, updated_at_ms = ?4,
447 updated_at = ?5, next_event_id = ?6 WHERE id = ?7",
448 params![
449 meta.event_count as i64,
450 meta.last_event_id.map(|value| value as i64),
451 meta.chain_root_hash,
452 meta.updated_at_ms,
453 meta.updated_at,
454 (at_event_id + 1) as i64,
455 session_id,
456 ],
457 )
458 .map_err(map_sql)?;
459 tx.commit().map_err(map_sql)?;
460 Ok(TruncateResult {
461 kept_event_count: meta.event_count,
462 removed_event_count: removed as usize,
463 new_tip_event_id: meta.last_event_id,
464 })
465 }
466
467 async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot> {
468 let conn = self.lock();
469 let (meta, _) = read_session_meta(&conn, session_id)?;
470 let mut events = load_all_events(&conn, session_id)?;
471 redact_stored_events(&self.hooks, &mut events)?;
472 let (ms, text) = now_ms_and_rfc3339();
473 let snapshot = Snapshot {
474 id: SnapshotId(format!("snap-{}", Uuid::now_v7())),
475 session: meta,
476 events,
477 captured_at_ms: ms,
478 captured_at: text,
479 };
480 let body = serde_json::to_string(&snapshot)
481 .map_err(|error| StoreError::Backend(error.to_string()))?;
482 conn.execute(
483 "INSERT INTO session_snapshots (id, session_id, captured_at_ms, captured_at, body_json)
484 VALUES (?1, ?2, ?3, ?4, ?5)",
485 params![
486 snapshot.id.0,
487 snapshot.session.id,
488 snapshot.captured_at_ms,
489 snapshot.captured_at,
490 body,
491 ],
492 )
493 .map_err(map_sql)?;
494 Ok(snapshot)
495 }
496
497 async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot> {
498 let conn = self.lock();
499 let body: Option<String> = conn
500 .query_row(
501 "SELECT body_json FROM session_snapshots WHERE id = ?1",
502 params![snapshot_id.0],
503 |row| row.get(0),
504 )
505 .optional()
506 .map_err(map_sql)?;
507 let body = body.ok_or_else(|| StoreError::NotFound(snapshot_id.0.clone()))?;
508 let mut snapshot: Snapshot =
509 serde_json::from_str(&body).map_err(|error| StoreError::Backend(error.to_string()))?;
510 redact_stored_events(&self.hooks, &mut snapshot.events)?;
511 Ok(snapshot)
512 }
513
514 async fn close(&self, session_id: &str) -> StoreResult<StoredEvent> {
515 let mut conn = self.lock();
516 let tx = write_transaction(&mut conn)?;
517 let (meta, _) = read_session_meta(&tx, session_id)?;
521 crate::memory_helpers::validate_open(&meta)?;
522 let record_root = match meta.chain_root_hash.clone() {
523 Some(root) => root,
524 None => chain_root_hash(&load_all_events(&tx, session_id)?),
525 };
526 let last_event_id = meta.last_event_id.unwrap_or(0);
527 let payload =
528 crate::signing::canonical_receipt_payload(session_id, last_event_id, &record_root);
529 let mut append = AppendEvent::new(SessionEventKind::Receipt, payload);
530 append.actor = Some("session_store".into());
531 let mut stored = append_in_tx(&tx, &self.hooks, session_id, append)?;
532 if let Some(signer) = self
538 .hooks
539 .receipt_signer
540 .as_ref()
541 .or(self.hooks.event_signer.as_ref())
542 {
543 let signature = signer.sign_receipt(&record_root);
544 let signature_json =
545 serde_json::to_string(&signature).unwrap_or_else(|_| "null".into());
546 tx.execute(
547 "UPDATE session_events SET signature_json = ?1
548 WHERE session_id = ?2 AND event_id = ?3",
549 params![signature_json, session_id, stored.event_id as i64],
550 )
551 .map_err(map_sql)?;
552 stored.signed_by = Some(signature);
553 }
554 let (ms, text) = now_ms_and_rfc3339();
555 tx.execute(
556 "UPDATE sessions SET status = ?1, closed_at_ms = ?2, closed_at = ?3,
557 updated_at_ms = ?2, updated_at = ?3 WHERE id = ?4",
558 params![status_to_sql(SessionStatus::Closed), ms, text, session_id,],
559 )
560 .map_err(map_sql)?;
561 tx.commit().map_err(map_sql)?;
562 Ok(stored)
563 }
564
565 async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta> {
566 let conn = self.lock();
567 let (mut meta, _) = read_session_meta(&conn, session_id)?;
568 match meta.status {
569 SessionStatus::HardDeleted => return Err(StoreError::NotFound(session_id.to_string())),
570 SessionStatus::SoftDeleted => return Ok(meta),
571 _ => {}
572 }
573 let (ms, text) = now_ms_and_rfc3339();
574 conn.execute(
575 "UPDATE sessions SET status = ?1, soft_deleted_at_ms = ?2,
576 updated_at_ms = ?2, updated_at = ?3 WHERE id = ?4",
577 params![
578 status_to_sql(SessionStatus::SoftDeleted),
579 ms,
580 text,
581 session_id,
582 ],
583 )
584 .map_err(map_sql)?;
585 meta.status = SessionStatus::SoftDeleted;
586 meta.soft_deleted_at_ms = Some(ms);
587 meta.updated_at_ms = ms;
588 meta.updated_at = text;
589 Ok(meta)
590 }
591
592 async fn hard_delete(&self, session_id: &str) -> StoreResult<()> {
593 let mut conn = self.lock();
594 let tx = write_transaction(&mut conn)?;
595 tx.execute(
596 "DELETE FROM session_events_fts WHERE session_id = ?1",
597 params![session_id],
598 )
599 .map_err(map_sql)?;
600 let removed = tx
601 .execute("DELETE FROM sessions WHERE id = ?1", params![session_id])
602 .map_err(map_sql)?;
603 if removed == 0 {
604 return Err(StoreError::NotFound(session_id.to_string()));
605 }
606 tx.commit().map_err(map_sql)?;
607 Ok(())
608 }
609
610 async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport> {
611 let conn = self.lock();
612 let (meta, _) = read_session_meta(&conn, session_id)?;
613 let events = load_all_events(&conn, session_id)?;
614 let event_verifier = self
615 .hooks
616 .event_signer
617 .as_ref()
618 .map(|signer| signer.verifying_key());
619 let receipt_verifier = self
620 .hooks
621 .receipt_signer
622 .as_ref()
623 .or(self.hooks.event_signer.as_ref())
624 .map(|signer| signer.verifying_key());
625 Ok(verify_session_chain(
626 &meta,
627 &events,
628 event_verifier.as_ref(),
629 receipt_verifier.as_ref(),
630 ))
631 }
632
633 async fn search(&self, query: SearchQuery) -> StoreResult<SearchResponse> {
634 query.validate().map_err(StoreError::InvalidInput)?;
635 let conn = self.lock();
636 let embedder = self.hooks.embedder.clone();
637 let semantic_available = embedder.is_semantic();
638 let effective_mode = if semantic_available {
639 query.mode
640 } else {
641 SearchMode::Fts
642 };
643
644 let literal_query = fts_literal_query(&query.query);
645 if effective_mode == SearchMode::Fts && literal_query.is_empty() {
646 let semantic_floor = !semantic_available;
647 return Ok(SearchResponse {
648 requested_mode: query.mode,
649 effective_mode,
650 embedding_backend: embedder.name().to_string(),
651 semantic_floor,
652 fallback_reason: (semantic_floor && query.mode != SearchMode::Fts)
653 .then(|| "semantic model unavailable; FTS-only fallback active".into()),
654 hits: Vec::new(),
655 });
656 }
657 let mut fts_scores = BTreeMap::new();
658 if effective_mode == SearchMode::Hybrid && !literal_query.is_empty() {
659 let mut sql = String::from(
660 "SELECT f.session_id, CAST(f.event_id AS INTEGER),
661 -bm25(session_events_fts)
662 FROM session_events_fts f
663 INNER JOIN sessions s ON s.id = f.session_id
664 WHERE session_events_fts MATCH :match
665 AND s.status NOT IN ('soft_deleted', 'hard_deleted')",
666 );
667 let mut args: Vec<(&'static str, rusqlite::types::Value)> =
668 vec![(":match", literal_query.clone().into())];
669 append_search_scope(&mut sql, &mut args, &query);
670 let named_args: Vec<(&str, &dyn rusqlite::ToSql)> = args
671 .iter()
672 .map(|(name, value)| (*name, value as &dyn rusqlite::ToSql))
673 .collect();
674 let mut stmt = conn.prepare(&sql).map_err(map_sql)?;
675 let rows = stmt
676 .query_map(named_args.as_slice(), |row| {
677 Ok((
678 row.get::<_, String>(0)?,
679 row.get::<_, i64>(1)? as EventId,
680 row.get::<_, f64>(2)? as f32,
681 ))
682 })
683 .map_err(map_sql)?;
684 for row in rows {
685 let (session_id, event_id, score) = row.map_err(map_sql)?;
686 fts_scores.insert((session_id, event_id), score.max(f32::MIN_POSITIVE));
687 }
688 }
689
690 let fts_only = effective_mode == SearchMode::Fts;
691 let mut sql = if fts_only {
692 String::from(
693 "SELECT e.session_id, e.event_id, e.tenant_id, e.parent_event_id,
694 e.actor, e.kind, e.custom_kind, e.payload_json, e.tags_json,
695 e.headers_json, e.ts_ms, e.ts, e.record_hash, e.prev_hash,
696 e.signature_json, s.title, s.cwd, s.model, s.project_scope,
697 NULL, NULL, NULL, -bm25(session_events_fts)
698 FROM session_events_fts
699 INNER JOIN session_events e
700 ON e.session_id = session_events_fts.session_id
701 AND e.event_id = CAST(session_events_fts.event_id AS INTEGER)
702 INNER JOIN sessions s ON s.id = e.session_id
703 WHERE session_events_fts MATCH :candidate_match
704 AND s.status NOT IN ('soft_deleted', 'hard_deleted')",
705 )
706 } else {
707 String::from(
708 "SELECT e.session_id, e.event_id, e.tenant_id, e.parent_event_id,
709 e.actor, e.kind, e.custom_kind, e.payload_json, e.tags_json,
710 e.headers_json, e.ts_ms, e.ts, e.record_hash, e.prev_hash,
711 e.signature_json, s.title, s.cwd, s.model, s.project_scope,
712 v.backend, v.dim, v.embedding, NULL
713 FROM session_events e
714 INNER JOIN sessions s ON s.id = e.session_id
715 LEFT JOIN session_event_vectors v
716 ON v.session_id = e.session_id AND v.event_id = e.event_id
717 WHERE s.status NOT IN ('soft_deleted', 'hard_deleted')",
718 )
719 };
720 let mut args: Vec<(&'static str, rusqlite::types::Value)> = if fts_only {
721 vec![(":candidate_match", literal_query.into())]
722 } else {
723 Vec::new()
724 };
725 append_search_scope(&mut sql, &mut args, &query);
726 if fts_only {
727 sql.push_str(
728 " ORDER BY bm25(session_events_fts) ASC,
729 e.session_id ASC, e.event_id ASC
730 LIMIT :candidate_limit",
731 );
732 args.push((
733 ":candidate_limit",
734 i64::try_from(query.limit()).unwrap_or(i64::MAX).into(),
735 ));
736 } else {
737 sql.push_str(" ORDER BY e.session_id ASC, e.event_id ASC");
738 }
739 let named_args: Vec<(&str, &dyn rusqlite::ToSql)> = args
740 .iter()
741 .map(|(name, value)| (*name, value as &dyn rusqlite::ToSql))
742 .collect();
743 let mut stmt = conn.prepare(&sql).map_err(map_sql)?;
744 let rows = stmt
745 .query_map(named_args.as_slice(), |row| {
746 Ok((
747 read_event(row)?,
748 row.get::<_, Option<String>>(15)?,
749 row.get::<_, Option<String>>(16)?,
750 row.get::<_, Option<String>>(17)?,
751 row.get::<_, Option<String>>(18)?,
752 row.get::<_, Option<String>>(19)?,
753 row.get::<_, Option<i64>>(20)?,
754 row.get::<_, Option<Vec<u8>>>(21)?,
755 row.get::<_, Option<f64>>(22)?.map(|score| score as f32),
756 ))
757 })
758 .map_err(map_sql)?;
759 let mut candidates = Vec::new();
760 for row in rows {
761 candidates.push(row.map_err(map_sql)?);
762 }
763 drop(stmt);
764 drop(conn);
765
766 let mut redacted = candidates
767 .iter()
768 .map(|(event, ..)| event.clone())
769 .collect::<Vec<_>>();
770 redact_stored_events(&self.hooks, &mut redacted)?;
771 for ((event, ..), redacted_event) in candidates.iter_mut().zip(redacted) {
772 *event = redacted_event;
773 }
774 let documents = candidates
775 .iter()
776 .map(|(event, title, cwd, model, project_scope, ..)| {
777 redacted_search_document_parts(
778 self.hooks.redaction.as_ref(),
779 title.as_deref(),
780 cwd.as_deref(),
781 model.as_deref(),
782 project_scope.as_deref(),
783 event,
784 )
785 })
786 .collect::<Vec<_>>();
787 let aligned_fts_scores = candidates
788 .iter()
789 .map(|(event, _, _, _, _, _, _, _, direct_fts_score)| {
790 direct_fts_score
791 .map(|score| score.max(f32::MIN_POSITIVE))
792 .unwrap_or_else(|| {
793 fts_scores
794 .get(&(event.session_id.clone(), event.event_id))
795 .copied()
796 .unwrap_or_default()
797 })
798 })
799 .collect::<Vec<_>>();
800 let semantic_scores = if semantic_available {
801 let query_vector = embedder.embed(&query.query);
802 candidates
803 .iter()
804 .enumerate()
805 .map(|(index, (_, _, _, _, _, backend, dim, blob, _))| {
806 let stored = backend
807 .as_deref()
808 .filter(|backend| *backend == embedder.name())
809 .zip(dim.and_then(|dim| usize::try_from(dim).ok()))
810 .filter(|(_, dim)| *dim == embedder.dim())
811 .zip(blob.as_deref())
812 .and_then(|((_, dim), blob)| vector_from_blob(blob, dim));
813 let vector =
814 stored.unwrap_or_else(|| embedder.embed(documents[index].as_str()));
815 crate::search::cosine(&query_vector, &vector).max(0.0)
816 })
817 .collect::<Vec<_>>()
818 } else {
819 vec![0.0; candidates.len()]
820 };
821
822 let fts_ranks = ranks(&aligned_fts_scores);
823 let semantic_ranks = ranks(&semantic_scores);
824 let mut hits = candidates
825 .into_iter()
826 .enumerate()
827 .filter_map(|(index, (event, ..))| {
828 let fts_rank = fts_ranks.get(&index).copied();
829 let semantic_rank = semantic_ranks.get(&index).copied();
830 let fts_score =
831 (aligned_fts_scores[index] > 0.0).then_some(aligned_fts_scores[index]);
832 let semantic_score =
833 (semantic_scores[index] > 0.0).then_some(semantic_scores[index]);
834 let included = match effective_mode {
835 SearchMode::Fts => fts_rank.is_some(),
836 SearchMode::Semantic => semantic_rank.is_some(),
837 SearchMode::Hybrid => fts_rank.is_some() || semantic_rank.is_some(),
838 };
839 included.then(|| SearchHit {
840 session_id: event.session_id.clone(),
841 event_id: event.event_id,
842 kind: event.kind.clone(),
843 score: combined_score(
844 effective_mode,
845 fts_rank,
846 semantic_rank,
847 fts_score,
848 semantic_score,
849 ),
850 fts_score,
851 semantic_score,
852 snippet: snippet(&documents[index], &query.query, 240),
853 event,
854 })
855 })
856 .collect::<Vec<_>>();
857 hits.sort_by(|left, right| {
858 right
859 .score
860 .total_cmp(&left.score)
861 .then_with(|| left.session_id.cmp(&right.session_id))
862 .then_with(|| left.event_id.cmp(&right.event_id))
863 });
864 hits.truncate(query.limit());
865 let semantic_floor = !semantic_available;
866 Ok(SearchResponse {
867 requested_mode: query.mode,
868 effective_mode,
869 embedding_backend: embedder.name().to_string(),
870 semantic_floor,
871 fallback_reason: (semantic_floor && query.mode != SearchMode::Fts)
872 .then(|| "semantic model unavailable; FTS-only fallback active".into()),
873 hits,
874 })
875 }
876}
877
878fn append_search_scope(
879 sql: &mut String,
880 args: &mut Vec<(&'static str, rusqlite::types::Value)>,
881 query: &SearchQuery,
882) {
883 if let Some(tenant_id) = query.filter.tenant_id.as_ref() {
884 sql.push_str(" AND s.tenant_id = :search_tenant");
885 args.push((":search_tenant", tenant_id.clone().into()));
886 }
887 if let Some(project_scope) = query.filter.project_scope.as_ref() {
888 sql.push_str(" AND s.project_scope = :search_project");
889 args.push((":search_project", project_scope.clone().into()));
890 }
891 if let Some(session_id) = query.filter.session_id.as_ref() {
892 sql.push_str(" AND s.id = :search_session");
893 args.push((":search_session", session_id.clone().into()));
894 }
895}