use anyhow::Result;
use serde::Serialize;
use crate::{
db,
memory::{
raw_archive::RawMessage,
service::{MultiHopMeta, SearchRequest, SearchResultSet},
Memory,
},
retrieval::search::{SearchExplain, SearchExplainDetails, SearchExplainResultBreakdown},
};
pub(in crate::cli) fn run_search(
query: &str,
project: Option<&str>,
memory_type: Option<&str>,
limit: i64,
offset: i64,
branch: Option<&str>,
include_stale: bool,
include_suppressed: bool,
multi_hop: bool,
explain: bool,
json: bool,
) -> Result<()> {
let conn = db::open_db()?;
let request = build_search_request(
query,
project,
memory_type,
limit,
offset,
branch,
include_stale,
include_suppressed,
multi_hop,
explain,
);
let detailed = crate::memory::service::search_memories_with_explain_details(&conn, &request)?;
let results = &detailed.result;
if json {
let output = build_search_json(
query,
project,
memory_type,
limit,
offset,
branch,
include_stale,
include_suppressed,
multi_hop,
explain,
results,
detailed.explain_details.as_ref(),
);
println!("{}", serde_json::to_string_pretty(&output)?);
return Ok(());
}
print!(
"{}",
render_search_results_with_details(
results,
detailed.explain_details.as_ref(),
offset,
limit.max(1),
)
);
Ok(())
}
pub(super) fn build_search_request(
query: &str,
project: Option<&str>,
memory_type: Option<&str>,
limit: i64,
offset: i64,
branch: Option<&str>,
include_stale: bool,
include_suppressed: bool,
multi_hop: bool,
explain: bool,
) -> SearchRequest {
SearchRequest {
query: Some(query.to_string()),
project: project.map(str::to_string),
memory_type: memory_type.map(str::to_string),
limit,
offset,
include_stale,
include_suppressed,
branch: branch.map(str::to_string),
multi_hop,
explain,
}
}
#[cfg(test)]
pub(super) fn render_search_results(results: &SearchResultSet, offset: i64, limit: i64) -> String {
render_search_results_with_details(results, None, offset, limit)
}
pub(super) fn render_search_results_with_details(
results: &SearchResultSet,
explain_details: Option<&SearchExplainDetails>,
offset: i64,
limit: i64,
) -> String {
let mut output = String::new();
if results.memories.is_empty() && results.raw_hits.is_empty() && results.raw_error.is_none() {
output.push_str("No curated memories found.\n");
append_empty_search_guidance(&mut output);
append_search_explain(&mut output, results.explain.as_ref(), explain_details);
return output;
}
if let Some(meta) = results.multi_hop.as_ref() {
render_multi_hop_meta(&mut output, meta);
}
if results.memories.is_empty() {
output.push_str("No curated memories found.\n");
} else {
output.push_str(&format!("Found {} result(s):\n\n", results.memories.len()));
for memory in &results.memories {
output.push_str(&format_memory_line(memory));
}
append_curated_next_steps(
&mut output,
&results.memories[0],
results.has_more,
offset,
limit,
);
}
if !results.raw_hits.is_empty() {
if !output.ends_with('\n') {
output.push('\n');
}
output.push('\n');
output.push_str("Raw archive fallback:\n");
for raw in &results.raw_hits {
output.push_str(&format_raw_hit_line(raw));
}
append_raw_fallback_next_step(&mut output);
}
append_raw_fallback_error(&mut output, results.raw_error.as_deref());
append_search_explain(&mut output, results.explain.as_ref(), explain_details);
output
}
#[allow(clippy::too_many_arguments)]
pub(super) fn build_search_json(
query: &str,
project: Option<&str>,
memory_type: Option<&str>,
limit: i64,
offset: i64,
branch: Option<&str>,
include_stale: bool,
include_suppressed: bool,
multi_hop: bool,
explain: bool,
results: &SearchResultSet,
explain_details: Option<&SearchExplainDetails>,
) -> SearchJson {
let normalized_limit = limit.max(1);
SearchJson {
query: query.to_string(),
project: project.map(str::to_string),
memory_type: memory_type.map(str::to_string),
limit: normalized_limit,
offset: offset.max(0),
branch: branch.map(str::to_string),
include_stale,
include_suppressed,
multi_hop_requested: multi_hop,
explain_requested: explain,
count: results.memories.len(),
has_more: results.has_more,
next_offset: results.has_more.then_some(offset.max(0) + normalized_limit),
results: results.memories.clone(),
raw_hits: results
.raw_hits
.iter()
.map(|raw| RawHitJson {
id: raw.id,
session_id: raw.session_id.clone(),
project: raw.project.clone(),
role: raw.role.clone(),
content: raw.content.clone(),
source: raw.source.clone(),
branch: raw.branch.clone(),
cwd: raw.cwd.clone(),
created_at_epoch: raw.created_at_epoch,
})
.collect(),
raw_hits_error: results.raw_error.clone(),
multi_hop: results.multi_hop.as_ref().map(|meta| MultiHopJson {
hops: meta.hops,
entities_discovered: meta.entities_discovered.clone(),
}),
explain_details: explain_details.cloned(),
}
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct SearchJson {
pub query: String,
pub project: Option<String>,
pub memory_type: Option<String>,
pub limit: i64,
pub offset: i64,
pub branch: Option<String>,
pub include_stale: bool,
pub include_suppressed: bool,
pub multi_hop_requested: bool,
pub explain_requested: bool,
pub count: usize,
pub has_more: bool,
pub next_offset: Option<i64>,
pub results: Vec<Memory>,
pub raw_hits: Vec<RawHitJson>,
pub raw_hits_error: Option<String>,
pub multi_hop: Option<MultiHopJson>,
pub explain_details: Option<SearchExplainDetails>,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct MultiHopJson {
pub hops: u8,
pub entities_discovered: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct RawHitJson {
pub id: i64,
pub session_id: String,
pub project: String,
pub role: String,
pub content: String,
pub source: String,
pub branch: Option<String>,
pub cwd: Option<String>,
pub created_at_epoch: i64,
}
fn append_empty_search_guidance(output: &mut String) {
output.push_str("\nTry:\n");
output.push_str(" remem search \"<query>\" --include-stale\n");
output.push_str(" remem search \"<query>\" --multi-hop\n");
output.push_str(" remem search \"<query>\" --project /path/to/repo\n");
output.push_str(" remem search \"<query>\" --explain\n");
}
fn append_curated_next_steps(
output: &mut String,
first_memory: &Memory,
has_more: bool,
offset: i64,
limit: i64,
) {
output.push_str("\nNext:\n");
output.push_str(&format!(
" remem show {} # full details for one memory\n",
first_memory.id
));
output.push_str(&format!(
" remem why {} # visibility and retrieval diagnostics\n",
first_memory.id
));
if has_more {
output.push_str(&format!(
" remem search \"<query>\" --offset {}\n",
offset.max(0) + limit.max(1)
));
}
}
fn append_raw_fallback_next_step(output: &mut String) {
output.push_str("\nNext:\n");
output.push_str(
" use raw hits for recall only; promote durable conclusions with review/save_memory.\n",
);
}
fn append_raw_fallback_error(output: &mut String, error: Option<&str>) {
let Some(error) = error else {
return;
};
if !output.ends_with('\n') {
output.push('\n');
}
output.push('\n');
output.push_str("Raw archive fallback error:\n");
output.push_str(" ");
output.push_str(error);
output.push('\n');
}
fn append_search_explain(
output: &mut String,
explain: Option<&SearchExplain>,
explain_details: Option<&SearchExplainDetails>,
) {
if let Some(explain) = explain {
if !output.ends_with('\n') {
output.push('\n');
}
output.push('\n');
output.push_str(&render_search_explain(
explain,
explain_details.map(|details| details.contribution_breakdowns.as_slice()),
));
}
}
fn render_multi_hop_meta(output: &mut String, meta: &MultiHopMeta) {
output.push_str(&format!("Multi-hop: hops={}", meta.hops));
if !meta.entities_discovered.is_empty() {
output.push_str(&format!(
" entities={}",
meta.entities_discovered.join(", ")
));
}
output.push_str("\n\n");
}
fn format_memory_line(memory: &Memory) -> String {
let mut output = format!(
" [{}] {} | {} | {} | {}\n",
memory.id,
memory.memory_type,
memory.project,
created_date(memory.created_at_epoch),
memory.title
);
let preview = preview_text(memory);
if !preview.is_empty() && preview != memory.title {
output.push_str(&format!(" {}\n", preview));
}
output
}
fn format_raw_hit_line(raw: &RawMessage) -> String {
let branch = raw
.branch
.as_deref()
.map(|branch| format!(" | branch={branch}"))
.unwrap_or_default();
let preview = preview_raw_text(raw);
let mut output = format!(
" [raw:{}] {} | {} | {}{}",
raw.id,
raw.role,
raw.project,
created_date(raw.created_at_epoch),
branch
);
if !preview.is_empty() {
output.push_str(&format!(" | {}", preview));
}
output.push('\n');
output
}
fn render_search_explain(
explain: &SearchExplain,
contribution_breakdowns: Option<&[SearchExplainResultBreakdown]>,
) -> String {
let mut output = String::new();
output.push_str("Search explain:\n");
output.push_str(&format!(" query: {:?}\n", explain.query));
output.push_str(&format!(
" filters: project={:?} branch={:?} type={:?} include_stale={}\n",
explain.project, explain.branch, explain.memory_type, explain.include_stale
));
output.push_str(&format!(
" pagination: limit={} offset={} fetch_limit={} has_more={}\n",
explain.limit, explain.offset, explain.fetch_limit, explain.has_more
));
output.push_str(&format!(
" expanded_terms: [{}]\n",
explain.expanded_terms.join(", ")
));
output.push_str(&format!(
" core_terms: [{}]\n",
explain.core_terms.join(", ")
));
output.push_str(&format!(
" claim_terms: [{}]\n",
explain.claim_terms.join(", ")
));
output.push_str(&format!(" fts_query: {:?}\n", explain.fts_query));
output.push_str(&format!(" temporal_range: {:?}\n", explain.temporal_range));
output.push_str(&format!(" temporal_field: {:?}\n", explain.temporal_field));
output.push_str(&format!(" rrf_k: {:.1}\n", explain.rrf_k));
output.push_str(&format!(
" evidence_gate: min_confidence={:.2} filtered={}\n",
explain.min_evidence_confidence, explain.filtered_result_count
));
if !explain.timings.is_empty() {
output.push_str(&format!(
" timings: {}\n",
crate::perf::format_phase_timings(&explain.timings)
));
}
output.push_str(" channels:\n");
for channel in &explain.channels {
if !channel.enabled {
output.push_str(&format!(
" {}: disabled ({})\n",
channel.name,
channel.disabled_reason.as_deref().unwrap_or("unknown")
));
continue;
}
output.push_str(&format!(
" {}: {}\n",
channel.name,
channel
.hits
.iter()
.map(|hit| format!("{}#{}", hit.memory_id, hit.rank))
.collect::<Vec<_>>()
.join(", ")
));
}
output.push_str(" results:\n");
for result in &explain.results {
let contribution_breakdown = contribution_breakdowns
.unwrap_or_default()
.iter()
.find(|breakdown| breakdown.memory_id == result.memory_id);
let post_fusion_score_factor = result
.post_fusion_score_factor()
.map(|factor| format!("{factor:.3}"))
.unwrap_or_else(|| "n/a".to_string());
output.push_str(&format!(
" [{}] rank={} score={:.6} fusion_score={:.6} post_fusion_score_factor={} evidence_confidence={:.2} visibility={} scope={} project={}\n",
result.memory_id,
result.final_rank,
result.final_score,
result.fusion_score(),
post_fusion_score_factor,
result.evidence_confidence,
result.visibility,
result.scope,
result.project
));
if let Some(breakdown) = contribution_breakdown {
output.push_str(&format!(
" contributions: {}\n",
breakdown
.contributions
.iter()
.map(|contribution| format!(
"{}#{}={:.6} (weight={:.6}, reciprocal_rank={:.6}, normalized_signal={}, total={:.6})",
contribution.channel,
contribution.rank,
contribution.total_score,
contribution.weight,
contribution.reciprocal_rank,
contribution
.normalized_signal
.map(|signal| format!("{signal:.6}"))
.unwrap_or_else(|| "none".to_string()),
contribution.total_score,
))
.collect::<Vec<_>>()
.join(", ")
));
}
}
output.push_str(&format!(
" raw_fallback_count: {}\n",
explain.raw_fallback_count
));
output
}
pub(super) fn created_date(created_at_epoch: i64) -> String {
chrono::DateTime::from_timestamp(created_at_epoch, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_default()
}
pub(super) fn preview_text(memory: &Memory) -> String {
memory
.text
.lines()
.next()
.unwrap_or("")
.chars()
.take(80)
.collect()
}
pub(super) fn preview_raw_text(raw: &RawMessage) -> String {
raw.content
.lines()
.next()
.unwrap_or("")
.chars()
.take(100)
.collect()
}