use crate::errors::Error;
use crate::sqlite::{Database, UpdateOptions};
use std::process::ExitCode;
pub(crate) const PROMOTION_THRESHOLD_DEFAULT: i64 = 5;
pub(crate) const PROMOTION_THRESHOLD_ENV: &str = "VIPUNE_PROMOTION_THRESHOLD";
pub(crate) struct PromotionCandidate {
pub id: String,
pub status: String,
pub retrieval_count: i64,
}
pub(crate) fn should_promote(status: &str, retrieval_count: i64, threshold: i64) -> bool {
status == "candidate" && retrieval_count >= threshold
}
pub(crate) fn resolve_promotion_threshold() -> Result<i64, Error> {
let Ok(raw) = std::env::var(PROMOTION_THRESHOLD_ENV) else {
return Ok(PROMOTION_THRESHOLD_DEFAULT);
};
let parsed: i64 = raw.trim().parse().map_err(|_| {
Error::InvalidInput(format!(
"Invalid {} '{}'; expected a non-negative integer",
PROMOTION_THRESHOLD_ENV, raw
))
})?;
if parsed < 0 {
return Err(Error::InvalidInput(format!(
"Invalid {} '{}'; must be >= 0",
PROMOTION_THRESHOLD_ENV, raw
)));
}
Ok(parsed)
}
pub(crate) fn fetch_candidates(
db: &Database,
project_id: &str,
) -> Result<Vec<PromotionCandidate>, Error> {
let mut stmt = db
.conn()
.prepare("SELECT id, status, retrieval_count FROM memories WHERE project_id = ? AND status = 'candidate'")
.map_err(Error::from)?;
let rows = stmt
.query_map([project_id], |row| {
Ok(PromotionCandidate {
id: row.get::<_, String>(0)?,
status: row.get::<_, String>(1)?,
retrieval_count: row.get::<_, i64>(2)?,
})
})
.map_err(Error::from)?
.collect::<Result<Vec<_>, rusqlite::Error>>()
.map_err(Error::from)?;
Ok(rows)
}
pub(crate) fn run_promotion(db: &Database, project_id: &str) -> Result<usize, Error> {
let threshold = resolve_promotion_threshold()?;
let candidates = fetch_candidates(db, project_id)?;
let mut promoted = 0usize;
for c in &candidates {
if should_promote(&c.status, c.retrieval_count, threshold) {
db.update(
&c.id,
project_id,
UpdateOptions {
content: None,
embedding: None,
metadata: None,
memory_type: None,
status: Some("active"),
importance: None,
},
)
.map_err(Error::from)?;
promoted += 1;
}
}
Ok(promoted)
}
pub fn handle_promote(
db_path: &std::path::Path,
project_id: &str,
json: bool,
) -> Result<ExitCode, Error> {
let db = Database::open(db_path).map_err(|e| {
let msg = e.to_string();
if msg.contains("database is locked") {
return Error::Config(
"Database is locked. Another process (likely the MCP server) is holding a lock. Stop the MCP server and retry.".to_string(),
);
}
Error::Config(msg)
})?;
let threshold = resolve_promotion_threshold()?;
let promoted = run_promotion(&db, project_id)?;
let response = serde_json::json!({
"project_id": project_id,
"threshold": threshold,
"promoted": promoted,
});
if json {
crate::output::print_json(&response);
} else {
println!(
"Promoted {} candidate memor{} to active (threshold retrieval_count >= {})",
promoted,
if promoted == 1 { "y" } else { "ies" },
threshold
);
}
Ok(ExitCode::SUCCESS)
}