Skip to main content

ski/
inject.rs

1//! Turn ranked hits into the text injected into the model's context.
2//!
3//! Two shapes (`Config::inject_mode`):
4//! - **directive** — a short pointer to the skill (name + description + path)
5//!   that tells the model to invoke it via the `Skill` tool, not to read the
6//!   file. Forcefulness set by [`Strength`].
7//! - **body** — the `SKILL.md` content inlined directly, no model agency.
8//!
9//! Either way the total stays under `char_budget`: blocks are added until the
10//! next one would overflow (the first block is always allowed so a single large
11//! skill still gets injected).
12
13use crate::confidence::{self, Band};
14use crate::config::{InjectMode, Strength};
15use crate::index::{Entry, Index};
16use std::fs;
17
18/// A skill chosen for injection: its id, the confidence we'll display, and an
19/// optional one-line evidence note (*why* this skill was surfaced — a referenced
20/// file, the workspace's ecosystem). The hook computes these (stage-appropriate
21/// confidence + dedup) and hands them to [`build`]; tests construct them directly.
22#[derive(Clone, Debug)]
23pub struct Rec {
24    pub id: String,
25    pub confidence: f32,
26    /// Concrete grounds for the recommendation, shown to the model. A directive
27    /// backed by observable evidence ("this workspace is a uv project") is harder
28    /// to dismiss than a bare assertion of relevance — and gives the model exactly
29    /// what it needs to judge fit for itself. `None` when the match is purely
30    /// semantic.
31    pub why: Option<String>,
32}
33
34/// Build the injection text for `recs` and return it alongside the ids actually
35/// injected (after the char budget is applied). `strength` must already be
36/// resolved (not [`Strength::Auto`]); `Auto` is treated as `Soft`.
37pub 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            // The dominant host failure mode is not picking the *wrong* skill, it
73            // is hand-rolling a task a skill already covers (see the recall-gap
74            // probe in `directive_block`'s docs). So the header states the
75            // decision rule outright: invoking a fitting skill beats doing the
76            // task by hand, and the only reason to skip one is clear irrelevance
77            // — which keeps the model's trust on prompts where ski over-surfaced.
78            "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
89/// One directive line: a distinctive `SkillRecommendation(name)` token, the
90/// description, then an imperative verb (hardest under [`Strength::Hard`] for
91/// weak local choosers).
92///
93/// The raw confidence is deliberately **not** shown to the model: every line
94/// here has already cleared a precision gate, so a bare decimal (`0.36`) only
95/// invites the model to anchor on it and discount a genuine match. The exact
96/// value still rides into telemetry via [`Rec::confidence`] for calibration.
97///
98/// The verb does **not** soften with low confidence. A controlled probe against
99/// the real host (see `[[ski-host-recall-gap]]`) showed that timid phrasing
100/// ("consider invoking it") is ignored 0/3 *even when the skill is the right
101/// one*, while "invoke it now, before you respond." is acted on 2/3..3/3 — and a
102/// strong host ignores false injects regardless of how firmly they're phrased
103/// (3/3), so there is no precision benefit to hedging. If ski cleared the floor
104/// and injected, it asks firmly.
105///
106/// `why`, when present, is inlined as the recommendation's evidence ("this
107/// workspace is a uv project"): concrete, checkable grounds the model can
108/// verify against the request.
109///
110/// Probe-measured (see `scripts/probe-compliance.py`, corrected 2026-07-03
111/// runs with per-run fixture isolation — earlier batches shared a mutable
112/// fixture and overstated some effects). What held up: the *directive itself*
113/// closes the invocation gap exactly where it matters — at a 50-skill menu on
114/// indirect prompts the baseline invoked 6/9 (1/3 on the conceptual rust
115/// match) while either directive form restored 9/9 — and firm phrasing caused
116/// zero wrong-skill invocations across ~150 runs. What did NOT hold up: the
117/// evidence clause's apparent immediacy edge over the bare directive
118/// disappeared under fixture isolation (invoked 9/9 both; first-action 6/9
119/// bare vs 5/9 evidence). The clause is kept because it has never harmed a
120/// run and it makes the injection transparent/checkable, not because of a
121/// measured compliance lift. Re-run the probe before tuning against any of
122/// these numbers.
123fn 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
153/// Drop a leading `--- ... ---` YAML frontmatter block, returning the body.
154fn strip_frontmatter(content: &str) -> &str {
155    let trimmed = content.trim_start();
156    let Some(rest) = trimmed.strip_prefix("---") else {
157        return content;
158    };
159    // The opening fence must be its own line, and we need a closing fence.
160    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            // Skip to the end of the closing fence line, then to the body.
167            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        // And absent evidence leaves the line clean.
229        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        // The distinctive token is shown; the raw confidence and source path are not.
257        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")); // high-confidence hard directive
262    }
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        // Every injected directive asks firmly: a skill that cleared the floor is
278        // worth invoking, and timid verbs ("consider…") are ignored by a strong
279        // host even when the skill is the right one. See `[[ski-host-recall-gap]]`.
280        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        // Budget of 1 still emits the first block, never the second.
315        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}