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::core::event::Event;
6use crate::search::extract::{redacted_event_text, snippet};
7use crate::search::{SearchHit, SearchQuery};
8use crate::shell::cli::{open_workspace_read_store, workspace_path};
9use crate::shell::fmt::fmt_ts;
10use crate::store::Store;
11use crate::store::event_index::{is_valid_slug, paths_from_event_payload, skills_from_event_json};
12use anyhow::{Context, Result};
13use std::path::Path;
14
15pub fn cmd_sessions_search(
16    workspace: Option<&Path>,
17    query: &str,
18    since: Option<&str>,
19    agent: Option<&str>,
20    kind: Option<&str>,
21    limit: usize,
22) -> Result<()> {
23    print!(
24        "{}",
25        sessions_search_text(workspace, query, since, agent, kind, limit)?
26    );
27    Ok(())
28}
29
30pub fn sessions_search_text(
31    workspace: Option<&Path>,
32    query: &str,
33    since: Option<&str>,
34    agent: Option<&str>,
35    kind: Option<&str>,
36    limit: usize,
37) -> Result<String> {
38    if crate::core_loop::query::is_structured(query) {
39        return structured_search_text(workspace, query, since, limit);
40    }
41    let (hits, fallback) = sessions_search_hits(workspace, query, since, agent, kind, limit)?;
42    render_hits(&hits, fallback)
43}
44
45fn structured_search_text(
46    workspace: Option<&Path>,
47    query: &str,
48    since: Option<&str>,
49    limit: usize,
50) -> Result<String> {
51    let ws = workspace_path(workspace)?;
52    let store = open_workspace_read_store(&ws, false)?;
53    let start = since
54        .map(|s| crate::core_loop::time::parse_window(Some(s), 7))
55        .transpose()?
56        .unwrap_or(0);
57    let hits = crate::core_loop::query::run(&store, &ws.to_string_lossy(), query, start, limit)?;
58    let mut out = String::new();
59    use std::fmt::Write;
60    writeln!(out, "{:<40} {:>6} {:<12} SUMMARY", "SESSION", "SEQ", "KIND")?;
61    for h in hits {
62        writeln!(
63            out,
64            "{:<40} {:>6} {:<12} {}",
65            h.session_id,
66            h.seq.map(|s| s.to_string()).unwrap_or_else(|| "-".into()),
67            h.kind,
68            h.summary
69        )?;
70    }
71    Ok(out)
72}
73
74pub fn sessions_search_hits(
75    workspace: Option<&Path>,
76    query: &str,
77    since: Option<&str>,
78    agent: Option<&str>,
79    kind: Option<&str>,
80    limit: usize,
81) -> Result<(Vec<SearchHit>, bool)> {
82    let ws = workspace_path(workspace)?;
83    let store = open_workspace_read_store(&ws, false)?;
84    let cfg = config::load(&ws)?;
85    let salt = try_team_salt(&cfg.sync).unwrap_or([0; 32]);
86    let opts = SearchQuery {
87        query: query.to_string(),
88        since_ms: parse_since(since)?,
89        agent: agent.map(str::to_string),
90        kind: kind.map(str::to_string),
91        limit,
92    };
93    let data_dir = crate::core::paths::project_data_dir(&ws)?;
94    if let Some(hits) = exact_tool_hits(&store, &ws, &opts, &salt)? {
95        return Ok((hits, false));
96    }
97    match crate::search::search(&data_dir, &opts, &ws, &salt, |s, q| store.get_event(s, q)) {
98        Ok(hits) => Ok((hits, false)),
99        Err(e) => anyhow::bail!("search index unavailable: {e}; run `kaizen search reindex`"),
100    }
101}
102
103fn exact_tool_hits(
104    store: &Store,
105    workspace: &Path,
106    opts: &SearchQuery,
107    salt: &[u8; 32],
108) -> Result<Option<Vec<SearchHit>>> {
109    let Some(tool) = exact_tool_query(opts) else {
110        return Ok(None);
111    };
112    let ws = workspace.to_string_lossy();
113    let rows =
114        store.search_tool_events(&ws, tool, opts.since_ms, opts.agent.as_deref(), opts.limit)?;
115    let hits = rows
116        .into_iter()
117        .map(|(agent, event)| tool_hit(agent, event, workspace, salt, &opts.query))
118        .collect::<Vec<_>>();
119    Ok((!hits.is_empty()).then_some(hits))
120}
121
122fn exact_tool_query(opts: &SearchQuery) -> Option<&str> {
123    if opts.limit == 0 || opts.kind.is_some() || !is_valid_slug(&opts.query) {
124        return None;
125    }
126    Some(opts.query.as_str())
127}
128
129fn tool_hit(
130    agent: String,
131    event: Event,
132    workspace: &Path,
133    salt: &[u8; 32],
134    query: &str,
135) -> SearchHit {
136    let text = redacted_event_text(&event, workspace, salt);
137    SearchHit {
138        session_id: event.session_id.clone(),
139        seq: event.seq,
140        ts_ms: event.ts_ms,
141        agent,
142        kind: crate::search::kind_label(&event.kind)
143            .unwrap_or("unknown")
144            .to_string(),
145        score: 1.0,
146        snippet: snippet(&text, query),
147        paths: paths_from_event_payload(&event.payload),
148        skills: skills_from_event_json(&event.payload),
149        tokens_total: crate::search::tokens_total(&event),
150    }
151}
152
153pub fn cmd_search_reindex(workspace: Option<&Path>) -> Result<()> {
154    let ws = workspace_path(workspace)?;
155    let store = Store::open(&crate::core::workspace::db_path(&ws)?)?;
156    let cfg = config::load(&ws)?;
157    let ws_str = ws.to_string_lossy().to_string();
158    let sessions = store.list_sessions(&ws_str)?;
159    let events = store.workspace_events(&ws_str)?;
160    let data_dir = crate::core::paths::project_data_dir(&ws)?;
161    let stats = crate::search::reindex_workspace(&data_dir, &ws, &sessions, events, &cfg)
162        .context("reindex search")?;
163    println!(
164        "search reindex: {} events seen, {} docs indexed",
165        stats.events_seen, stats.docs_indexed
166    );
167    Ok(())
168}
169
170fn render_hits(hits: &[SearchHit], fallback: bool) -> Result<String> {
171    use std::fmt::Write;
172    let mut out = String::new();
173    if fallback {
174        writeln!(
175            out,
176            "warning: search index unavailable; falling back to event scan"
177        )?;
178    }
179    writeln!(out, "{:<40} {:<19} {:>7} SNIPPET", "SESSION", "TS", "SCORE")?;
180    for h in hits {
181        writeln!(
182            out,
183            "{:<40} {:<19} {:>7.3} {}",
184            h.session_id,
185            fmt_ts(h.ts_ms),
186            h.score,
187            h.snippet
188        )?;
189    }
190    Ok(out)
191}
192
193fn parse_since(raw: Option<&str>) -> Result<Option<u64>> {
194    let Some(raw) = raw else { return Ok(None) };
195    let days = raw.trim_end_matches('d').parse::<u64>()?;
196    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
197    Ok(Some(
198        (now.as_millis() as u64).saturating_sub(days.saturating_mul(86_400_000)),
199    ))
200}