use std::collections::HashMap;
use std::path::Path;
use rmcp::schemars::{self, JsonSchema};
use serde::Serialize;
use crate::core::db::{Database, DbError};
const WORKTREE_PREFIX: &str = "worktree://";
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ProjectSummary {
pub wing: String,
pub path: Option<String>,
pub last_activity: String,
pub total: i64,
pub evidence: i64,
pub knowledge: i64,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ResumeEvidence {
pub drawer_id: String,
pub source_file: Option<String>,
pub snippet: String,
pub added_at: String,
pub importance: i64,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ResumeCandidate {
pub drawer_id: String,
pub statement: Option<String>,
pub tier: Option<String>,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct ResumePack {
pub wing: String,
pub path: Option<String>,
pub last_activity: String,
pub total: i64,
pub evidence: i64,
pub knowledge: i64,
pub recent_evidence: Vec<ResumeEvidence>,
pub in_flight: Vec<ResumeCandidate>,
pub next_step: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(tag = "resolution", rename_all = "snake_case")]
pub enum ResumeResolution {
Resolved(Box<ResumePack>),
Ambiguous {
query: String,
candidates: Vec<ProjectSummary>,
},
NotFound {
query: String,
available: Vec<String>,
},
}
pub fn list_projects(db: &Database) -> Result<Vec<ProjectSummary>, DbError> {
let mut agg = db.conn().prepare(
r#"
SELECT wing,
COUNT(*) AS total,
SUM(CASE WHEN memory_kind = 'evidence' THEN 1 ELSE 0 END) AS evidence,
SUM(CASE WHEN memory_kind = 'knowledge' THEN 1 ELSE 0 END) AS knowledge,
MAX(added_at) AS last_activity
FROM drawers
WHERE deleted_at IS NULL
GROUP BY wing
"#,
)?;
let mut summaries: Vec<ProjectSummary> = agg
.query_map([], |row| {
Ok(ProjectSummary {
wing: row.get(0)?,
path: None,
total: row.get(1)?,
evidence: row.get(2)?,
knowledge: row.get(3)?,
last_activity: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
let mut paths = db.conn().prepare(
r#"
SELECT wing, anchor_id, MAX(added_at)
FROM drawers
WHERE deleted_at IS NULL
AND anchor_kind = 'worktree'
AND anchor_id LIKE 'worktree://%'
GROUP BY wing
"#,
)?;
let path_map: HashMap<String, String> = paths
.query_map([], |row| {
let wing: String = row.get(0)?;
let anchor: String = row.get(1)?;
Ok((wing, anchor))
})?
.collect::<std::result::Result<Vec<_>, _>>()?
.into_iter()
.map(|(wing, anchor)| {
let path = anchor
.strip_prefix(WORKTREE_PREFIX)
.unwrap_or(&anchor)
.to_string();
(wing, path)
})
.collect();
for summary in &mut summaries {
summary.path = path_map.get(&summary.wing).cloned();
}
summaries.sort_by(|a, b| {
b.last_activity
.parse::<i64>()
.unwrap_or(0)
.cmp(&a.last_activity.parse::<i64>().unwrap_or(0))
.then_with(|| a.wing.cmp(&b.wing))
});
Ok(summaries)
}
pub fn resume_project(
db: &Database,
query: &str,
evidence_limit: usize,
candidate_limit: usize,
) -> Result<ResumeResolution, DbError> {
let needle = query.trim().to_lowercase();
let projects = list_projects(db)?;
let available = || projects.iter().map(|p| p.wing.clone()).collect::<Vec<_>>();
if needle.is_empty() {
return Ok(ResumeResolution::NotFound {
query: query.to_string(),
available: available(),
});
}
let exact: Vec<&ProjectSummary> = projects
.iter()
.filter(|p| p.wing.to_lowercase() == needle)
.collect();
let matches: Vec<&ProjectSummary> = if !exact.is_empty() {
exact
} else {
projects
.iter()
.filter(|p| {
p.wing.to_lowercase().contains(&needle) || path_basename_matches(p, &needle)
})
.collect()
};
match matches.len() {
0 => Ok(ResumeResolution::NotFound {
query: query.to_string(),
available: available(),
}),
1 => {
let p = matches[0].clone();
let recent_evidence = recent_evidence(db, &p.wing, evidence_limit)?;
let in_flight = candidate_knowledge(db, &p.wing, candidate_limit)?;
let next_step = match &p.path {
Some(path) => {
format!("cd {path} && mempal context \"resume {}\"", p.wing)
}
None => format!(
"project '{}' has no recorded worktree path (legacy drawers); \
supply the path, then run mempal context",
p.wing
),
};
Ok(ResumeResolution::Resolved(Box::new(ResumePack {
wing: p.wing,
path: p.path,
last_activity: p.last_activity,
total: p.total,
evidence: p.evidence,
knowledge: p.knowledge,
recent_evidence,
in_flight,
next_step,
})))
}
_ => Ok(ResumeResolution::Ambiguous {
query: query.to_string(),
candidates: matches.into_iter().cloned().collect(),
}),
}
}
fn path_basename_matches(project: &ProjectSummary, needle: &str) -> bool {
project
.path
.as_deref()
.and_then(|path| Path::new(path).file_name())
.and_then(|name| name.to_str())
.map(|name| name.to_lowercase().contains(needle))
.unwrap_or(false)
}
fn recent_evidence(
db: &Database,
wing: &str,
limit: usize,
) -> Result<Vec<ResumeEvidence>, DbError> {
let mut statement = db.conn().prepare(
r#"
SELECT id, source_file, substr(content, 1, 200), added_at, importance
FROM drawers
WHERE deleted_at IS NULL AND wing = ?1 AND memory_kind = 'evidence'
ORDER BY added_at DESC, importance DESC
LIMIT ?2
"#,
)?;
let rows = statement
.query_map(rusqlite::params![wing, limit as i64], |row| {
Ok(ResumeEvidence {
drawer_id: row.get(0)?,
source_file: row.get::<_, Option<String>>(1)?,
snippet: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
added_at: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
importance: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}
fn candidate_knowledge(
db: &Database,
wing: &str,
limit: usize,
) -> Result<Vec<ResumeCandidate>, DbError> {
let mut statement = db.conn().prepare(
r#"
SELECT id, statement, tier
FROM drawers
WHERE deleted_at IS NULL AND wing = ?1
AND memory_kind = 'knowledge' AND status = 'candidate'
ORDER BY added_at DESC
LIMIT ?2
"#,
)?;
let rows = statement
.query_map(rusqlite::params![wing, limit as i64], |row| {
Ok(ResumeCandidate {
drawer_id: row.get(0)?,
statement: row.get::<_, Option<String>>(1)?,
tier: row.get::<_, Option<String>>(2)?,
})
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
Ok(rows)
}