1use anyhow::{Context, Result};
2use rusqlite::{params, Connection, OptionalExtension};
3
4pub const ROLE_USER: &str = "user";
5pub const ROLE_ASSISTANT: &str = "assistant";
6
7pub const SOURCE_TRANSCRIPT: &str = "transcript";
8pub const SOURCE_HOOK: &str = "hook";
9pub const SOURCE_MANUAL: &str = "manual";
10
11pub const SOURCE_ROOT_LOCAL: &str = "local";
12
13#[derive(Debug, Clone)]
14pub struct RawMessage {
15 pub id: i64,
16 pub session_id: String,
17 pub project: String,
18 pub role: String,
19 pub content: String,
20 pub source: String,
21 pub branch: Option<String>,
22 pub cwd: Option<String>,
23 pub created_at_epoch: i64,
24}
25
26fn exact_content_hash(content: &str) -> String {
30 crate::db::content_identity_hash(content.as_bytes())
31}
32
33fn legacy_exact_content_hash(content: &str) -> String {
34 crate::db::legacy_content_identity_hash(content.as_bytes())
35}
36
37#[derive(Debug, Clone, Copy)]
38pub struct RawInsertOutcome {
39 pub id: i64,
40 pub inserted: bool,
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq)]
44pub struct RawIngestReport {
45 pub inserted: usize,
46 pub duplicates: usize,
47 pub empty_messages: usize,
48 pub skipped_messages: usize,
49 pub parse_errors: usize,
50 pub insert_errors: usize,
51 pub identity_conflicts: usize,
52 pub read_error: Option<String>,
53 pub partial_tail: bool,
57}
58
59impl RawIngestReport {
60 pub fn has_failures(&self) -> bool {
61 self.read_error.is_some() || self.parse_errors > 0 || self.insert_errors > 0
62 }
63
64 pub fn failure_kind(&self) -> Option<&'static str> {
65 match (
66 self.read_error.is_some(),
67 self.parse_errors > 0,
68 self.insert_errors > 0,
69 ) {
70 (true, false, false) => Some("read_error"),
71 (false, true, false) => Some("parse_errors"),
72 (false, false, true) => Some("insert_errors"),
73 (true, _, _) | (_, true, true) => Some("mixed_errors"),
74 (false, false, false) => None,
75 }
76 }
77
78 fn failure_message(&self) -> String {
79 if let Some(error) = &self.read_error {
80 return error.clone();
81 }
82 format!(
83 "parse_errors={} insert_errors={}",
84 self.parse_errors, self.insert_errors
85 )
86 }
87}
88
89pub fn insert_raw_message(
90 conn: &Connection,
91 session_id: &str,
92 project: &str,
93 role: &str,
94 content: &str,
95 source: &str,
96 branch: Option<&str>,
97 cwd: Option<&str>,
98) -> Result<Option<RawInsertOutcome>> {
99 insert_raw_message_from_root(
100 conn,
101 session_id,
102 project,
103 role,
104 content,
105 source,
106 branch,
107 cwd,
108 SOURCE_ROOT_LOCAL,
109 )
110}
111
112#[allow(clippy::too_many_arguments)]
113pub fn insert_raw_message_from_root(
114 conn: &Connection,
115 session_id: &str,
116 project: &str,
117 role: &str,
118 content: &str,
119 source: &str,
120 branch: Option<&str>,
121 cwd: Option<&str>,
122 source_root: &str,
123) -> Result<Option<RawInsertOutcome>> {
124 insert_raw_message_from_root_at(
125 conn,
126 session_id,
127 project,
128 role,
129 content,
130 source,
131 branch,
132 cwd,
133 source_root,
134 None,
135 )
136}
137
138#[allow(clippy::too_many_arguments)]
139pub fn insert_raw_message_from_root_at(
140 conn: &Connection,
141 session_id: &str,
142 project: &str,
143 role: &str,
144 content: &str,
145 source: &str,
146 branch: Option<&str>,
147 cwd: Option<&str>,
148 source_root: &str,
149 created_at_epoch: Option<i64>,
150) -> Result<Option<RawInsertOutcome>> {
151 let trimmed = content.trim();
152 if trimmed.is_empty() {
153 return Ok(None);
154 }
155 let hash = exact_content_hash(trimmed);
156 if let Some(id) =
157 find_matching_legacy_raw_message(conn, session_id, project, role, trimmed, source_root)?
158 {
159 return Ok(Some(RawInsertOutcome {
160 id,
161 inserted: false,
162 }));
163 }
164 let inserted_at = created_at_epoch.unwrap_or_else(|| chrono::Utc::now().timestamp());
165
166 let event_time_source = if source == SOURCE_TRANSCRIPT {
167 if created_at_epoch.is_some() {
168 "transcript_event"
169 } else {
170 "ingest_fallback"
171 }
172 } else {
173 "legacy_unknown"
174 };
175 let inserted = conn.execute(
176 "INSERT OR IGNORE INTO raw_messages \
177 (session_id, project, role, content, content_hash, source, branch, cwd, \
178 created_at_epoch, source_root, event_time_source) \
179 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
180 params![
181 session_id,
182 project,
183 role,
184 trimmed,
185 hash,
186 source,
187 branch,
188 cwd,
189 inserted_at,
190 source_root,
191 event_time_source
192 ],
193 )?;
194
195 if inserted > 0 {
196 Ok(Some(RawInsertOutcome {
197 id: conn.last_insert_rowid(),
198 inserted: true,
199 }))
200 } else {
201 let existing: i64 = conn.query_row(
202 "SELECT id FROM raw_messages \
203 WHERE source_root = ?1 AND project = ?2 AND session_id = ?3 \
204 AND role = ?4 AND content_hash = ?5
205 AND transcript_identity_id IS NULL",
206 params![source_root, project, session_id, role, hash],
207 |row| row.get(0),
208 )?;
209 Ok(Some(RawInsertOutcome {
210 id: existing,
211 inserted: false,
212 }))
213 }
214}
215
216fn find_matching_legacy_raw_message(
217 conn: &Connection,
218 session_id: &str,
219 project: &str,
220 role: &str,
221 content: &str,
222 source_root: &str,
223) -> Result<Option<i64>> {
224 let legacy_hash = legacy_exact_content_hash(content);
225 let Some((id, stored_content)) = conn
226 .query_row(
227 "SELECT id, content FROM raw_messages
228 WHERE source_root = ?1 AND project = ?2 AND session_id = ?3 \
229 AND role = ?4 AND content_hash = ?5
230 AND transcript_identity_id IS NULL",
231 params![source_root, project, session_id, role, legacy_hash],
232 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
233 )
234 .optional()?
235 else {
236 return Ok(None);
237 };
238
239 if stored_content == content {
240 Ok(Some(id))
241 } else {
242 Ok(None)
243 }
244}
245
246#[derive(Debug, Clone)]
248pub struct TranscriptDrainOptions<'a> {
249 pub source_root: &'a str,
252 pub tolerate_partial_tail: bool,
256 pub transcript_identity_id: Option<i64>,
259}
260
261impl Default for TranscriptDrainOptions<'_> {
262 fn default() -> Self {
263 Self {
264 source_root: SOURCE_ROOT_LOCAL,
265 tolerate_partial_tail: false,
266 transcript_identity_id: None,
267 }
268 }
269}
270
271pub fn drain_transcript(
273 conn: &Connection,
274 transcript_path: &str,
275 session_id: &str,
276 project: &str,
277 branch: Option<&str>,
278 cwd: Option<&str>,
279) -> Result<RawIngestReport> {
280 drain_transcript_with_options(
281 conn,
282 transcript_path,
283 session_id,
284 project,
285 branch,
286 cwd,
287 &TranscriptDrainOptions::default(),
288 )
289}
290
291pub fn raw_ingest_status(report: &RawIngestReport) -> &'static str {
292 if report.read_error.is_some() {
293 "read_failed"
294 } else if report.parse_errors > 0 || report.insert_errors > 0 {
295 "partial"
296 } else if report.inserted == 0 && report.duplicates > 0 {
297 "duplicate_only"
298 } else {
299 "ok"
300 }
301}
302
303#[allow(clippy::too_many_arguments)]
305pub fn drain_transcript_with_options(
306 conn: &Connection,
307 transcript_path: &str,
308 session_id: &str,
309 project: &str,
310 branch: Option<&str>,
311 cwd: Option<&str>,
312 options: &TranscriptDrainOptions<'_>,
313) -> Result<RawIngestReport> {
314 drain_transcript_with_capture_limit(
315 conn,
316 transcript_path,
317 session_id,
318 project,
319 branch,
320 cwd,
321 options,
322 None,
323 )
324}
325
326#[allow(clippy::too_many_arguments)]
327pub(crate) fn drain_transcript_with_capture_limit(
328 conn: &Connection,
329 transcript_path: &str,
330 session_id: &str,
331 project: &str,
332 branch: Option<&str>,
333 cwd: Option<&str>,
334 options: &TranscriptDrainOptions<'_>,
335 byte_limit: Option<u64>,
336) -> Result<RawIngestReport> {
337 let mut report = RawIngestReport::default();
338 let mut record_ordinal = 0_i64;
339 let stream_result = with_raw_archive_drain_savepoint(conn, || {
340 crate::memory::raw_transcript::stream_transcript_lines(
341 transcript_path,
342 byte_limit,
343 |line, is_final| {
344 let ordinal = record_ordinal;
345 record_ordinal += 1;
346 use crate::memory::raw_transcript::TranscriptRecordClass;
347 let message =
348 match crate::memory::raw_transcript::classify_transcript_line(line, None) {
349 TranscriptRecordClass::Conversation(message)
350 | TranscriptRecordClass::MetaUser(message)
351 | TranscriptRecordClass::XmlControlUser(message)
352 | TranscriptRecordClass::MissingEventTime(message) => message,
353 TranscriptRecordClass::MalformedRecord => {
354 if options.tolerate_partial_tail && is_final {
355 report.partial_tail = true;
356 } else {
357 report.parse_errors += 1;
358 }
359 return;
360 }
361 TranscriptRecordClass::EmptyText => {
362 report.empty_messages += 1;
363 return;
364 }
365 TranscriptRecordClass::UnsupportedRecord
366 | TranscriptRecordClass::OutsideWindow => {
367 report.skipped_messages += 1;
368 return;
369 }
370 };
371 let insert_result = if let Some(identity_id) = options.transcript_identity_id {
372 crate::memory::raw_occurrence::insert_transcript_occurrence(
373 conn,
374 session_id,
375 project,
376 message.role,
377 &message.text,
378 branch,
379 cwd,
380 options.source_root,
381 message.created_at_epoch,
382 identity_id,
383 ordinal,
384 )
385 } else {
386 insert_raw_message_from_root_at(
387 conn,
388 session_id,
389 project,
390 message.role,
391 &message.text,
392 SOURCE_TRANSCRIPT,
393 branch,
394 cwd,
395 options.source_root,
396 message.created_at_epoch,
397 )
398 };
399 match insert_result {
400 Ok(Some(outcome)) if outcome.inserted => report.inserted += 1,
401 Ok(Some(_)) => report.duplicates += 1,
402 Ok(None) => report.empty_messages += 1,
403 Err(error) => {
404 report.insert_errors += 1;
405 if error
406 .downcast_ref::<crate::memory::raw_occurrence::RawIdentityConflict>()
407 .is_some()
408 {
409 report.identity_conflicts += 1;
410 }
411 crate::log::warn(
412 "raw-archive",
413 &format!("insert raw message failed: {}", error),
414 );
415 }
416 }
417 },
418 )
419 .map_err(anyhow::Error::from)?;
420 if report.parse_errors > 0 || report.insert_errors > 0 {
421 anyhow::bail!("raw archive drain validation failed");
422 }
423 Ok(())
424 })?;
425 if let Err(error) = stream_result {
426 report.inserted = 0;
427 report.duplicates = 0;
428 if report.parse_errors == 0 && report.insert_errors == 0 {
429 report.read_error = Some(format!("read transcript {transcript_path} failed: {error}"));
430 crate::log::warn(
431 "raw-archive",
432 report
433 .read_error
434 .as_deref()
435 .unwrap_or("read transcript failed"),
436 );
437 }
438 }
439 if report.has_failures() {
440 record_raw_ingest_failure(
441 conn,
442 session_id,
443 project,
444 SOURCE_TRANSCRIPT,
445 Some(transcript_path),
446 &report,
447 )?;
448 }
449 Ok(report)
450}
451
452fn with_raw_archive_drain_savepoint<T>(
453 conn: &Connection,
454 f: impl FnOnce() -> Result<T>,
455) -> Result<std::result::Result<T, anyhow::Error>> {
456 conn.execute_batch("SAVEPOINT remem_raw_archive_drain;")
457 .context("start raw archive drain savepoint")?;
458 match f() {
459 Ok(value) => {
460 conn.execute_batch("RELEASE SAVEPOINT remem_raw_archive_drain;")
461 .context("release raw archive drain savepoint")?;
462 Ok(Ok(value))
463 }
464 Err(error) => {
465 let rollback = conn.execute_batch(
466 "ROLLBACK TO SAVEPOINT remem_raw_archive_drain;
467 RELEASE SAVEPOINT remem_raw_archive_drain;",
468 );
469 match rollback {
470 Ok(()) => Ok(Err(error)),
471 Err(rollback_error) => Err(error).context(format!(
472 "raw archive drain rollback also failed: {rollback_error}"
473 )),
474 }
475 }
476 }
477}
478
479pub fn record_raw_ingest_failure(
480 conn: &Connection,
481 session_id: &str,
482 project: &str,
483 source: &str,
484 transcript_path: Option<&str>,
485 report: &RawIngestReport,
486) -> Result<()> {
487 let Some(kind) = report.failure_kind() else {
488 return Ok(());
489 };
490 conn.execute(
491 "INSERT INTO raw_ingest_failures
492 (project, session_id, source, transcript_path, error_kind, error_message,
493 inserted, duplicates, parse_errors, insert_errors, created_at_epoch)
494 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
495 params![
496 project,
497 session_id,
498 source,
499 transcript_path,
500 kind,
501 crate::db::truncate_str(&report.failure_message(), 1000),
502 report.inserted as i64,
503 report.duplicates as i64,
504 report.parse_errors as i64,
505 report.insert_errors as i64,
506 chrono::Utc::now().timestamp()
507 ],
508 )?;
509 Ok(())
510}
511
512#[derive(Debug, Clone)]
513pub struct RawSearchRequest {
514 pub query: String,
515 pub project: Option<String>,
516 pub branch: Option<String>,
517 pub role: Option<String>,
518 pub limit: i64,
519 pub offset: i64,
520 pub since_epoch: Option<i64>,
523 pub until_epoch: Option<i64>,
526}
527
528pub fn search_raw_messages(conn: &Connection, req: &RawSearchRequest) -> Result<Vec<RawMessage>> {
529 let limit = req.limit.max(1);
530 let offset = req.offset.max(0);
531 let query = req.query.trim();
532 if query.is_empty() {
533 return Ok(vec![]);
534 }
535
536 let mut sql = String::from(
537 "SELECT r.id, r.session_id, r.project, r.role, r.content, r.source, \
538 r.branch, r.cwd, r.created_at_epoch \
539 FROM raw_messages r \
540 JOIN raw_messages_fts f ON f.rowid = r.id \
541 WHERE raw_messages_fts MATCH ?1",
542 );
543 let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(fts_query(query))];
544
545 if let Some(project) = req.project.as_deref() {
546 sql.push_str(" AND r.project = ?");
547 sql.push_str(&(binds.len() + 1).to_string());
548 binds.push(Box::new(project.to_string()));
549 }
550 if let Some(branch) = req.branch.as_deref() {
551 let idx = binds.len() + 1;
552 sql.push_str(&format!(" AND (r.branch = ?{idx} OR r.branch IS NULL)"));
553 binds.push(Box::new(branch.to_string()));
554 }
555 if let Some(role) = req.role.as_deref() {
556 sql.push_str(" AND r.role = ?");
557 sql.push_str(&(binds.len() + 1).to_string());
558 binds.push(Box::new(role.to_string()));
559 }
560 if let Some(since) = req.since_epoch {
561 sql.push_str(" AND r.created_at_epoch >= ?");
562 sql.push_str(&(binds.len() + 1).to_string());
563 binds.push(Box::new(since));
564 }
565 if let Some(until) = req.until_epoch {
566 sql.push_str(" AND r.created_at_epoch <= ?");
567 sql.push_str(&(binds.len() + 1).to_string());
568 binds.push(Box::new(until));
569 }
570
571 sql.push_str(&format!(
572 " ORDER BY r.created_at_epoch DESC LIMIT {} OFFSET {}",
573 limit, offset
574 ));
575
576 let mut stmt = conn.prepare(&sql)?;
577 let rows = stmt.query_map(
578 rusqlite::params_from_iter(crate::db::to_sql_refs(&binds)),
579 |row| {
580 Ok(RawMessage {
581 id: row.get(0)?,
582 session_id: row.get(1)?,
583 project: row.get(2)?,
584 role: row.get(3)?,
585 content: row.get(4)?,
586 source: row.get(5)?,
587 branch: row.get(6)?,
588 cwd: row.get(7)?,
589 created_at_epoch: row.get(8)?,
590 })
591 },
592 )?;
593
594 let mut out = Vec::new();
595 for row in rows {
596 out.push(row?);
597 }
598 Ok(out)
599}
600
601#[derive(Debug, Clone, Default)]
603pub struct RawSessionQuery {
604 pub since_epoch: Option<i64>,
606 pub until_epoch: Option<i64>,
608 pub project: Option<String>,
610 pub sample_user_messages: i64,
613}
614
615const SESSION_SAMPLE_PREVIEW_CHARS: usize = 200;
617
618#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
621pub struct RawSessionSummary {
622 pub source_root: String,
623 pub project: String,
624 pub session_id: String,
625 pub first_epoch: i64,
627 pub last_epoch: i64,
628 pub message_count: i64,
629 pub user_message_count: i64,
630 pub assistant_message_count: i64,
631 pub user_message_samples: Vec<String>,
633}
634
635pub fn list_sessions(conn: &Connection, query: &RawSessionQuery) -> Result<Vec<RawSessionSummary>> {
638 let mut sql = String::from(
639 "SELECT source_root, project, session_id, \
640 MIN(created_at_epoch), MAX(created_at_epoch), COUNT(*), \
641 SUM(CASE WHEN role = 'user' THEN 1 ELSE 0 END), \
642 SUM(CASE WHEN role = 'assistant' THEN 1 ELSE 0 END) \
643 FROM raw_messages WHERE 1=1",
644 );
645 let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
646 push_session_filters(&mut sql, &mut binds, query);
647 sql.push_str(" GROUP BY source_root, project, session_id ORDER BY MIN(created_at_epoch) ASC");
648
649 let mut stmt = conn.prepare(&sql)?;
650 let rows = stmt.query_map(
651 rusqlite::params_from_iter(crate::db::to_sql_refs(&binds)),
652 |row| {
653 Ok(RawSessionSummary {
654 source_root: row.get(0)?,
655 project: row.get(1)?,
656 session_id: row.get(2)?,
657 first_epoch: row.get(3)?,
658 last_epoch: row.get(4)?,
659 message_count: row.get(5)?,
660 user_message_count: row.get(6)?,
661 assistant_message_count: row.get(7)?,
662 user_message_samples: Vec::new(),
663 })
664 },
665 )?;
666
667 let mut sessions = Vec::new();
668 for row in rows {
669 sessions.push(row?);
670 }
671 if query.sample_user_messages > 0 {
672 for session in &mut sessions {
673 session.user_message_samples = sample_user_messages(conn, query, session)?;
674 }
675 }
676 Ok(sessions)
677}
678
679fn push_session_filters(
680 sql: &mut String,
681 binds: &mut Vec<Box<dyn rusqlite::types::ToSql>>,
682 query: &RawSessionQuery,
683) {
684 if let Some(project) = query.project.as_deref() {
685 sql.push_str(" AND project = ?");
686 sql.push_str(&(binds.len() + 1).to_string());
687 binds.push(Box::new(project.to_string()));
688 }
689 if let Some(since) = query.since_epoch {
690 sql.push_str(" AND created_at_epoch >= ?");
691 sql.push_str(&(binds.len() + 1).to_string());
692 binds.push(Box::new(since));
693 }
694 if let Some(until) = query.until_epoch {
695 sql.push_str(" AND created_at_epoch <= ?");
696 sql.push_str(&(binds.len() + 1).to_string());
697 binds.push(Box::new(until));
698 }
699}
700
701fn sample_user_messages(
702 conn: &Connection,
703 query: &RawSessionQuery,
704 session: &RawSessionSummary,
705) -> Result<Vec<String>> {
706 let mut sql = String::from(
707 "SELECT content FROM raw_messages \
708 WHERE source_root = ?1 AND project = ?2 AND session_id = ?3 AND role = ?4",
709 );
710 let mut binds: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
711 Box::new(session.source_root.clone()),
712 Box::new(session.project.clone()),
713 Box::new(session.session_id.clone()),
714 Box::new(ROLE_USER.to_string()),
715 ];
716 if let Some(since) = query.since_epoch {
717 sql.push_str(" AND created_at_epoch >= ?");
718 sql.push_str(&(binds.len() + 1).to_string());
719 binds.push(Box::new(since));
720 }
721 if let Some(until) = query.until_epoch {
722 sql.push_str(" AND created_at_epoch <= ?");
723 sql.push_str(&(binds.len() + 1).to_string());
724 binds.push(Box::new(until));
725 }
726 sql.push_str(&format!(
727 " ORDER BY created_at_epoch ASC, id ASC LIMIT {}",
728 query.sample_user_messages.max(0)
729 ));
730
731 let mut stmt = conn.prepare(&sql)?;
732 let rows = stmt.query_map(
733 rusqlite::params_from_iter(crate::db::to_sql_refs(&binds)),
734 |row| row.get::<_, String>(0),
735 )?;
736 let mut samples = Vec::new();
737 for row in rows {
738 let content = row?;
739 samples.push(content.chars().take(SESSION_SAMPLE_PREVIEW_CHARS).collect());
740 }
741 Ok(samples)
742}
743
744#[derive(Debug, Clone, serde::Serialize)]
747pub struct RawSessionsJson {
748 pub since_epoch: Option<i64>,
749 pub until_epoch: Option<i64>,
750 pub project: Option<String>,
751 pub sample: i64,
752 pub count: usize,
753 pub sessions: Vec<RawSessionSummary>,
754}
755
756pub fn build_sessions_json(
757 query: &RawSessionQuery,
758 sessions: Vec<RawSessionSummary>,
759) -> RawSessionsJson {
760 RawSessionsJson {
761 since_epoch: query.since_epoch,
762 until_epoch: query.until_epoch,
763 project: query.project.clone(),
764 sample: query.sample_user_messages,
765 count: sessions.len(),
766 sessions,
767 }
768}
769
770pub fn parse_time_bound(value: &str) -> Result<i64> {
777 super::raw_query::parse_time_lower_bound(value)
778}
779
780fn fts_query(query: &str) -> String {
781 let cleaned: Vec<String> = query
784 .split_whitespace()
785 .filter(|token| !token.is_empty())
786 .map(|token| format!("\"{}\"", token.replace('\"', "\"\"")))
787 .collect();
788 if cleaned.is_empty() {
789 format!("\"{}\"", query.replace('\"', "\"\""))
790 } else {
791 cleaned.join(" ")
792 }
793}
794
795#[cfg(test)]
796mod tests;