1use 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}