1use crate::confidence::{self, Band};
14use crate::config::{InjectMode, Strength};
15use crate::index::{Entry, Index};
16use std::fs;
17
18#[derive(Clone, Debug)]
23pub struct Rec {
24 pub id: String,
25 pub confidence: f32,
26 pub why: Option<String>,
32}
33
34pub fn build(
38 recs: &[Rec],
39 index: &Index,
40 mode: InjectMode,
41 strength: Strength,
42 char_budget: usize,
43) -> (String, Vec<String>) {
44 let mut blocks: Vec<String> = Vec::new();
45 let mut ids: Vec<String> = Vec::new();
46 let mut used = 0usize;
47
48 for r in recs {
49 let Some(entry) = index.get(&r.id) else {
50 continue;
51 };
52 let block = match mode {
53 InjectMode::Directive => {
54 directive_block(entry, strength, r.confidence, r.why.as_deref())
55 }
56 InjectMode::Body => body_block(entry),
57 };
58 if !blocks.is_empty() && used + block.len() > char_budget {
59 break;
60 }
61 used += block.len();
62 blocks.push(block);
63 ids.push(r.id.clone());
64 }
65
66 if blocks.is_empty() {
67 return (String::new(), ids);
68 }
69
70 let header = match mode {
71 InjectMode::Directive => {
72 "ski matched these skills to your request — a dedicated retrieval+rerank pass, \
79 separate from and complementary to the host's own skill selection. Invoke \
80 fitting ones by name via the `Skill` tool; do not Read the files. Prefer \
81 invoking a matching skill over doing its task by hand; skip a \
82 recommendation only if it clearly does not apply:"
83 }
84 InjectMode::Body => "Skill instructions relevant to this request are included below:",
85 };
86 (format!("{header}\n\n{}", blocks.join("\n\n")), ids)
87}
88
89fn directive_block(
124 entry: &Entry,
125 strength: Strength,
126 confidence: f32,
127 why: Option<&str>,
128) -> String {
129 let verb = match (strength, confidence::band(confidence)) {
130 (Strength::Hard, Band::High) => "you MUST invoke it before responding.",
131 (Strength::Hard, _) => "you should invoke it before responding.",
132 (_, _) => "invoke it now, before you respond.",
133 };
134 match why {
135 Some(why) => format!(
136 "- SkillRecommendation(`{}`): {} [matched because {why}] — {}",
137 entry.name, entry.description, verb
138 ),
139 None => format!(
140 "- SkillRecommendation(`{}`): {} — {}",
141 entry.name, entry.description, verb
142 ),
143 }
144}
145
146fn body_block(entry: &Entry) -> String {
147 let body = fs::read_to_string(&entry.path)
148 .map(|c| strip_frontmatter(&c).to_string())
149 .unwrap_or_else(|_| entry.description.clone());
150 format!("<skill name=\"{}\">\n{}\n</skill>", entry.name, body.trim())
151}
152
153fn strip_frontmatter(content: &str) -> &str {
155 let trimmed = content.trim_start();
156 let Some(rest) = trimmed.strip_prefix("---") else {
157 return content;
158 };
159 if !rest.starts_with('\n') && !rest.starts_with("\r\n") {
161 return content;
162 }
163 match rest.find("\n---") {
164 Some(end) => {
165 let after = &rest[end + "\n---".len()..];
166 after
168 .find('\n')
169 .map(|nl| after[nl + 1..].trim_start_matches(['\n', '\r']))
170 .unwrap_or("")
171 }
172 None => content,
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn entry(id: &str, name: &str, path: &str) -> Entry {
181 Entry {
182 id: id.to_string(),
183 name: name.to_string(),
184 description: "does a thing".to_string(),
185 path: path.to_string(),
186 keywords: vec![],
187 trigger_phrases: vec![],
188 body_head: String::new(),
189 hash: "0".to_string(),
190 embedding: vec![],
191 }
192 }
193
194 fn index_of(entries: Vec<Entry>) -> Index {
195 Index {
196 model: "test".to_string(),
197 dim: 0,
198 skills: entries,
199 }
200 }
201
202 fn rec(id: &str, confidence: f32) -> Rec {
203 Rec {
204 id: id.to_string(),
205 confidence,
206 why: None,
207 }
208 }
209
210 #[test]
211 fn directive_carries_evidence_when_present() {
212 let idx = index_of(vec![entry("a", "alpha", "/p/SKILL.md")]);
213 let with_why = Rec {
214 why: Some("this workspace is a uv project (uv.lock)".to_string()),
215 ..rec("a", 0.9)
216 };
217 let (text, _) = build(
218 &[with_why],
219 &idx,
220 InjectMode::Directive,
221 Strength::Soft,
222 6000,
223 );
224 assert!(
225 text.contains("[matched because this workspace is a uv project (uv.lock)]"),
226 "{text}"
227 );
228 let (text, _) = build(
230 &[rec("a", 0.9)],
231 &idx,
232 InjectMode::Directive,
233 Strength::Soft,
234 6000,
235 );
236 assert!(!text.contains("matched because"), "{text}");
237 }
238
239 #[test]
240 fn directive_soft_vs_hard() {
241 let idx = index_of(vec![entry("a", "alpha", "/p/SKILL.md")]);
242 let (soft, _) = build(
243 &[rec("a", 0.91)],
244 &idx,
245 InjectMode::Directive,
246 Strength::Soft,
247 6000,
248 );
249 let (hard, _) = build(
250 &[rec("a", 0.91)],
251 &idx,
252 InjectMode::Directive,
253 Strength::Hard,
254 6000,
255 );
256 assert!(soft.contains("SkillRecommendation(`alpha`)"));
258 assert!(!soft.contains("0.91"));
259 assert!(!soft.contains("/p/SKILL.md"));
260 assert!(!soft.contains("MUST"));
261 assert!(hard.contains("MUST")); }
263
264 #[test]
265 fn directive_soft_is_firm_regardless_of_band() {
266 let idx = index_of(vec![entry("a", "alpha", "/p/SKILL.md")]);
267 let soft = |c| {
268 build(
269 &[rec("a", c)],
270 &idx,
271 InjectMode::Directive,
272 Strength::Soft,
273 6000,
274 )
275 .0
276 };
277 for c in [0.95_f32, 0.70, 0.40] {
281 let line = soft(c);
282 assert!(
283 line.contains("invoke it now, before you respond."),
284 "c={c}: {line}"
285 );
286 assert!(!line.contains("consider"), "c={c}: {line}");
287 }
288 }
289
290 #[test]
291 fn directive_hard_scales_with_high_band() {
292 let idx = index_of(vec![entry("a", "alpha", "/p/SKILL.md")]);
293 let hard = |c| {
294 build(
295 &[rec("a", c)],
296 &idx,
297 InjectMode::Directive,
298 Strength::Hard,
299 6000,
300 )
301 .0
302 };
303 assert!(hard(0.95).contains("you MUST invoke it before responding."));
304 assert!(!hard(0.40).contains("MUST"));
305 assert!(hard(0.40).contains("you should invoke it before responding."));
306 }
307
308 #[test]
309 fn char_budget_caps_but_allows_first() {
310 let idx = index_of(vec![
311 entry("a", "alpha", "/p/a/SKILL.md"),
312 entry("b", "bravo", "/p/b/SKILL.md"),
313 ]);
314 let (text, ids) = build(
316 &[rec("a", 0.9), rec("b", 0.9)],
317 &idx,
318 InjectMode::Directive,
319 Strength::Soft,
320 1,
321 );
322 assert_eq!(ids, ["a"]);
323 assert!(text.contains("alpha") && !text.contains("bravo"));
324 }
325
326 #[test]
327 fn unknown_id_skipped() {
328 let idx = index_of(vec![entry("a", "alpha", "/p/SKILL.md")]);
329 let (_, ids) = build(
330 &[rec("missing", 0.9), rec("a", 0.9)],
331 &idx,
332 InjectMode::Directive,
333 Strength::Soft,
334 6000,
335 );
336 assert_eq!(ids, ["a"]);
337 }
338
339 #[test]
340 fn empty_recs_yield_empty() {
341 let idx = index_of(vec![]);
342 let (text, ids) = build(&[], &idx, InjectMode::Directive, Strength::Soft, 6000);
343 assert!(text.is_empty() && ids.is_empty());
344 }
345
346 #[test]
347 fn strip_frontmatter_removes_yaml() {
348 let md = "---\nname: x\ndescription: y\n---\n\nReal body here.\n";
349 assert_eq!(strip_frontmatter(md), "Real body here.\n");
350 }
351
352 #[test]
353 fn strip_frontmatter_passthrough_without_block() {
354 let md = "no frontmatter\njust text\n";
355 assert_eq!(strip_frontmatter(md), md);
356 }
357
358 #[test]
359 fn strip_frontmatter_handles_unterminated() {
360 let md = "---\nname: x\nno closing fence\n";
361 assert_eq!(strip_frontmatter(md), md);
362 }
363}