Skip to main content

mempal_runtime/
projects.rs

1//! P109: cross-project resume.
2//!
3//! mempal stores every project's memory in one global `palace.db` keyed by
4//! `wing`, and each project's absolute worktree path lives in its
5//! `worktree://` anchor_id. This module surfaces that so an agent can, from any
6//! directory, list known projects and resume one by fuzzy name. Everything here
7//! is deterministic, embedder-free, and read-only — no LLM, no writes.
8
9use 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/// One project's at-a-glance summary, derived from its drawers.
20#[derive(Debug, Clone, Serialize, JsonSchema)]
21pub struct ProjectSummary {
22    pub wing: String,
23    /// Absolute worktree path from the latest `worktree://` anchor, or null when
24    /// the project only has legacy `repo://legacy` anchors.
25    pub path: Option<String>,
26    /// Epoch-seconds (string) of the most recent drawer in this wing.
27    pub last_activity: String,
28    pub total: i64,
29    pub evidence: i64,
30    pub knowledge: i64,
31}
32
33/// A recent evidence drawer, for the resume pack.
34#[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/// An in-flight candidate knowledge drawer (distilled but not yet promoted).
44#[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/// Everything an agent needs to pick a project back up.
52#[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    /// Concrete next step, e.g. `cd <path> && mempal context "resume <wing>"`.
63    pub next_step: String,
64}
65
66/// Outcome of resolving a fuzzy resume query.
67#[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
81/// List every project (wing) mempal knows, newest activity first.
82pub 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    // Latest worktree path per wing. With MAX(added_at), SQLite returns the
109    // anchor_id from the row holding that max — i.e. the most recent worktree.
110    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
151/// Resolve a fuzzy project name and, on a unique match, build a resume pack.
152pub 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    // An exact wing match wins over substring matches.
170    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}