1use std::collections::HashMap;
10use std::path::Path;
11
12use rmcp::schemars::{self, JsonSchema};
13use serde::Serialize;
14
15use crate::core::db::{Database, DbError};
16
17const WORKTREE_PREFIX: &str = "worktree://";
18
19#[derive(Debug, Clone, Serialize, JsonSchema)]
21pub struct ProjectSummary {
22 pub wing: String,
23 pub path: Option<String>,
26 pub last_activity: String,
28 pub total: i64,
29 pub evidence: i64,
30 pub knowledge: i64,
31}
32
33#[derive(Debug, Clone, Serialize, JsonSchema)]
35pub struct ResumeEvidence {
36 pub drawer_id: String,
37 pub source_file: Option<String>,
38 pub snippet: String,
39 pub added_at: String,
40 pub importance: i64,
41}
42
43#[derive(Debug, Clone, Serialize, JsonSchema)]
45pub struct ResumeCandidate {
46 pub drawer_id: String,
47 pub statement: Option<String>,
48 pub tier: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, JsonSchema)]
53pub struct ResumePack {
54 pub wing: String,
55 pub path: Option<String>,
56 pub last_activity: String,
57 pub total: i64,
58 pub evidence: i64,
59 pub knowledge: i64,
60 pub recent_evidence: Vec<ResumeEvidence>,
61 pub in_flight: Vec<ResumeCandidate>,
62 pub next_step: String,
64}
65
66#[derive(Debug, Clone, Serialize, JsonSchema)]
68#[serde(tag = "resolution", rename_all = "snake_case")]
69pub enum ResumeResolution {
70 Resolved(Box<ResumePack>),
71 Ambiguous {
72 query: String,
73 candidates: Vec<ProjectSummary>,
74 },
75 NotFound {
76 query: String,
77 available: Vec<String>,
78 },
79}
80
81pub fn list_projects(db: &Database) -> Result<Vec<ProjectSummary>, DbError> {
83 let mut agg = db.conn().prepare(
84 r#"
85 SELECT wing,
86 COUNT(*) AS total,
87 SUM(CASE WHEN memory_kind = 'evidence' THEN 1 ELSE 0 END) AS evidence,
88 SUM(CASE WHEN memory_kind = 'knowledge' THEN 1 ELSE 0 END) AS knowledge,
89 MAX(added_at) AS last_activity
90 FROM drawers
91 WHERE deleted_at IS NULL
92 GROUP BY wing
93 "#,
94 )?;
95 let mut summaries: Vec<ProjectSummary> = agg
96 .query_map([], |row| {
97 Ok(ProjectSummary {
98 wing: row.get(0)?,
99 path: None,
100 total: row.get(1)?,
101 evidence: row.get(2)?,
102 knowledge: row.get(3)?,
103 last_activity: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
104 })
105 })?
106 .collect::<std::result::Result<Vec<_>, _>>()?;
107
108 let mut paths = db.conn().prepare(
111 r#"
112 SELECT wing, anchor_id, MAX(added_at)
113 FROM drawers
114 WHERE deleted_at IS NULL
115 AND anchor_kind = 'worktree'
116 AND anchor_id LIKE 'worktree://%'
117 GROUP BY wing
118 "#,
119 )?;
120 let path_map: HashMap<String, String> = paths
121 .query_map([], |row| {
122 let wing: String = row.get(0)?;
123 let anchor: String = row.get(1)?;
124 Ok((wing, anchor))
125 })?
126 .collect::<std::result::Result<Vec<_>, _>>()?
127 .into_iter()
128 .map(|(wing, anchor)| {
129 let path = anchor
130 .strip_prefix(WORKTREE_PREFIX)
131 .unwrap_or(&anchor)
132 .to_string();
133 (wing, path)
134 })
135 .collect();
136
137 for summary in &mut summaries {
138 summary.path = path_map.get(&summary.wing).cloned();
139 }
140
141 summaries.sort_by(|a, b| {
142 b.last_activity
143 .parse::<i64>()
144 .unwrap_or(0)
145 .cmp(&a.last_activity.parse::<i64>().unwrap_or(0))
146 .then_with(|| a.wing.cmp(&b.wing))
147 });
148 Ok(summaries)
149}
150
151pub fn resume_project(
153 db: &Database,
154 query: &str,
155 evidence_limit: usize,
156 candidate_limit: usize,
157) -> Result<ResumeResolution, DbError> {
158 let needle = query.trim().to_lowercase();
159 let projects = list_projects(db)?;
160 let available = || projects.iter().map(|p| p.wing.clone()).collect::<Vec<_>>();
161
162 if needle.is_empty() {
163 return Ok(ResumeResolution::NotFound {
164 query: query.to_string(),
165 available: available(),
166 });
167 }
168
169 let exact: Vec<&ProjectSummary> = projects
171 .iter()
172 .filter(|p| p.wing.to_lowercase() == needle)
173 .collect();
174 let matches: Vec<&ProjectSummary> = if !exact.is_empty() {
175 exact
176 } else {
177 projects
178 .iter()
179 .filter(|p| {
180 p.wing.to_lowercase().contains(&needle) || path_basename_matches(p, &needle)
181 })
182 .collect()
183 };
184
185 match matches.len() {
186 0 => Ok(ResumeResolution::NotFound {
187 query: query.to_string(),
188 available: available(),
189 }),
190 1 => {
191 let p = matches[0].clone();
192 let recent_evidence = recent_evidence(db, &p.wing, evidence_limit)?;
193 let in_flight = candidate_knowledge(db, &p.wing, candidate_limit)?;
194 let next_step = match &p.path {
195 Some(path) => {
196 format!("cd {path} && mempal context \"resume {}\"", p.wing)
197 }
198 None => format!(
199 "project '{}' has no recorded worktree path (legacy drawers); \
200 supply the path, then run mempal context",
201 p.wing
202 ),
203 };
204 Ok(ResumeResolution::Resolved(Box::new(ResumePack {
205 wing: p.wing,
206 path: p.path,
207 last_activity: p.last_activity,
208 total: p.total,
209 evidence: p.evidence,
210 knowledge: p.knowledge,
211 recent_evidence,
212 in_flight,
213 next_step,
214 })))
215 }
216 _ => Ok(ResumeResolution::Ambiguous {
217 query: query.to_string(),
218 candidates: matches.into_iter().cloned().collect(),
219 }),
220 }
221}
222
223fn path_basename_matches(project: &ProjectSummary, needle: &str) -> bool {
224 project
225 .path
226 .as_deref()
227 .and_then(|path| Path::new(path).file_name())
228 .and_then(|name| name.to_str())
229 .map(|name| name.to_lowercase().contains(needle))
230 .unwrap_or(false)
231}
232
233fn recent_evidence(
234 db: &Database,
235 wing: &str,
236 limit: usize,
237) -> Result<Vec<ResumeEvidence>, DbError> {
238 let mut statement = db.conn().prepare(
239 r#"
240 SELECT id, source_file, substr(content, 1, 200), added_at, importance
241 FROM drawers
242 WHERE deleted_at IS NULL AND wing = ?1 AND memory_kind = 'evidence'
243 ORDER BY added_at DESC, importance DESC
244 LIMIT ?2
245 "#,
246 )?;
247 let rows = statement
248 .query_map(rusqlite::params![wing, limit as i64], |row| {
249 Ok(ResumeEvidence {
250 drawer_id: row.get(0)?,
251 source_file: row.get::<_, Option<String>>(1)?,
252 snippet: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
253 added_at: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
254 importance: row.get::<_, Option<i64>>(4)?.unwrap_or(0),
255 })
256 })?
257 .collect::<std::result::Result<Vec<_>, _>>()?;
258 Ok(rows)
259}
260
261fn candidate_knowledge(
262 db: &Database,
263 wing: &str,
264 limit: usize,
265) -> Result<Vec<ResumeCandidate>, DbError> {
266 let mut statement = db.conn().prepare(
267 r#"
268 SELECT id, statement, tier
269 FROM drawers
270 WHERE deleted_at IS NULL AND wing = ?1
271 AND memory_kind = 'knowledge' AND status = 'candidate'
272 ORDER BY added_at DESC
273 LIMIT ?2
274 "#,
275 )?;
276 let rows = statement
277 .query_map(rusqlite::params![wing, limit as i64], |row| {
278 Ok(ResumeCandidate {
279 drawer_id: row.get(0)?,
280 statement: row.get::<_, Option<String>>(1)?,
281 tier: row.get::<_, Option<String>>(2)?,
282 })
283 })?
284 .collect::<std::result::Result<Vec<_>, _>>()?;
285 Ok(rows)
286}