use crate::confidence::{self, Stage};
use crate::config::{Config, InjectMode, Strength};
use crate::embed::{self, EmbedKind};
use crate::index::{self, Index};
use crate::inject::Rec;
use crate::rank::Hit;
use crate::session::Session;
use crate::{context, inject, paths, pipeline, rank, skill, telemetry};
use serde::Deserialize;
use std::io::Read;
use std::str::FromStr;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Host {
Claude,
Opencode,
}
impl FromStr for Host {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"claude" => Ok(Host::Claude),
"opencode" => Ok(Host::Opencode),
other => anyhow::bail!("unknown host '{other}' (expected 'claude' or 'opencode')"),
}
}
}
#[derive(Debug, Default, Deserialize)]
struct RawEvent {
#[serde(default)]
prompt: String,
#[serde(default)]
session_id: String,
#[serde(default)]
cwd: String,
}
#[derive(Debug, Default)]
struct Decision {
inject: String,
skills: Vec<String>,
}
pub fn run(host: Host) -> anyhow::Result<()> {
let decision = decide(host).unwrap_or_else(|e| {
crate::trace::debug("hook decide failed, injecting nothing", &e);
Decision::default()
});
let out = match host {
Host::Claude => render_claude(&decision),
Host::Opencode => render_opencode(&decision),
};
if !out.is_empty() {
println!("{out}");
}
Ok(())
}
fn decide(host: Host) -> anyhow::Result<Decision> {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
let event: RawEvent = serde_json::from_str(&buf).unwrap_or_default();
if event.prompt.trim().is_empty() {
return Ok(Decision::default());
}
if is_control_prompt(&event.prompt) {
return Ok(Decision::default());
}
let invoked_skill = slash_command_id(&event.prompt);
let (mut cfg, file) = Config::load(host);
telemetry::init(cfg.telemetry); let embedder = embed::build(&cfg.model)?;
cfg.calibrate_to(embedder.as_ref());
file.apply_cosine(&mut cfg); let idx = load_or_build_index(&cfg, embedder.as_ref(), host)?;
if idx.skills.is_empty() {
return Ok(Decision::default());
}
let session_path = paths::session_path(&event.session_id);
let mut session = Session::load(&session_path);
let query = embedder
.embed(std::slice::from_ref(&event.prompt), EmbedKind::Query)?
.remove(0);
let cvec = context::vector(embedder.as_ref(), &session.recent_prompts, &cfg).unwrap_or(None);
let file_ids = if cfg.file_boost > 0.0 {
let file_text = format!("{} {}", session.recent_prompts.join(" "), event.prompt);
context::file_ids(&file_text)
} else {
std::collections::BTreeSet::new()
};
let project_hits: std::collections::BTreeMap<String, String> = if cfg.project_boost > 0.0 {
let mut terms = context::project_terms(&event.cwd);
let code_text = format!("{} {}", session.recent_prompts.join(" "), event.prompt);
terms.extend(context::code_terms(&code_text));
context::skills_for_terms(&terms, &idx)
} else {
std::collections::BTreeMap::new()
};
let project_ids: std::collections::BTreeSet<String> = project_hits.keys().cloned().collect();
let hits = rank::rank_all_ctx(
&query,
cvec.as_deref(),
&file_ids,
&project_ids,
&event.prompt,
&idx,
&cfg,
);
let prompt_top = hits.iter().map(|h| h.cosine).fold(0.0_f32, f32::max);
let rerank_query = context::rerank_query(
&event.prompt,
prompt_top,
&session.recent_prompts,
!file_ids.is_empty(),
&cfg,
);
if cfg.context_depth > 0 {
session.push_prompt(&event.prompt, cfg.context_depth);
let _ = session.save_merged(&session_path);
}
if telemetry::enabled() {
session.last_prompt = event.prompt.clone();
let _ = session.save_merged(&session_path);
}
let plan = pipeline::decide(&hits, &idx, &event.prompt, &rerank_query, &cfg);
let stage = plan.stage;
let considered = match &plan.lexical {
Some(win) => vec![(win.id.clone(), win.score)],
None => top_considered(&plan.rows),
};
let passed = without_invoked(&plan.passed, invoked_skill.as_deref());
let selected = finalize(&passed, stage, &cfg, &session, &project_hits);
if selected.is_empty() {
telemetry::record_recommend(
&event.session_id,
&event.prompt,
stage,
&considered,
&[],
&[],
Some("below_gate"),
);
return Ok(Decision::default());
}
let strength = resolve_strength(cfg.directive_strength, host);
let mode = inject_mode(&selected, &cfg);
let (text, ids) = inject::build(&selected, &idx, mode, strength, cfg.char_budget);
if text.is_empty() {
telemetry::record_recommend(
&event.session_id,
&event.prompt,
stage,
&considered,
&selected,
&[],
Some("empty_text"),
);
return Ok(Decision::default());
}
let injected: Vec<(String, f32)> = ids
.iter()
.map(|id| (id.clone(), confidence_of(&selected, id)))
.collect();
for (id, conf) in &injected {
session.mark_recommended(id, *conf);
}
let _ = session.save_merged(&session_path);
telemetry::record_recommend(
&event.session_id,
&event.prompt,
stage,
&considered,
&selected,
&injected,
None,
);
Ok(Decision {
inject: text,
skills: ids,
})
}
const CONSIDER_K: usize = 10;
fn top_considered(hits: &[Hit]) -> Vec<(String, f32)> {
hits.iter()
.take(CONSIDER_K)
.map(|h| (h.id.clone(), h.score))
.collect()
}
fn is_control_prompt(prompt: &str) -> bool {
let p = prompt.trim_start();
p.starts_with("<task-notification") || p.starts_with("<system-reminder")
}
fn slash_command_id(prompt: &str) -> Option<String> {
let rest = prompt.trim_start().strip_prefix('/')?;
let name = rest.split_whitespace().next()?;
let ok = !name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':'));
ok.then(|| name.rsplit(':').next().unwrap_or(name).to_string())
}
fn confidence_of(recs: &[Rec], id: &str) -> f32 {
recs.iter()
.find(|r| r.id == id)
.map(|r| r.confidence)
.unwrap_or(0.0)
}
fn load_or_build_index(
cfg: &Config,
embedder: &dyn embed::Embedder,
host: Host,
) -> anyhow::Result<Index> {
let path = paths::index_path(host);
match Index::load(&path) {
Ok(Some(idx)) if idx.model == embedder.id() => return Ok(idx),
Ok(_) => {}
Err(e) => crate::trace::debug(
&format!("index {} unreadable; rebuilding", path.display()),
&e,
),
}
let skills = skill::discover(&cfg.roots)?;
let idx = index::build(&skills, embedder, None)?;
let _ = idx.save(&path);
Ok(idx)
}
fn without_invoked(passed: &[Hit], invoked: Option<&str>) -> Vec<Hit> {
passed
.iter()
.filter(|h| Some(h.id.as_str()) != invoked)
.cloned()
.collect()
}
fn finalize(
passed: &[Hit],
stage: Stage,
cfg: &Config,
session: &Session,
project_hits: &std::collections::BTreeMap<String, String>,
) -> Vec<Rec> {
passed
.iter()
.filter(|h| !cfg.deny.contains(&h.id))
.map(|h| Rec {
confidence: confidence::of(h.score, stage, cfg),
why: evidence(h, project_hits),
id: h.id.clone(),
})
.filter(|r| session.should_recommend(&r.id, r.confidence, confidence::HIGH))
.take(cfg.max_skills)
.collect()
}
fn evidence(h: &Hit, project_hits: &std::collections::BTreeMap<String, String>) -> Option<String> {
if h.file > 0.0 {
return Some("a file of this skill's document type is part of this conversation".into());
}
if h.project > 0.0 {
return project_hits
.get(&h.id)
.map(|term| format!("you are working in a {term} project"));
}
None
}
fn inject_mode(recs: &[Rec], cfg: &Config) -> InjectMode {
if cfg.inject_mode == InjectMode::Directive
&& recs.len() == 1
&& recs[0].confidence >= cfg.body_inject_min
{
InjectMode::Body
} else {
cfg.inject_mode
}
}
fn resolve_strength(strength: Strength, host: Host) -> Strength {
match strength {
Strength::Auto => match host {
Host::Claude => Strength::Soft,
Host::Opencode => Strength::Hard,
},
other => other,
}
}
fn render_claude(d: &Decision) -> String {
if d.inject.is_empty() {
return String::new();
}
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": d.inject,
}
})
.to_string()
}
fn render_opencode(d: &Decision) -> String {
serde_json::json!({ "skills": d.skills, "inject": d.inject }).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::session::Source;
fn hit(id: &str, score: f32, keyword: f32) -> Hit {
Hit {
id: id.to_string(),
name: id.to_string(),
cosine: score - keyword,
context: 0.0,
file: 0.0,
project: 0.0,
keyword,
phrase: 0.0,
score,
}
}
#[test]
fn host_parse() {
assert_eq!("claude".parse::<Host>().unwrap(), Host::Claude);
assert_eq!("OpenCode".parse::<Host>().unwrap(), Host::Opencode);
assert!("bogus".parse::<Host>().is_err());
}
#[test]
fn raw_event_parses_claude_and_opencode_shapes() {
let claude = r#"{"session_id":"s1","cwd":"/r","prompt":"hi","transcript_path":"/t"}"#;
let ev: RawEvent = serde_json::from_str(claude).unwrap();
assert_eq!(ev.prompt, "hi");
assert_eq!(ev.session_id, "s1");
let oc = r#"{"host":"opencode","session_id":"s2","cwd":"/r","prompt":"yo"}"#;
let ev: RawEvent = serde_json::from_str(oc).unwrap();
assert_eq!(ev.prompt, "yo");
assert_eq!(ev.session_id, "s2");
}
#[test]
fn strength_resolution() {
assert_eq!(
resolve_strength(Strength::Auto, Host::Claude),
Strength::Soft
);
assert_eq!(
resolve_strength(Strength::Auto, Host::Opencode),
Strength::Hard
);
assert_eq!(
resolve_strength(Strength::Hard, Host::Claude),
Strength::Hard
);
}
fn select_cosine(hits: &[Hit], cfg: &Config, session: &Session) -> Vec<Rec> {
finalize(
&pipeline::cosine_passed(hits, cfg),
Stage::Cosine,
cfg,
session,
&std::collections::BTreeMap::new(),
)
}
#[test]
fn select_threshold_and_cap() {
let cfg = Config::default(); let session = Session::default();
let hits = vec![
hit("a", 0.90, 0.0),
hit("b", 0.85, 0.0),
hit("c", 0.84, 0.0), hit("d", 0.10, 0.0), ];
let got: Vec<String> = select_cosine(&hits, &cfg, &session)
.into_iter()
.map(|h| h.id)
.collect();
assert_eq!(got, ["a", "b"]); }
#[test]
fn select_skips_loaded_and_denied() {
let cfg = Config {
deny: vec!["a".to_string()],
..Default::default()
};
let mut session = Session::default();
session.mark("b", Source::Model);
let hits = vec![
hit("a", 0.90, 0.0),
hit("b", 0.85, 0.0),
hit("c", 0.80, 0.0),
];
let got: Vec<String> = select_cosine(&hits, &cfg, &session)
.into_iter()
.map(|h| h.id)
.collect();
assert_eq!(got, ["c"]); }
#[test]
fn select_margin_drops_weak_tail() {
let cfg = Config::default(); let session = Session::default();
let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.50, 0.0)];
let got: Vec<String> = select_cosine(&hits, &cfg, &session)
.into_iter()
.map(|h| h.id)
.collect();
assert_eq!(got, ["a"]);
}
#[test]
fn select_repeat_falls_silent() {
let cfg = Config::default();
let mut session = Session::default();
session.mark_recommended("a", 0.95);
let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.50, 0.0)];
assert!(select_cosine(&hits, &cfg, &session).is_empty());
}
#[test]
fn select_repeats_on_rise_into_high() {
let cfg = Config::default();
let mut session = Session::default();
session.mark_recommended("a", 0.60); let hits = vec![hit("a", 0.90, 0.0)]; let got: Vec<String> = select_cosine(&hits, &cfg, &session)
.into_iter()
.map(|r| r.id)
.collect();
assert_eq!(got, ["a"]);
}
#[test]
fn select_keeps_co_relevant_cluster() {
let cfg = Config::default(); let session = Session::default();
let hits = vec![hit("a", 0.90, 0.0), hit("b", 0.80, 0.0)];
let got: Vec<String> = select_cosine(&hits, &cfg, &session)
.into_iter()
.map(|h| h.id)
.collect();
assert_eq!(got, ["a", "b"]);
}
#[test]
fn select_force_bypasses_threshold_on_keyword() {
let cfg = Config {
force: vec!["x".to_string()],
..Default::default()
};
let session = Session::default();
let hits = vec![hit("x", 0.1, 0.15), hit("y", 0.2, 0.0)];
let got: Vec<String> = select_cosine(&hits, &cfg, &session)
.into_iter()
.map(|h| h.id)
.collect();
assert_eq!(got, ["x"]);
}
fn rec(id: &str, confidence: f32) -> Rec {
Rec {
id: id.to_string(),
confidence,
why: None,
}
}
#[test]
fn finalize_attaches_project_evidence() {
let cfg = Config::default();
let session = Session::default();
let mut h = hit("uv-development", 0.90, 0.0);
h.project = 0.15; let project_hits: std::collections::BTreeMap<String, String> =
[("uv-development".to_string(), "uv".to_string())].into();
let got = finalize(&[h], Stage::Cosine, &cfg, &session, &project_hits);
assert_eq!(
got[0].why.as_deref(),
Some("you are working in a uv project")
);
let got = finalize(
&[hit("a", 0.90, 0.0)],
Stage::Cosine,
&cfg,
&session,
&project_hits,
);
assert_eq!(got[0].why, None);
}
#[test]
fn lone_near_certain_match_escalates_to_body() {
let cfg = Config::default(); assert_eq!(inject_mode(&[rec("a", 0.95)], &cfg), InjectMode::Body);
}
#[test]
fn body_escalation_needs_high_confidence() {
let cfg = Config::default();
assert_eq!(inject_mode(&[rec("a", 0.85)], &cfg), InjectMode::Directive);
}
#[test]
fn body_escalation_needs_a_lone_match() {
let cfg = Config::default();
assert_eq!(
inject_mode(&[rec("a", 0.95), rec("b", 0.95)], &cfg),
InjectMode::Directive
);
}
#[test]
fn body_escalation_disabled_above_one() {
let cfg = Config {
body_inject_min: 1.1, ..Default::default()
};
assert_eq!(inject_mode(&[rec("a", 0.99)], &cfg), InjectMode::Directive);
}
#[test]
fn explicit_body_mode_is_unchanged() {
let cfg = Config {
inject_mode: InjectMode::Body,
..Default::default()
};
assert_eq!(
inject_mode(&[rec("a", 0.2), rec("b", 0.2)], &cfg),
InjectMode::Body
);
}
#[test]
fn invoked_skill_does_not_consume_a_cap_slot() {
let cfg = Config::default(); let session = Session::default();
let hits = vec![
hit("a", 0.90, 0.0),
hit("b", 0.85, 0.0),
hit("c", 0.84, 0.0),
];
let passed = pipeline::cosine_passed(&hits, &cfg);
let got: Vec<String> = finalize(
&without_invoked(&passed, Some("a")),
Stage::Cosine,
&cfg,
&session,
&std::collections::BTreeMap::new(),
)
.into_iter()
.map(|r| r.id)
.collect();
assert_eq!(got, ["b", "c"]);
}
#[test]
fn control_prompts_detected() {
assert!(is_control_prompt(
"<task-notification>\n<task-id>x</task-id>\n</task-notification>"
));
assert!(is_control_prompt(
" <system-reminder>foo</system-reminder>"
));
assert!(!is_control_prompt(
"explain the <task-notification> payload"
));
assert!(!is_control_prompt("set up a python project"));
}
#[test]
fn slash_command_id_extracts_name() {
assert_eq!(slash_command_id("/pickup"), Some("pickup".into()));
assert_eq!(
slash_command_id("/pickup keep going"),
Some("pickup".into())
);
assert_eq!(slash_command_id(" /handoff now"), Some("handoff".into()));
assert_eq!(
slash_command_id("/caveman:caveman-commit"),
Some("caveman-commit".into())
);
assert_eq!(slash_command_id("commit and push"), None);
assert_eq!(slash_command_id("/etc/hosts is a path"), None);
assert_eq!(slash_command_id("/"), None);
}
#[test]
fn render_claude_empty_is_silent() {
assert_eq!(render_claude(&Decision::default()), "");
}
#[test]
fn render_claude_wraps_context() {
let d = Decision {
inject: "ctx".to_string(),
skills: vec!["a".to_string()],
};
let v: serde_json::Value = serde_json::from_str(&render_claude(&d)).unwrap();
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "UserPromptSubmit");
assert_eq!(v["hookSpecificOutput"]["additionalContext"], "ctx");
}
#[test]
fn render_opencode_always_json() {
let v: serde_json::Value = serde_json::from_str(&render_opencode(&Decision::default()))
.expect("opencode output is always valid JSON");
assert_eq!(v["inject"], "");
assert!(v["skills"].as_array().unwrap().is_empty());
}
}