1use crate::confidence::{self, Stage};
13use crate::config::{Config, InjectMode, Strength};
14use crate::embed::{self, EmbedKind};
15use crate::index::{self, Index};
16use crate::inject::Rec;
17use crate::rank::Hit;
18use crate::session::Session;
19use crate::{context, inject, paths, pipeline, rank, skill, telemetry};
20use serde::Deserialize;
21use std::io::Read;
22use std::str::FromStr;
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Host {
26 Claude,
27 Opencode,
28}
29
30impl FromStr for Host {
31 type Err = anyhow::Error;
32 fn from_str(s: &str) -> Result<Self, Self::Err> {
33 match s.to_ascii_lowercase().as_str() {
34 "claude" => Ok(Host::Claude),
35 "opencode" => Ok(Host::Opencode),
36 other => anyhow::bail!("unknown host '{other}' (expected 'claude' or 'opencode')"),
37 }
38 }
39}
40
41#[derive(Debug, Default, Deserialize)]
44struct RawEvent {
45 #[serde(default)]
46 prompt: String,
47 #[serde(default)]
48 session_id: String,
49 #[serde(default)]
50 cwd: String,
51}
52
53#[derive(Debug, Default)]
54struct Decision {
55 inject: String,
56 skills: Vec<String>,
57}
58
59pub fn run(host: Host) -> anyhow::Result<()> {
62 let decision = decide(host).unwrap_or_else(|e| {
63 crate::trace::debug("hook decide failed, injecting nothing", &e);
64 Decision::default()
65 });
66 let out = match host {
67 Host::Claude => render_claude(&decision),
68 Host::Opencode => render_opencode(&decision),
69 };
70 if !out.is_empty() {
71 println!("{out}");
72 }
73 Ok(())
74}
75
76fn decide(host: Host) -> anyhow::Result<Decision> {
77 let mut buf = String::new();
78 std::io::stdin().read_to_string(&mut buf)?;
79 let event: RawEvent = serde_json::from_str(&buf).unwrap_or_default();
80 if event.prompt.trim().is_empty() {
81 return Ok(Decision::default());
82 }
83 if is_control_prompt(&event.prompt) {
87 return Ok(Decision::default());
88 }
89 let invoked_skill = slash_command_id(&event.prompt);
93
94 let (mut cfg, file) = Config::load(host);
95 telemetry::init(cfg.telemetry); let embedder = embed::build(&cfg.model)?;
97 cfg.calibrate_to(embedder.as_ref());
98 file.apply_cosine(&mut cfg); let idx = load_or_build_index(&cfg, embedder.as_ref(), host)?;
100 if idx.skills.is_empty() {
101 return Ok(Decision::default());
102 }
103
104 let session_path = paths::session_path(&event.session_id);
105 let mut session = Session::load(&session_path);
106
107 let query = embedder
108 .embed(std::slice::from_ref(&event.prompt), EmbedKind::Query)?
109 .remove(0);
110 let cvec = context::vector(embedder.as_ref(), &session.recent_prompts, &cfg).unwrap_or(None);
114 let file_ids = if cfg.file_boost > 0.0 {
118 let file_text = format!("{} {}", session.recent_prompts.join(" "), event.prompt);
119 context::file_ids(&file_text)
120 } else {
121 std::collections::BTreeSet::new()
122 };
123 let project_hits: std::collections::BTreeMap<String, String> = if cfg.project_boost > 0.0 {
130 let mut terms = context::project_terms(&event.cwd);
131 let code_text = format!("{} {}", session.recent_prompts.join(" "), event.prompt);
132 terms.extend(context::code_terms(&code_text));
133 context::skills_for_terms(&terms, &idx)
134 } else {
135 std::collections::BTreeMap::new()
136 };
137 let project_ids: std::collections::BTreeSet<String> = project_hits.keys().cloned().collect();
138 let hits = rank::rank_all_ctx(
139 &query,
140 cvec.as_deref(),
141 &file_ids,
142 &project_ids,
143 &event.prompt,
144 &idx,
145 &cfg,
146 );
147 let prompt_top = hits.iter().map(|h| h.cosine).fold(0.0_f32, f32::max);
148 let rerank_query = context::rerank_query(
149 &event.prompt,
150 prompt_top,
151 &session.recent_prompts,
152 !file_ids.is_empty(),
153 &cfg,
154 );
155 if cfg.context_depth > 0 {
159 session.push_prompt(&event.prompt, cfg.context_depth);
160 let _ = session.save_merged(&session_path);
161 }
162
163 if telemetry::enabled() {
168 session.last_prompt = event.prompt.clone();
169 let _ = session.save_merged(&session_path);
170 }
171 let plan = pipeline::decide(&hits, &idx, &event.prompt, &rerank_query, &cfg);
177 let stage = plan.stage;
178 let considered = match &plan.lexical {
183 Some(win) => vec![(win.id.clone(), win.score)],
184 None => top_considered(&plan.rows),
185 };
186 let passed = without_invoked(&plan.passed, invoked_skill.as_deref());
192 let selected = finalize(&passed, stage, &cfg, &session, &project_hits);
193 if selected.is_empty() {
194 telemetry::record_recommend(
199 &event.session_id,
200 &event.prompt,
201 stage,
202 &considered,
203 &[],
204 &[],
205 Some("below_gate"),
206 );
207 return Ok(Decision::default());
208 }
209
210 let strength = resolve_strength(cfg.directive_strength, host);
211 let mode = inject_mode(&selected, &cfg);
215 let (text, ids) = inject::build(&selected, &idx, mode, strength, cfg.char_budget);
216 if text.is_empty() {
217 telemetry::record_recommend(
218 &event.session_id,
219 &event.prompt,
220 stage,
221 &considered,
222 &selected,
223 &[],
224 Some("empty_text"),
225 );
226 return Ok(Decision::default());
227 }
228
229 let injected: Vec<(String, f32)> = ids
232 .iter()
233 .map(|id| (id.clone(), confidence_of(&selected, id)))
234 .collect();
235 for (id, conf) in &injected {
236 session.mark_recommended(id, *conf);
237 }
238 let _ = session.save_merged(&session_path); telemetry::record_recommend(
243 &event.session_id,
244 &event.prompt,
245 stage,
246 &considered,
247 &selected,
248 &injected,
249 None,
250 );
251
252 Ok(Decision {
253 inject: text,
254 skills: ids,
255 })
256}
257
258const CONSIDER_K: usize = 10;
262
263fn top_considered(hits: &[Hit]) -> Vec<(String, f32)> {
266 hits.iter()
267 .take(CONSIDER_K)
268 .map(|h| (h.id.clone(), h.score))
269 .collect()
270}
271
272fn is_control_prompt(prompt: &str) -> bool {
279 let p = prompt.trim_start();
280 p.starts_with("<task-notification") || p.starts_with("<system-reminder")
281}
282
283fn slash_command_id(prompt: &str) -> Option<String> {
288 let rest = prompt.trim_start().strip_prefix('/')?;
289 let name = rest.split_whitespace().next()?;
290 let ok = !name.is_empty()
291 && name
292 .chars()
293 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':'));
294 ok.then(|| name.rsplit(':').next().unwrap_or(name).to_string())
296}
297
298fn confidence_of(recs: &[Rec], id: &str) -> f32 {
300 recs.iter()
301 .find(|r| r.id == id)
302 .map(|r| r.confidence)
303 .unwrap_or(0.0)
304}
305
306fn load_or_build_index(
313 cfg: &Config,
314 embedder: &dyn embed::Embedder,
315 host: Host,
316) -> anyhow::Result<Index> {
317 let path = paths::index_path(host);
318 match Index::load(&path) {
322 Ok(Some(idx)) if idx.model == embedder.id() => return Ok(idx),
323 Ok(_) => {}
324 Err(e) => crate::trace::debug(
325 &format!("index {} unreadable; rebuilding", path.display()),
326 &e,
327 ),
328 }
329 let skills = skill::discover(&cfg.roots)?;
330 let idx = index::build(&skills, embedder, None)?;
331 let _ = idx.save(&path);
332 Ok(idx)
333}
334
335fn without_invoked(passed: &[Hit], invoked: Option<&str>) -> Vec<Hit> {
338 passed
339 .iter()
340 .filter(|h| Some(h.id.as_str()) != invoked)
341 .cloned()
342 .collect()
343}
344
345fn finalize(
360 passed: &[Hit],
361 stage: Stage,
362 cfg: &Config,
363 session: &Session,
364 project_hits: &std::collections::BTreeMap<String, String>,
365) -> Vec<Rec> {
366 passed
367 .iter()
368 .filter(|h| !cfg.deny.contains(&h.id))
369 .map(|h| Rec {
370 confidence: confidence::of(h.score, stage, cfg),
371 why: evidence(h, project_hits),
372 id: h.id.clone(),
373 })
374 .filter(|r| session.should_recommend(&r.id, r.confidence, confidence::HIGH))
375 .take(cfg.max_skills)
376 .collect()
377}
378
379fn evidence(h: &Hit, project_hits: &std::collections::BTreeMap<String, String>) -> Option<String> {
385 if h.file > 0.0 {
386 return Some("a file of this skill's document type is part of this conversation".into());
387 }
388 if h.project > 0.0 {
389 return project_hits
390 .get(&h.id)
391 .map(|term| format!("you are working in a {term} project"));
392 }
393 None
394}
395
396fn inject_mode(recs: &[Rec], cfg: &Config) -> InjectMode {
403 if cfg.inject_mode == InjectMode::Directive
404 && recs.len() == 1
405 && recs[0].confidence >= cfg.body_inject_min
406 {
407 InjectMode::Body
408 } else {
409 cfg.inject_mode
410 }
411}
412
413fn resolve_strength(strength: Strength, host: Host) -> Strength {
416 match strength {
417 Strength::Auto => match host {
418 Host::Claude => Strength::Soft,
419 Host::Opencode => Strength::Hard,
420 },
421 other => other,
422 }
423}
424
425fn render_claude(d: &Decision) -> String {
426 if d.inject.is_empty() {
427 return String::new();
428 }
429 serde_json::json!({
430 "hookSpecificOutput": {
431 "hookEventName": "UserPromptSubmit",
432 "additionalContext": d.inject,
433 }
434 })
435 .to_string()
436}
437
438fn render_opencode(d: &Decision) -> String {
439 serde_json::json!({ "skills": d.skills, "inject": d.inject }).to_string()
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::config::Config;
446 use crate::session::Source;
447
448 fn hit(id: &str, score: f32, keyword: f32) -> Hit {
449 Hit {
450 id: id.to_string(),
451 name: id.to_string(),
452 cosine: score - keyword,
453 context: 0.0,
454 file: 0.0,
455 project: 0.0,
456 keyword,
457 phrase: 0.0,
458 score,
459 }
460 }
461
462 #[test]
463 fn host_parse() {
464 assert_eq!("claude".parse::<Host>().unwrap(), Host::Claude);
465 assert_eq!("OpenCode".parse::<Host>().unwrap(), Host::Opencode);
466 assert!("bogus".parse::<Host>().is_err());
467 }
468
469 #[test]
470 fn raw_event_parses_claude_and_opencode_shapes() {
471 let claude = r#"{"session_id":"s1","cwd":"/r","prompt":"hi","transcript_path":"/t"}"#;
472 let ev: RawEvent = serde_json::from_str(claude).unwrap();
473 assert_eq!(ev.prompt, "hi");
474 assert_eq!(ev.session_id, "s1");
475
476 let oc = r#"{"host":"opencode","session_id":"s2","cwd":"/r","prompt":"yo"}"#;
477 let ev: RawEvent = serde_json::from_str(oc).unwrap();
478 assert_eq!(ev.prompt, "yo");
479 assert_eq!(ev.session_id, "s2");
480 }
481
482 #[test]
483 fn strength_resolution() {
484 assert_eq!(
485 resolve_strength(Strength::Auto, Host::Claude),
486 Strength::Soft
487 );
488 assert_eq!(
489 resolve_strength(Strength::Auto, Host::Opencode),
490 Strength::Hard
491 );
492 assert_eq!(
494 resolve_strength(Strength::Hard, Host::Claude),
495 Strength::Hard
496 );
497 }
498
499 fn select_cosine(hits: &[Hit], cfg: &Config, session: &Session) -> Vec<Rec> {
502 finalize(
503 &pipeline::cosine_passed(hits, cfg),
504 Stage::Cosine,
505 cfg,
506 session,
507 &std::collections::BTreeMap::new(),
508 )
509 }
510
511 #[test]
512 fn select_threshold_and_cap() {
513 let cfg = Config::default(); let session = Session::default();
515 let hits = vec![
516 hit("a", 0.90, 0.0),
517 hit("b", 0.85, 0.0),
518 hit("c", 0.84, 0.0), hit("d", 0.10, 0.0), ];
521 let got: Vec<String> = select_cosine(&hits, &cfg, &session)
522 .into_iter()
523 .map(|h| h.id)
524 .collect();
525 assert_eq!(got, ["a", "b"]); }
527
528 #[test]
529 fn select_skips_loaded_and_denied() {
530 let cfg = Config {
531 deny: vec!["a".to_string()],
532 ..Default::default()
533 };
534 let mut session = Session::default();
535 session.mark("b", Source::Model);
536 let hits = vec![
537 hit("a", 0.90, 0.0),
538 hit("b", 0.85, 0.0),
539 hit("c", 0.80, 0.0),
540 ];
541 let got: Vec<String> = select_cosine(&hits, &cfg, &session)
542 .into_iter()
543 .map(|h| h.id)
544 .collect();
545 assert_eq!(got, ["c"]); }
547
548 #[test]
549 fn select_margin_drops_weak_tail() {
550 let cfg = Config::default(); let session = Session::default();
552 let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.50, 0.0)];
554 let got: Vec<String> = select_cosine(&hits, &cfg, &session)
555 .into_iter()
556 .map(|h| h.id)
557 .collect();
558 assert_eq!(got, ["a"]);
559 }
560
561 #[test]
562 fn select_repeat_falls_silent() {
563 let cfg = Config::default();
567 let mut session = Session::default();
568 session.mark_recommended("a", 0.95);
569 let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.50, 0.0)];
570 assert!(select_cosine(&hits, &cfg, &session).is_empty());
571 }
572
573 #[test]
574 fn select_repeats_on_rise_into_high() {
575 let cfg = Config::default();
578 let mut session = Session::default();
579 session.mark_recommended("a", 0.60); let hits = vec![hit("a", 0.90, 0.0)]; let got: Vec<String> = select_cosine(&hits, &cfg, &session)
582 .into_iter()
583 .map(|r| r.id)
584 .collect();
585 assert_eq!(got, ["a"]);
586 }
587
588 #[test]
589 fn select_keeps_co_relevant_cluster() {
590 let cfg = Config::default(); let session = Session::default();
592 let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.80, 0.0)];
593 let got: Vec<String> = select_cosine(&hits, &cfg, &session)
594 .into_iter()
595 .map(|h| h.id)
596 .collect();
597 assert_eq!(got, ["a", "b"]);
598 }
599
600 #[test]
601 fn select_force_bypasses_threshold_on_keyword() {
602 let cfg = Config {
603 force: vec!["x".to_string()],
604 ..Default::default()
605 };
606 let session = Session::default();
607 let hits = vec![hit("x", 0.1, 0.15), hit("y", 0.2, 0.0)];
609 let got: Vec<String> = select_cosine(&hits, &cfg, &session)
610 .into_iter()
611 .map(|h| h.id)
612 .collect();
613 assert_eq!(got, ["x"]);
614 }
615
616 fn rec(id: &str, confidence: f32) -> Rec {
617 Rec {
618 id: id.to_string(),
619 confidence,
620 why: None,
621 }
622 }
623
624 #[test]
625 fn finalize_attaches_project_evidence() {
626 let cfg = Config::default();
627 let session = Session::default();
628 let mut h = hit("uv-development", 0.90, 0.0);
629 h.project = 0.15; let project_hits: std::collections::BTreeMap<String, String> =
631 [("uv-development".to_string(), "uv".to_string())].into();
632 let got = finalize(&[h], Stage::Cosine, &cfg, &session, &project_hits);
633 assert_eq!(
634 got[0].why.as_deref(),
635 Some("you are working in a uv project")
636 );
637 let got = finalize(
639 &[hit("a", 0.90, 0.0)],
640 Stage::Cosine,
641 &cfg,
642 &session,
643 &project_hits,
644 );
645 assert_eq!(got[0].why, None);
646 }
647
648 #[test]
649 fn lone_near_certain_match_escalates_to_body() {
650 let cfg = Config::default(); assert_eq!(inject_mode(&[rec("a", 0.95)], &cfg), InjectMode::Body);
652 }
653
654 #[test]
655 fn body_escalation_needs_high_confidence() {
656 let cfg = Config::default();
657 assert_eq!(inject_mode(&[rec("a", 0.85)], &cfg), InjectMode::Directive);
659 }
660
661 #[test]
662 fn body_escalation_needs_a_lone_match() {
663 let cfg = Config::default();
664 assert_eq!(
667 inject_mode(&[rec("a", 0.95), rec("b", 0.95)], &cfg),
668 InjectMode::Directive
669 );
670 }
671
672 #[test]
673 fn body_escalation_disabled_above_one() {
674 let cfg = Config {
675 body_inject_min: 1.1, ..Default::default()
677 };
678 assert_eq!(inject_mode(&[rec("a", 0.99)], &cfg), InjectMode::Directive);
679 }
680
681 #[test]
682 fn explicit_body_mode_is_unchanged() {
683 let cfg = Config {
684 inject_mode: InjectMode::Body,
685 ..Default::default()
686 };
687 assert_eq!(
689 inject_mode(&[rec("a", 0.2), rec("b", 0.2)], &cfg),
690 InjectMode::Body
691 );
692 }
693
694 #[test]
695 fn invoked_skill_does_not_consume_a_cap_slot() {
696 let cfg = Config::default(); let session = Session::default();
701 let hits = vec![
702 hit("a", 0.90, 0.0),
703 hit("b", 0.85, 0.0),
704 hit("c", 0.84, 0.0),
705 ];
706 let passed = pipeline::cosine_passed(&hits, &cfg);
707 let got: Vec<String> = finalize(
708 &without_invoked(&passed, Some("a")),
709 Stage::Cosine,
710 &cfg,
711 &session,
712 &std::collections::BTreeMap::new(),
713 )
714 .into_iter()
715 .map(|r| r.id)
716 .collect();
717 assert_eq!(got, ["b", "c"]);
718 }
719
720 #[test]
721 fn control_prompts_detected() {
722 assert!(is_control_prompt(
723 "<task-notification>\n<task-id>x</task-id>\n</task-notification>"
724 ));
725 assert!(is_control_prompt(
726 " <system-reminder>foo</system-reminder>"
727 ));
728 assert!(!is_control_prompt(
730 "explain the <task-notification> payload"
731 ));
732 assert!(!is_control_prompt("set up a python project"));
733 }
734
735 #[test]
736 fn slash_command_id_extracts_name() {
737 assert_eq!(slash_command_id("/pickup"), Some("pickup".into()));
738 assert_eq!(
739 slash_command_id("/pickup keep going"),
740 Some("pickup".into())
741 );
742 assert_eq!(slash_command_id(" /handoff now"), Some("handoff".into()));
743 assert_eq!(
745 slash_command_id("/caveman:caveman-commit"),
746 Some("caveman-commit".into())
747 );
748 assert_eq!(slash_command_id("commit and push"), None);
750 assert_eq!(slash_command_id("/etc/hosts is a path"), None);
751 assert_eq!(slash_command_id("/"), None);
752 }
753
754 #[test]
755 fn render_claude_empty_is_silent() {
756 assert_eq!(render_claude(&Decision::default()), "");
757 }
758
759 #[test]
760 fn render_claude_wraps_context() {
761 let d = Decision {
762 inject: "ctx".to_string(),
763 skills: vec!["a".to_string()],
764 };
765 let v: serde_json::Value = serde_json::from_str(&render_claude(&d)).unwrap();
766 assert_eq!(v["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit");
767 assert_eq!(v["hookSpecificOutput"]["additionalContext"], "ctx");
768 }
769
770 #[test]
771 fn render_opencode_always_json() {
772 let v: serde_json::Value = serde_json::from_str(&render_opencode(&Decision::default()))
773 .expect("opencode output is always valid JSON");
774 assert_eq!(v["inject"], "");
775 assert!(v["skills"].as_array().unwrap().is_empty());
776 }
777}