Skip to main content

remem/memory/scope_cleanup/
audit.rs

1use anyhow::Result;
2use rusqlite::{params, Connection};
3use serde::Serialize;
4use std::collections::BTreeMap;
5
6use super::preference_cluster::preference_clusters;
7use super::{ObjectRef, ScopeObjectKind};
8
9const LOW_CONFIDENCE_THRESHOLD: f64 = 0.6;
10
11#[derive(Debug, Clone)]
12pub struct ScopeAuditRequest<'a> {
13    pub project: &'a str,
14    pub limit: i64,
15    pub now_epoch: i64,
16}
17
18#[derive(Debug, Clone, Serialize)]
19pub struct ScopeAuditReport {
20    pub project: String,
21    pub limit: i64,
22    pub likely_correct_repo_memory: Vec<AuditItem>,
23    pub likely_cross_tool_domain_pollution: Vec<AuditItem>,
24    pub duplicate_preferences: Vec<DuplicateCluster>,
25    pub duplicate_workstreams: Vec<DuplicateCluster>,
26    pub stale_temporal_facts: Vec<AuditItem>,
27    pub low_confidence_routing: Vec<AuditItem>,
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct AuditItem {
32    pub object_ref: String,
33    pub object_type: String,
34    pub title: String,
35    pub status: String,
36    pub owner_scope: Option<String>,
37    pub owner_key: Option<String>,
38    pub source_project: Option<String>,
39    pub target_project: Option<String>,
40    pub topic_domain: Option<String>,
41    pub routing_confidence: Option<f64>,
42    pub reason: String,
43    pub suggested_owner_scope: Option<String>,
44    pub suggested_owner_key: Option<String>,
45    pub suggested_target_project: Option<String>,
46    pub suggested_action: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize)]
50pub struct DuplicateCluster {
51    pub cluster_key: String,
52    pub canonical_ref: String,
53    pub refs: Vec<String>,
54    pub reason: String,
55    pub merged_content: Option<String>,
56}
57
58pub fn audit_scope(conn: &Connection, req: &ScopeAuditRequest<'_>) -> Result<ScopeAuditReport> {
59    let limit = req.limit.clamp(1, 500);
60    let memories = load_memory_audit_rows(conn, req.project)?;
61    let workstreams = load_workstream_audit_rows(conn, req.project)?;
62
63    let mut likely_correct_repo_memory = Vec::new();
64    let mut likely_cross_tool_domain_pollution = Vec::new();
65    let mut stale_temporal_facts = Vec::new();
66    let mut low_confidence_routing = Vec::new();
67
68    for row in &memories {
69        if is_low_confidence(row.owner_scope.as_deref(), row.routing_confidence) {
70            low_confidence_routing.push(row.audit_item(
71                "routing confidence is missing or below review threshold",
72                None,
73                Some("review"),
74            ));
75        }
76        if row
77            .expires_at_epoch
78            .is_some_and(|expires| expires <= req.now_epoch)
79            && row.status == "active"
80        {
81            stale_temporal_facts.push(row.audit_item(
82                "active memory is past expires_at_epoch",
83                None,
84                Some("archive"),
85            ));
86        }
87        let routing_blob = row.routing_blob();
88        let suggestion = route_suggestion(&routing_blob);
89        let has_repo_evidence = row.has_strong_repo_evidence(req.project, &routing_blob);
90        if is_repo_owned_for_project(
91            req.project,
92            row.project.as_str(),
93            row.scope.as_deref(),
94            row.owner_scope.as_deref(),
95            row.owner_key.as_deref(),
96            row.target_project.as_deref(),
97        ) {
98            if let Some(suggestion) = suggestion.as_ref().filter(|_| !has_repo_evidence) {
99                likely_cross_tool_domain_pollution.push(row.audit_item(
100                    suggestion.reason,
101                    Some(suggestion),
102                    Some("reroute"),
103                ));
104            } else if suggestion.is_some() && has_repo_evidence {
105                low_confidence_routing.push(row.audit_item(
106                    "repo evidence conflicts with tool/domain routing keywords",
107                    suggestion.as_ref(),
108                    Some("review"),
109                ));
110            } else if row.status == "active"
111                && row.memory_type != "preference"
112                && row
113                    .expires_at_epoch
114                    .is_none_or(|expires| expires > req.now_epoch)
115                && !is_low_confidence(row.owner_scope.as_deref(), row.routing_confidence)
116            {
117                likely_correct_repo_memory.push(row.audit_item(
118                    "repo-owned memory matches the audited project",
119                    None,
120                    Some("keep"),
121                ));
122            }
123        }
124    }
125
126    for row in &workstreams {
127        if is_low_confidence(row.owner_scope.as_deref(), row.routing_confidence) {
128            low_confidence_routing.push(row.audit_item(
129                "routing confidence is missing or below review threshold",
130                None,
131                Some("review"),
132            ));
133        }
134        let routing_blob = row.routing_blob();
135        let suggestion = route_suggestion(&routing_blob);
136        let has_repo_evidence = row.has_strong_repo_evidence(req.project, &routing_blob);
137        if is_repo_owned_for_project(
138            req.project,
139            row.project.as_str(),
140            None,
141            row.owner_scope.as_deref(),
142            row.owner_key.as_deref(),
143            row.target_project.as_deref(),
144        ) {
145            if let Some(suggestion) = suggestion.as_ref().filter(|_| !has_repo_evidence) {
146                likely_cross_tool_domain_pollution.push(row.audit_item(
147                    suggestion.reason,
148                    Some(suggestion),
149                    Some("pause"),
150                ));
151            } else if suggestion.is_some() && has_repo_evidence {
152                low_confidence_routing.push(row.audit_item(
153                    "repo evidence conflicts with tool/domain routing keywords",
154                    suggestion.as_ref(),
155                    Some("review"),
156                ));
157            } else if row.status == "active"
158                && !is_low_confidence(row.owner_scope.as_deref(), row.routing_confidence)
159            {
160                likely_correct_repo_memory.push(row.audit_item(
161                    "repo-owned workstream matches the audited project",
162                    None,
163                    Some("keep"),
164                ));
165            }
166        }
167    }
168
169    Ok(ScopeAuditReport {
170        project: req.project.to_string(),
171        limit,
172        likely_correct_repo_memory: take_limit(likely_correct_repo_memory, limit),
173        likely_cross_tool_domain_pollution: take_limit(likely_cross_tool_domain_pollution, limit),
174        duplicate_preferences: take_limit(preference_clusters(&memories, req.project), limit),
175        duplicate_workstreams: take_limit(workstream_clusters(&workstreams), limit),
176        stale_temporal_facts: take_limit(stale_temporal_facts, limit),
177        low_confidence_routing: take_limit(low_confidence_routing, limit),
178    })
179}
180
181#[derive(Debug, Clone)]
182struct RouteSuggestion {
183    owner_scope: &'static str,
184    owner_key: &'static str,
185    target_project: Option<String>,
186    reason: &'static str,
187}
188
189fn route_suggestion(blob: &str) -> Option<RouteSuggestion> {
190    if contains_any(
191        blob,
192        &[
193            "codex",
194            "workspace-write",
195            "approval",
196            "sandbox",
197            "mcp config",
198            "codex cli",
199        ],
200    ) {
201        return Some(RouteSuggestion {
202            owner_scope: "tool",
203            owner_key: "codex-cli",
204            target_project: None,
205            reason: "content is about Codex CLI sandbox, approvals, or runtime",
206        });
207    }
208    if contains_any(blob, &["grok", "xai", "x.ai"]) {
209        return Some(RouteSuggestion {
210            owner_scope: "domain",
211            owner_key: "grok-api",
212            target_project: None,
213            reason: "content is about Grok/xAI API rather than this repo",
214        });
215    }
216    if contains_any(
217        blob,
218        &["warp", "macos", "tcc", "app routing", "terminal launch"],
219    ) {
220        return Some(RouteSuggestion {
221            owner_scope: "domain",
222            owner_key: "macos",
223            target_project: None,
224            reason: "content is about macOS or terminal routing rather than this repo",
225        });
226    }
227    if contains_any(blob, &["hermes"]) {
228        return Some(RouteSuggestion {
229            owner_scope: "domain",
230            owner_key: "hermes",
231            target_project: None,
232            reason: "content is about Hermes rather than this repo",
233        });
234    }
235    None
236}
237
238fn contains_any(haystack: &str, needles: &[&str]) -> bool {
239    needles.iter().any(|needle| haystack.contains(needle))
240}
241
242fn is_repo_owned_for_project(
243    project: &str,
244    legacy_project: &str,
245    scope: Option<&str>,
246    owner_scope: Option<&str>,
247    owner_key: Option<&str>,
248    target_project: Option<&str>,
249) -> bool {
250    match owner_scope {
251        Some("repo") => owner_key == Some(project) || target_project == Some(project),
252        Some(_) => false,
253        None => legacy_project == project && scope.unwrap_or("project") != "global",
254    }
255}
256
257fn is_low_confidence(owner_scope: Option<&str>, confidence: Option<f64>) -> bool {
258    owner_scope.is_none() || confidence.is_none_or(|value| value < LOW_CONFIDENCE_THRESHOLD)
259}
260
261fn take_limit<T>(mut values: Vec<T>, limit: i64) -> Vec<T> {
262    values.truncate(limit as usize);
263    values
264}
265
266#[derive(Debug, Clone)]
267pub(super) struct MemoryAuditRow {
268    pub(super) id: i64,
269    pub(super) project: String,
270    pub(super) topic_key: Option<String>,
271    pub(super) title: String,
272    pub(super) content: String,
273    pub(super) memory_type: String,
274    pub(super) status: String,
275    pub(super) scope: Option<String>,
276    pub(super) source_project: Option<String>,
277    pub(super) target_project: Option<String>,
278    pub(super) owner_scope: Option<String>,
279    pub(super) owner_key: Option<String>,
280    pub(super) topic_domain: Option<String>,
281    pub(super) routing_confidence: Option<f64>,
282    pub(super) context_class: Option<String>,
283    pub(super) expires_at_epoch: Option<i64>,
284    pub(super) updated_at_epoch: i64,
285    pub(super) state_key: Option<String>,
286    pub(super) current_memory_id: Option<i64>,
287}
288
289impl MemoryAuditRow {
290    fn object_ref(&self) -> ObjectRef {
291        ObjectRef::memory(self.id)
292    }
293
294    fn routing_blob(&self) -> String {
295        format!(
296            "{} {} {} {}",
297            self.title,
298            self.content,
299            self.topic_domain.as_deref().unwrap_or_default(),
300            self.context_class.as_deref().unwrap_or_default()
301        )
302        .to_ascii_lowercase()
303    }
304
305    fn has_strong_repo_evidence(&self, project: &str, routing_blob: &str) -> bool {
306        has_repo_evidence(project, routing_blob, self.topic_domain.as_deref())
307    }
308
309    fn audit_item(
310        &self,
311        reason: &str,
312        suggestion: Option<&RouteSuggestion>,
313        suggested_action: Option<&str>,
314    ) -> AuditItem {
315        AuditItem {
316            object_ref: self.object_ref().to_string(),
317            object_type: ScopeObjectKind::Memory.as_str().to_string(),
318            title: self.title.clone(),
319            status: self.status.clone(),
320            owner_scope: self.owner_scope.clone(),
321            owner_key: self.owner_key.clone(),
322            source_project: self.source_project.clone(),
323            target_project: self.target_project.clone(),
324            topic_domain: self.topic_domain.clone(),
325            routing_confidence: self.routing_confidence,
326            reason: reason.to_string(),
327            suggested_owner_scope: suggestion.map(|value| value.owner_scope.to_string()),
328            suggested_owner_key: suggestion.map(|value| value.owner_key.to_string()),
329            suggested_target_project: suggestion.and_then(|value| value.target_project.clone()),
330            suggested_action: suggested_action.map(str::to_string),
331        }
332    }
333}
334
335pub(super) fn load_memory_audit_rows(
336    conn: &Connection,
337    project: &str,
338) -> Result<Vec<MemoryAuditRow>> {
339    let mut stmt = conn.prepare(
340        "SELECT m.id, m.project, m.topic_key, m.title, m.content, m.memory_type,
341                m.status, m.scope, m.source_project, m.target_project, m.owner_scope,
342                m.owner_key, m.topic_domain, m.routing_confidence, m.context_class,
343                m.expires_at_epoch, m.updated_at_epoch, sk.state_key, sk.current_memory_id
344         FROM memories m
345         LEFT JOIN memory_state_keys sk ON sk.id = m.state_key_id
346         WHERE m.project = ?1
347            OR m.source_project = ?1
348            OR m.target_project = ?1
349            OR (m.owner_scope = 'repo' AND m.owner_key = ?1)
350         ORDER BY m.updated_at_epoch DESC, m.id DESC",
351    )?;
352    let rows = stmt.query_map(params![project], |row| {
353        Ok(MemoryAuditRow {
354            id: row.get(0)?,
355            project: row.get(1)?,
356            topic_key: row.get(2)?,
357            title: row.get(3)?,
358            content: row.get(4)?,
359            memory_type: row.get(5)?,
360            status: row.get(6)?,
361            scope: row.get(7)?,
362            source_project: row.get(8)?,
363            target_project: row.get(9)?,
364            owner_scope: row.get(10)?,
365            owner_key: row.get(11)?,
366            topic_domain: row.get(12)?,
367            routing_confidence: row.get(13)?,
368            context_class: row.get(14)?,
369            expires_at_epoch: row.get(15)?,
370            updated_at_epoch: row.get(16)?,
371            state_key: row.get(17)?,
372            current_memory_id: row.get(18)?,
373        })
374    })?;
375    crate::db::query::collect_rows(rows)
376}
377
378#[derive(Debug, Clone)]
379struct WorkstreamAuditRow {
380    id: i64,
381    project: String,
382    title: String,
383    status: String,
384    progress: Option<String>,
385    next_action: Option<String>,
386    blockers: Option<String>,
387    source_project: Option<String>,
388    target_project: Option<String>,
389    owner_scope: Option<String>,
390    owner_key: Option<String>,
391    topic_domain: Option<String>,
392    routing_confidence: Option<f64>,
393    context_class: Option<String>,
394}
395
396impl WorkstreamAuditRow {
397    fn object_ref(&self) -> ObjectRef {
398        ObjectRef {
399            kind: ScopeObjectKind::Workstream,
400            id: self.id,
401        }
402    }
403
404    fn routing_blob(&self) -> String {
405        format!(
406            "{} {} {} {} {} {}",
407            self.title,
408            self.progress.as_deref().unwrap_or_default(),
409            self.next_action.as_deref().unwrap_or_default(),
410            self.blockers.as_deref().unwrap_or_default(),
411            self.topic_domain.as_deref().unwrap_or_default(),
412            self.context_class.as_deref().unwrap_or_default()
413        )
414        .to_ascii_lowercase()
415    }
416
417    fn has_strong_repo_evidence(&self, project: &str, routing_blob: &str) -> bool {
418        has_repo_evidence(project, routing_blob, self.topic_domain.as_deref())
419    }
420
421    fn audit_item(
422        &self,
423        reason: &str,
424        suggestion: Option<&RouteSuggestion>,
425        suggested_action: Option<&str>,
426    ) -> AuditItem {
427        AuditItem {
428            object_ref: self.object_ref().to_string(),
429            object_type: ScopeObjectKind::Workstream.as_str().to_string(),
430            title: self.title.clone(),
431            status: self.status.clone(),
432            owner_scope: self.owner_scope.clone(),
433            owner_key: self.owner_key.clone(),
434            source_project: self.source_project.clone(),
435            target_project: self.target_project.clone(),
436            topic_domain: self.topic_domain.clone(),
437            routing_confidence: self.routing_confidence,
438            reason: reason.to_string(),
439            suggested_owner_scope: suggestion.map(|value| value.owner_scope.to_string()),
440            suggested_owner_key: suggestion.map(|value| value.owner_key.to_string()),
441            suggested_target_project: suggestion.and_then(|value| value.target_project.clone()),
442            suggested_action: suggested_action.map(str::to_string),
443        }
444    }
445}
446
447fn load_workstream_audit_rows(conn: &Connection, project: &str) -> Result<Vec<WorkstreamAuditRow>> {
448    let mut stmt = conn.prepare(
449        "SELECT id, project, title, status, progress, next_action, blockers,
450                source_project, target_project, owner_scope, owner_key, topic_domain,
451                routing_confidence, context_class
452         FROM workstreams
453         WHERE project = ?1
454            OR source_project = ?1
455            OR target_project = ?1
456            OR (owner_scope = 'repo' AND owner_key = ?1)
457         ORDER BY updated_at_epoch DESC, id DESC",
458    )?;
459    let rows = stmt.query_map(params![project], |row| {
460        Ok(WorkstreamAuditRow {
461            id: row.get(0)?,
462            project: row.get(1)?,
463            title: row.get(2)?,
464            status: row.get(3)?,
465            progress: row.get(4)?,
466            next_action: row.get(5)?,
467            blockers: row.get(6)?,
468            source_project: row.get(7)?,
469            target_project: row.get(8)?,
470            owner_scope: row.get(9)?,
471            owner_key: row.get(10)?,
472            topic_domain: row.get(11)?,
473            routing_confidence: row.get(12)?,
474            context_class: row.get(13)?,
475        })
476    })?;
477    crate::db::query::collect_rows(rows)
478}
479
480fn workstream_clusters(rows: &[WorkstreamAuditRow]) -> Vec<DuplicateCluster> {
481    let mut groups: BTreeMap<String, Vec<&WorkstreamAuditRow>> = BTreeMap::new();
482    for row in rows {
483        if row.status != "active" {
484            continue;
485        }
486        let key = workstream_cluster_key(&row.title);
487        groups.entry(key).or_default().push(row);
488    }
489    groups
490        .into_iter()
491        .filter_map(|(key, mut members)| {
492            if key == "unique" || members.len() < 2 {
493                return None;
494            }
495            members.sort_by_key(|row| row.id);
496            let canonical = members.first().copied()?;
497            Some(DuplicateCluster {
498                cluster_key: key,
499                canonical_ref: canonical.object_ref().to_string(),
500                refs: members
501                    .iter()
502                    .map(|row| row.object_ref().to_string())
503                    .collect(),
504                reason: "active workstreams appear to track the same task".to_string(),
505                merged_content: None,
506            })
507        })
508        .collect()
509}
510
511fn workstream_cluster_key(title: &str) -> String {
512    let text = normalize_text(title);
513    if text.contains("stash") && text.contains("sidebar") && text.contains("polish") {
514        return "stash-sidebar-polish".to_string();
515    }
516    "unique".to_string()
517}
518
519fn normalize_text(value: &str) -> String {
520    value
521        .chars()
522        .map(|ch| {
523            if ch.is_ascii_alphanumeric() {
524                ch.to_ascii_lowercase()
525            } else if ch.is_alphanumeric() {
526                ch
527            } else {
528                ' '
529            }
530        })
531        .collect::<String>()
532        .split_whitespace()
533        .collect::<Vec<_>>()
534        .join(" ")
535}
536
537fn has_repo_evidence(project: &str, routing_blob: &str, topic_domain: Option<&str>) -> bool {
538    let slug = project
539        .rsplit('/')
540        .next()
541        .unwrap_or(project)
542        .to_ascii_lowercase();
543    let project_lower = project.to_ascii_lowercase();
544    topic_domain
545        .map(|domain| domain.to_ascii_lowercase().starts_with(&slug))
546        .unwrap_or(false)
547        || routing_blob.contains(&slug)
548        || routing_blob.contains(&project_lower)
549}