Skip to main content

kaizen/shell/
search.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! `kaizen sessions search` and `kaizen search reindex`.
3
4use crate::core::config::{self, try_team_salt};
5use crate::search::{SearchHit, SearchQuery};
6use crate::shell::cli::workspace_path;
7use crate::shell::fmt::fmt_ts;
8use crate::store::Store;
9use anyhow::{Context, Result};
10use std::path::Path;
11
12pub fn cmd_sessions_search(
13    workspace: Option<&Path>,
14    query: &str,
15    since: Option<&str>,
16    agent: Option<&str>,
17    kind: Option<&str>,
18    limit: usize,
19) -> Result<()> {
20    print!(
21        "{}",
22        sessions_search_text(workspace, query, since, agent, kind, limit)?
23    );
24    Ok(())
25}
26
27pub fn sessions_search_text(
28    workspace: Option<&Path>,
29    query: &str,
30    since: Option<&str>,
31    agent: Option<&str>,
32    kind: Option<&str>,
33    limit: usize,
34) -> Result<String> {
35    let (hits, fallback) = sessions_search_hits(workspace, query, since, agent, kind, limit)?;
36    render_hits(&hits, fallback)
37}
38
39pub fn sessions_search_hits(
40    workspace: Option<&Path>,
41    query: &str,
42    since: Option<&str>,
43    agent: Option<&str>,
44    kind: Option<&str>,
45    limit: usize,
46) -> Result<(Vec<SearchHit>, bool)> {
47    let ws = workspace_path(workspace)?;
48    let store = Store::open(&crate::core::workspace::db_path(&ws))?;
49    store.flush_search().ok();
50    let cfg = config::load(&ws)?;
51    let salt = try_team_salt(&cfg.sync).unwrap_or([0; 32]);
52    let opts = SearchQuery {
53        query: query.to_string(),
54        since_ms: parse_since(since)?,
55        agent: agent.map(str::to_string),
56        kind: kind.map(str::to_string),
57        limit,
58    };
59    match crate::search::search(&ws.join(".kaizen"), &opts, &ws, &salt, |s, q| {
60        store.get_event(s, q)
61    }) {
62        Ok(hits) => Ok((hits, false)),
63        Err(_) => Ok((scan_fallback(&store, &ws, &opts, &salt)?, true)),
64    }
65}
66
67pub fn cmd_search_reindex(workspace: Option<&Path>) -> Result<()> {
68    let ws = workspace_path(workspace)?;
69    let store = Store::open(&crate::core::workspace::db_path(&ws))?;
70    let cfg = config::load(&ws)?;
71    let ws_str = ws.to_string_lossy().to_string();
72    let sessions = store.list_sessions(&ws_str)?;
73    let events = store.workspace_events(&ws_str)?;
74    let stats = crate::search::reindex_workspace(&ws.join(".kaizen"), &ws, &sessions, events, &cfg)
75        .context("reindex search")?;
76    println!(
77        "search reindex: {} events seen, {} docs indexed",
78        stats.events_seen, stats.docs_indexed
79    );
80    Ok(())
81}
82
83fn render_hits(hits: &[SearchHit], fallback: bool) -> Result<String> {
84    use std::fmt::Write;
85    let mut out = String::new();
86    if fallback {
87        writeln!(
88            out,
89            "warning: search index unavailable; falling back to event scan"
90        )?;
91    }
92    writeln!(out, "{:<40} {:<19} {:>7} SNIPPET", "SESSION", "TS", "SCORE")?;
93    for h in hits {
94        writeln!(
95            out,
96            "{:<40} {:<19} {:>7.3} {}",
97            h.session_id,
98            fmt_ts(h.ts_ms),
99            h.score,
100            h.snippet
101        )?;
102    }
103    Ok(out)
104}
105
106fn scan_fallback(
107    store: &Store,
108    ws: &Path,
109    opts: &SearchQuery,
110    salt: &[u8; 32],
111) -> Result<Vec<SearchHit>> {
112    let mut out = Vec::new();
113    for (session, event) in store.workspace_events(&ws.to_string_lossy())? {
114        if out.len() >= opts.limit {
115            break;
116        }
117        let Some(doc) = crate::search::extract_doc(&event, &session, ws, salt) else {
118            continue;
119        };
120        if !scan_match(&doc, opts) {
121            continue;
122        }
123        out.push(SearchHit {
124            session_id: doc.session_id,
125            seq: doc.seq,
126            ts_ms: doc.ts_ms,
127            agent: doc.agent,
128            kind: doc.kind,
129            score: 0.0,
130            snippet: crate::search::extract::snippet(&doc.text, &opts.query),
131            paths: doc.paths,
132            skills: doc.skills,
133            tokens_total: doc.tokens_total,
134        });
135    }
136    Ok(out)
137}
138
139fn scan_match(doc: &crate::search::SearchDoc, opts: &SearchQuery) -> bool {
140    let q = opts.query.trim_matches('"').to_lowercase();
141    opts.agent.as_ref().is_none_or(|a| &doc.agent == a)
142        && opts.kind.as_ref().is_none_or(|k| &doc.kind == k)
143        && opts.since_ms.is_none_or(|ms| doc.ts_ms >= ms)
144        && (doc.text.to_lowercase().contains(&q)
145            || doc.paths.iter().any(|p| opts.query.contains(p)))
146}
147
148fn parse_since(raw: Option<&str>) -> Result<Option<u64>> {
149    let Some(raw) = raw else { return Ok(None) };
150    let days = raw.trim_end_matches('d').parse::<u64>()?;
151    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
152    Ok(Some(
153        (now.as_millis() as u64).saturating_sub(days.saturating_mul(86_400_000)),
154    ))
155}