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}