1use std::collections::HashMap;
7
8use crate::core::agents::{AgentDiary, DiaryEntryType};
9use crate::core::knowledge::ProjectKnowledge;
10
11#[derive(Debug, Clone)]
14pub struct SkillCandidate {
15 pub slug: String,
17 pub title: String,
19 pub body: String,
21 pub category: String,
23 pub recurrence: u32,
25 pub confidence: f32,
27 pub sources: Vec<String>,
29 pub derivation: Derivation,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct Derivation {
39 pub store: String,
41 pub facts: Vec<(String, String, String)>,
43 pub unbound: bool,
46}
47
48impl Derivation {
49 fn of_fact(store: &str, category: &str, key: &str, value: &str) -> Self {
50 Self {
51 store: store.to_owned(),
52 facts: vec![(category.to_owned(), key.to_owned(), value.to_owned())],
53 unbound: false,
54 }
55 }
56
57 fn unbound() -> Self {
58 Self {
59 unbound: true,
60 ..Self::default()
61 }
62 }
63
64 fn join(&mut self, other: Self) {
66 if self.store.is_empty() {
67 self.store = other.store;
68 }
69 self.unbound |= other.unbound;
70 for fact in other.facts {
71 if !self.facts.contains(&fact) {
72 self.facts.push(fact);
73 }
74 }
75 }
76}
77
78fn knowledge_category_is_skillable(cat: &str) -> bool {
81 matches!(
82 cat.to_ascii_lowercase().as_str(),
83 "decision" | "insight" | "gotcha" | "pattern" | "convention" | "preference"
84 )
85}
86
87pub fn mine_candidates(project_root: &str) -> Vec<SkillCandidate> {
90 let mut by_slug: HashMap<String, SkillCandidate> = HashMap::new();
91
92 let knowledge = ProjectKnowledge::load_or_create(project_root);
96 let deliverable = knowledge.delivery_filter();
97 for fact in &knowledge.facts {
98 if !knowledge_category_is_skillable(&fact.category)
101 || !deliverable(fact)
102 || fact.hive_origin().is_some()
103 {
104 continue;
105 }
106 let title = title_from(&fact.key, &fact.value);
107 let slug = slugify(&title);
108 if slug.is_empty() {
109 continue;
110 }
111 let sources: Vec<String> = if fact.source_session.is_empty() {
112 Vec::new()
113 } else {
114 vec![fact.source_session.clone()]
115 };
116 merge_candidate(
117 &mut by_slug,
118 SkillCandidate {
119 slug,
120 title,
121 body: fact.value.trim().to_string(),
122 category: fact.category.to_ascii_lowercase(),
123 recurrence: fact.confirmation_count.max(1),
125 confidence: fact.confidence,
126 sources,
127 derivation: Derivation::of_fact(
128 &knowledge.project_root,
129 &fact.category,
130 &fact.key,
131 &fact.value,
132 ),
133 },
134 );
135 }
136
137 for diary in AgentDiary::load_all_for_project(project_root) {
139 for entry in &diary.entries {
140 let category = match entry.entry_type {
141 DiaryEntryType::Decision => "decision",
142 DiaryEntryType::Insight => "insight",
143 DiaryEntryType::Discovery => "discovery",
144 DiaryEntryType::Progress | DiaryEntryType::Blocker => continue,
145 };
146 let body = entry.content.trim().to_string();
147 if body.is_empty() {
148 continue;
149 }
150 let title = title_from("", &body);
151 let slug = slugify(&title);
152 if slug.is_empty() {
153 continue;
154 }
155 merge_candidate(
156 &mut by_slug,
157 SkillCandidate {
158 slug,
159 title,
160 body,
161 category: category.to_string(),
162 recurrence: 1,
163 confidence: 0.6,
164 sources: vec![diary.agent_id.clone()],
165 derivation: Derivation::unbound(),
166 },
167 );
168 }
169 }
170
171 let mut out: Vec<SkillCandidate> = by_slug.into_values().collect();
172 out.sort_by(|a, b| {
173 b.recurrence
174 .cmp(&a.recurrence)
175 .then(b.confidence.total_cmp(&a.confidence))
176 .then(a.slug.cmp(&b.slug))
177 });
178 out
179}
180
181fn merge_candidate(map: &mut HashMap<String, SkillCandidate>, cand: SkillCandidate) {
182 if let Some(existing) = map.get_mut(&cand.slug) {
183 existing.recurrence = existing.recurrence.saturating_add(cand.recurrence);
184 existing.derivation.join(cand.derivation);
185 if cand.confidence > existing.confidence {
186 existing.confidence = cand.confidence;
187 }
188 if cand.body.len() > existing.body.len() {
190 existing.body = cand.body;
191 }
192 for s in cand.sources {
193 if !s.is_empty() && !existing.sources.contains(&s) {
194 existing.sources.push(s);
195 }
196 }
197 } else {
198 map.insert(cand.slug.clone(), cand);
199 }
200}
201
202fn title_from(key: &str, value: &str) -> String {
204 let k = key.trim();
205 if !k.is_empty() && k.chars().count() <= 80 {
206 return k.to_string();
207 }
208 first_sentence(value)
209}
210
211fn first_sentence(text: &str) -> String {
212 let t = text.trim();
213 let end = t.find(['.', '\n', '!', '?']).unwrap_or(t.len());
214 let candidate = t[..end].trim();
215 let s = if candidate.is_empty() { t } else { candidate };
216 truncate_chars(s, 80)
217}
218
219pub(crate) fn slugify(text: &str) -> String {
221 let mut slug = String::new();
222 let mut prev_dash = false;
223 for ch in text.trim().to_ascii_lowercase().chars() {
224 if ch.is_ascii_alphanumeric() {
225 slug.push(ch);
226 prev_dash = false;
227 } else if !slug.is_empty() && !prev_dash {
228 slug.push('-');
229 prev_dash = true;
230 }
231 }
232 let trimmed = slug.trim_matches('-');
233 truncate_chars(trimmed, 50).trim_matches('-').to_string()
234}
235
236fn truncate_chars(s: &str, max: usize) -> String {
237 if s.chars().count() <= max {
238 return s.to_string();
239 }
240 s.chars().take(max).collect()
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn slugify_normalizes() {
249 assert_eq!(slugify("Stop before Build!"), "stop-before-build");
250 assert_eq!(slugify(" multiple spaces "), "multiple-spaces");
251 assert_eq!(slugify("___weird@@@chars###"), "weird-chars");
252 assert_eq!(slugify(""), "");
253 }
254
255 #[test]
256 fn first_sentence_truncates() {
257 assert_eq!(
258 first_sentence("Use atomic writes. And more."),
259 "Use atomic writes"
260 );
261 assert!(first_sentence(&"x".repeat(200)).chars().count() <= 80);
262 }
263
264 #[test]
265 fn merge_sums_recurrence_and_keeps_strongest() {
266 let mut map = HashMap::new();
267 merge_candidate(
268 &mut map,
269 SkillCandidate {
270 slug: "s".into(),
271 title: "t".into(),
272 body: "short".into(),
273 category: "decision".into(),
274 recurrence: 1,
275 confidence: 0.5,
276 sources: vec!["a".into()],
277 derivation: Derivation::default(),
278 },
279 );
280 merge_candidate(
281 &mut map,
282 SkillCandidate {
283 slug: "s".into(),
284 title: "t".into(),
285 body: "a much longer body".into(),
286 category: "decision".into(),
287 recurrence: 2,
288 confidence: 0.9,
289 sources: vec!["b".into()],
290 derivation: Derivation::default(),
291 },
292 );
293 let c = &map["s"];
294 assert_eq!(c.recurrence, 3);
295 assert_eq!(c.confidence, 0.9);
296 assert_eq!(c.body, "a much longer body");
297 assert_eq!(c.sources, vec!["a".to_string(), "b".to_string()]);
298 }
299
300 #[test]
302 fn a_fact_that_is_not_served_is_not_mined() {
303 use crate::core::context_admission::stored::lineage_support::{
304 BOUND_FACT, TYPED_FACT, governed, project, scoped, two_facts, withdraw_source,
305 };
306 let _data = governed();
307 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
308 let (_directory, root) = project();
309 two_facts(&root, "decision");
310 let bodies = || -> Vec<String> {
311 scoped(&root, || mine_candidates(&root))
312 .into_iter()
313 .map(|candidate| candidate.body)
314 .collect()
315 };
316 let before = bodies();
317 assert!(before.contains(&BOUND_FACT.to_owned()), "{before:?}");
318 assert!(before.contains(&TYPED_FACT.to_owned()), "{before:?}");
319 withdraw_source(&root);
320 assert_eq!(bodies(), [TYPED_FACT.to_owned()]);
321 }
322}