Skip to main content

remem/memory/
raw_archive.rs

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
26/// Exact byte-for-byte hash of the raw message content. Distinct from
27/// `memory::promote::slug::content_hash`, which normalizes whitespace/case for
28/// semantic dedup of curated memories.
29fn 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    /// The last line failed JSON parse while the drain was told to tolerate an
54    /// actively-appended tail (issue #722). Not counted as a parse error; the
55    /// caller must not advance its ingest cursor so the tail is re-read later.
56    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/// Options for a transcript drain beyond the hook-path defaults.
247#[derive(Debug, Clone)]
248pub struct TranscriptDrainOptions<'a> {
249    /// Label of the scan root the transcript came from (`local` for the hook
250    /// path and default `ingest-sessions` roots).
251    pub source_root: &'a str,
252    /// Treat a JSON parse failure on the final line as an actively-appended
253    /// partial tail instead of a parse error (issue #722). The caller decides
254    /// this from the file mtime; see `RawIngestReport::partial_tail`.
255    pub tolerate_partial_tail: bool,
256    /// Stable local transcript identity. When present, line ordinals preserve
257    /// repeated identical turns while making Stop/batch replay idempotent.
258    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
271/// Drain a Claude Code transcript JSONL file into raw_messages.
272pub 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/// Drain a transcript with an explicit source root and partial-tail policy.
304#[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    /// Inclusive lower bound on `created_at_epoch`. None keeps the
521    /// pre-window behavior (issue #723).
522    pub since_epoch: Option<i64>,
523    /// Inclusive upper bound on `created_at_epoch`. None keeps the
524    /// pre-window behavior (issue #723).
525    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/// Query parameters for the window session listing (issue #723).
602#[derive(Debug, Clone, Default)]
603pub struct RawSessionQuery {
604    /// Inclusive lower bound on `created_at_epoch`.
605    pub since_epoch: Option<i64>,
606    /// Inclusive upper bound on `created_at_epoch`.
607    pub until_epoch: Option<i64>,
608    /// Restrict to one project path.
609    pub project: Option<String>,
610    /// Sample up to this many role=user message texts per session, ascending
611    /// by epoch. 0 disables sampling.
612    pub sample_user_messages: i64,
613}
614
615/// Truncation bound for sampled user message texts.
616const SESSION_SAMPLE_PREVIEW_CHARS: usize = 200;
617
618/// One session seen inside the query window. Serialized shape is the shared
619/// CLI/MCP JSON contract (product invariant 10).
620#[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    /// Min/max `created_at_epoch` among the session's messages in the window.
626    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    /// First N role=user message texts (truncated), ascending by epoch.
632    pub user_message_samples: Vec<String>,
633}
634
635/// List sessions with messages inside the window, grouped by
636/// `(source_root, project, session_id)` and ordered by first message epoch.
637pub 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/// Shared CLI/MCP JSON envelope for the window session listing so both
745/// surfaces emit identical fields (product invariant 10).
746#[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
770/// Parse a time bound given as Unix epoch seconds, an ISO8601 datetime, or a
771/// plain `YYYY-MM-DD` date interpreted as UTC midnight.
772///
773/// This public compatibility entry point retains its original date semantics.
774/// Query surfaces that need an inclusive date-only upper bound use the
775/// transport-neutral upper-bound parser instead.
776pub 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    // Wrap each token in quotes so we use phrase matching (robust against
782    // punctuation that trigram tokenizer would otherwise choke on).
783    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;