use std::collections::HashMap;use std::collections::HashSet;use std::sync::Arc;use crate::SkillLoadOutcome;use crate::SkillMetadata;use crate::build_skill_name_counts;use codex_analytics::AnalyticsEventsClient;use codex_analytics::InvocationType;use codex_analytics::SkillInvocation;use codex_analytics::TrackEventsContext;use codex_exec_server::LOCAL_FS;use codex_otel::SessionTelemetry;use codex_protocol::user_input::UserInput;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_path_uri::PathUri;use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;#[derive(Debug, Default)]pub struct SkillInjections { pub items: Vec<SkillInjection>, pub warnings: Vec<String>,}#[derive(Debug, Clone, PartialEq, Eq)]pub struct SkillInjection { pub name: String, pub path: String, pub contents: String,}/// Host skill prompts that have already been injected by an extension for this/// turn.////// Core uses this to keep the legacy skill-injection path from sending the same/// host `SKILL.md` body again while the skills extension is being wired in.#[derive(Debug, Clone, Default, PartialEq, Eq)]pub struct InjectedHostSkillPrompts { paths: HashSet<String>,}impl InjectedHostSkillPrompts { pub fn insert_path(&mut self, path: impl Into<String>) { let path = path.into(); self.paths.insert(normalize_host_skill_path(&path)); self.paths.insert(path); } pub fn is_empty(&self) -> bool { self.paths.is_empty() } pub fn contains_path(&self, path: &str) -> bool { self.paths.contains(path) || self.paths.contains(&normalize_host_skill_path(path)) }}pub async fn build_skill_injections( mentioned_skills: &[SkillMetadata], loaded_skills: Option<&SkillLoadOutcome>, otel: Option<&SessionTelemetry>, analytics_client: &AnalyticsEventsClient, tracking: TrackEventsContext,) -> SkillInjections { if mentioned_skills.is_empty() { return SkillInjections::default(); } let mut result = SkillInjections { items: Vec::with_capacity(mentioned_skills.len()), warnings: Vec::new(), }; let mut invocations = Vec::new(); for skill in mentioned_skills { let fs = loaded_skills .and_then(|outcome| outcome.file_system_for_skill(skill)) .unwrap_or_else(|| Arc::clone(&LOCAL_FS)); let path = PathUri::from_abs_path(&skill.path_to_skills_md); match fs.read_file_text(&path, /*sandbox*/ None).await { Ok(contents) => { emit_skill_injected_metric(otel, skill, "ok"); invocations.push(SkillInvocation { skill_name: skill.name.clone(), skill_scope: skill.scope, skill_path: skill.path_to_skills_md.to_path_buf(), plugin_id: skill.plugin_id.clone(), invocation_type: InvocationType::Explicit, }); result.items.push(SkillInjection { name: skill.name.clone(), path: skill.path_to_skills_md.to_string_lossy().into_owned(), contents, }); } Err(err) => { emit_skill_injected_metric(otel, skill, "error"); let message = format!( "Failed to load skill {name} at {path}: {err:#}", name = skill.name, path = skill.path_to_skills_md.display() ); result.warnings.push(message); } } } analytics_client.track_skill_invocations(tracking, invocations); result}fn normalize_host_skill_path(path: &str) -> String { normalize_skill_path(path).replace('\\', "/")}fn emit_skill_injected_metric( otel: Option<&SessionTelemetry>, skill: &SkillMetadata, status: &str,) { let Some(otel) = otel else { return; }; otel.counter( "codex.skill.injected", /*inc*/ 1, &[("status", status), ("skill", skill.name.as_str())], );}/// Collect explicitly mentioned skills from structured and text mentions.////// Structured `UserInput::Skill` selections are resolved first by path against/// enabled skills. Text inputs are then scanned to extract `$skill-name` tokens, and we/// iterate `skills` in their existing order to preserve prior ordering semantics./// Explicit links are resolved by path and plain names are only used when the match/// is unambiguous.////// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where:/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs,/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input.pub fn collect_explicit_skill_mentions( inputs: &[UserInput], skills: &[SkillMetadata], disabled_paths: &HashSet<AbsolutePathBuf>, connector_slug_counts: &HashMap<String, usize>,) -> Vec<SkillMetadata> { let skill_name_counts = build_skill_name_counts(skills, disabled_paths).0; let selection_context = SkillSelectionContext { skills, disabled_paths, skill_name_counts: &skill_name_counts, connector_slug_counts, }; let mut selected: Vec<SkillMetadata> = Vec::new(); let mut seen_names: HashSet<String> = HashSet::new(); let mut seen_paths: HashSet<AbsolutePathBuf> = HashSet::new(); let mut blocked_plain_names: HashSet<String> = HashSet::new(); for input in inputs { if let UserInput::Skill { name, path, .. } = input { blocked_plain_names.insert(name.clone()); let Ok(path) = AbsolutePathBuf::relative_to_current_dir(path) else { continue; }; if selection_context.disabled_paths.contains(&path) || seen_paths.contains(&path) { continue; } if let Some(skill) = selection_context .skills .iter() .find(|skill| skill.path_to_skills_md == path) { seen_paths.insert(skill.path_to_skills_md.clone()); seen_names.insert(skill.name.clone()); selected.push(skill.clone()); } } } for input in inputs { if let UserInput::Text { text, .. } = input { let mentioned_names = extract_tool_mentions(text); select_skills_from_mentions( &selection_context, &blocked_plain_names, &mentioned_names, &mut seen_names, &mut seen_paths, &mut selected, ); } } selected}struct SkillSelectionContext<'a> { skills: &'a [SkillMetadata], disabled_paths: &'a HashSet<AbsolutePathBuf>, skill_name_counts: &'a HashMap<String, usize>, connector_slug_counts: &'a HashMap<String, usize>,}pub struct ToolMentions<'a> { names: HashSet<&'a str>, paths: HashSet<&'a str>, plain_names: HashSet<&'a str>,}impl<'a> ToolMentions<'a> { fn is_empty(&self) -> bool { self.names.is_empty() && self.paths.is_empty() } pub fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ { self.plain_names.iter().copied() } pub fn paths(&self) -> impl Iterator<Item = &'a str> + '_ { self.paths.iter().copied() }}#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum ToolMentionKind { App, Mcp, Plugin, Skill, Other,}const APP_PATH_PREFIX: &str = "app://";const MCP_PATH_PREFIX: &str = "mcp://";const PLUGIN_PATH_PREFIX: &str = "plugin://";const SKILL_PATH_PREFIX: &str = "skill://";const SKILL_FILENAME: &str = "SKILL.md";pub fn tool_kind_for_path(path: &str) -> ToolMentionKind { if path.starts_with(APP_PATH_PREFIX) { ToolMentionKind::App } else if path.starts_with(MCP_PATH_PREFIX) { ToolMentionKind::Mcp } else if path.starts_with(PLUGIN_PATH_PREFIX) { ToolMentionKind::Plugin } else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) { ToolMentionKind::Skill } else { ToolMentionKind::Other }}fn is_skill_filename(path: &str) -> bool { let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path); file_name.eq_ignore_ascii_case(SKILL_FILENAME)}pub fn app_id_from_path(path: &str) -> Option<&str> { path.strip_prefix(APP_PATH_PREFIX) .filter(|value| !value.is_empty())}pub fn plugin_config_name_from_path(path: &str) -> Option<&str> { path.strip_prefix(PLUGIN_PATH_PREFIX) .filter(|value| !value.is_empty())}pub(crate) fn normalize_skill_path(path: &str) -> &str { path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path)}/// Extract `$tool-name` mentions from a single text input.////// Supports explicit resource links in the form `[$tool-name](resource path)`. When a/// resource path is present, it is captured for exact path matching while also tracking/// the name for fallback matching.pub fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL)}pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> { let text_bytes = text.as_bytes(); let mut mentioned_names: HashSet<&str> = HashSet::new(); let mut mentioned_paths: HashSet<&str> = HashSet::new(); let mut plain_names: HashSet<&str> = HashSet::new(); let mut index = 0; while index < text_bytes.len() { let byte = text_bytes[index]; if byte == b'[' && let Some((name, path, end_index)) = parse_linked_tool_mention(text, text_bytes, index, sigil) { if !is_common_env_var(name) { if !matches!( tool_kind_for_path(path), ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin ) { mentioned_names.insert(name); } mentioned_paths.insert(path); } index = end_index; continue; } if byte != sigil as u8 { index += 1; continue; } let name_start = index + 1; let Some(first_name_byte) = text_bytes.get(name_start) else { index += 1; continue; }; if !is_mention_name_char(*first_name_byte) { index += 1; continue; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) && is_mention_name_char(*next_byte) { name_end += 1; } let name = &text[name_start..name_end]; if !is_common_env_var(name) { mentioned_names.insert(name); plain_names.insert(name); } index = name_end; } ToolMentions { names: mentioned_names, paths: mentioned_paths, plain_names, }}/// Select mentioned skills while preserving the order of `skills`.fn select_skills_from_mentions( selection_context: &SkillSelectionContext<'_>, blocked_plain_names: &HashSet<String>, mentions: &ToolMentions<'_>, seen_names: &mut HashSet<String>, seen_paths: &mut HashSet<AbsolutePathBuf>, selected: &mut Vec<SkillMetadata>,) { if mentions.is_empty() { return; } let mention_skill_paths: HashSet<&str> = mentions .paths() .filter(|path| { !matches!( tool_kind_for_path(path), ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin ) }) .map(normalize_skill_path) .collect(); for skill in selection_context.skills { if selection_context .disabled_paths .contains(&skill.path_to_skills_md) || seen_paths.contains(&skill.path_to_skills_md) { continue; } let path_str = skill.path_to_skills_md.to_string_lossy(); if mention_skill_paths.contains(path_str.as_ref()) { seen_paths.insert(skill.path_to_skills_md.clone()); seen_names.insert(skill.name.clone()); selected.push(skill.clone()); } } for skill in selection_context.skills { if selection_context .disabled_paths .contains(&skill.path_to_skills_md) || seen_paths.contains(&skill.path_to_skills_md) { continue; } if blocked_plain_names.contains(skill.name.as_str()) { continue; } if !mentions.plain_names.contains(skill.name.as_str()) { continue; } let skill_count = selection_context .skill_name_counts .get(skill.name.as_str()) .copied() .unwrap_or(0); let connector_count = selection_context .connector_slug_counts .get(&skill.name.to_ascii_lowercase()) .copied() .unwrap_or(0); if skill_count != 1 || connector_count != 0 { continue; } if seen_names.insert(skill.name.clone()) { seen_paths.insert(skill.path_to_skills_md.clone()); selected.push(skill.clone()); } }}fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, sigil: char,) -> Option<(&'a str, &'a str, usize)> { let sigil_index = start + 1; if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) && is_mention_name_char(*next_byte) { name_end += 1; } if text_bytes.get(name_end) != Some(&b']') { return None; } let mut path_start = name_end + 1; while let Some(next_byte) = text_bytes.get(path_start) && next_byte.is_ascii_whitespace() { path_start += 1; } if text_bytes.get(path_start) != Some(&b'(') { return None; } let mut path_end = path_start + 1; while let Some(next_byte) = text_bytes.get(path_end) && *next_byte != b')' { path_end += 1; } if text_bytes.get(path_end) != Some(&b')') { return None; } let path = text[path_start + 1..path_end].trim(); if path.is_empty() { return None; } let name = &text[name_start..name_end]; Some((name, path, path_end + 1))}fn is_common_env_var(name: &str) -> bool { let upper = name.to_ascii_uppercase(); matches!( upper.as_str(), "PATH" | "HOME" | "USER" | "SHELL" | "PWD" | "TMPDIR" | "TEMP" | "TMP" | "LANG" | "TERM" | "XDG_CONFIG_HOME" )}#[cfg(test)]fn text_mentions_skill(text: &str, skill_name: &str) -> bool { if skill_name.is_empty() { return false; } let text_bytes = text.as_bytes(); let skill_bytes = skill_name.as_bytes(); for (index, byte) in text_bytes.iter().copied().enumerate() { if byte != b'$' { continue; } let name_start = index + 1; let Some(rest) = text_bytes.get(name_start..) else { continue; }; if !rest.starts_with(skill_bytes) { continue; } let after_index = name_start + skill_bytes.len(); let after = text_bytes.get(after_index).copied(); if after.is_none_or(|b| !is_mention_name_char(b)) { return true; } } false}fn is_mention_name_char(byte: u8) -> bool { matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':')}#[cfg(test)]#[path = "injection_tests.rs"]mod tests;