mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why The skills extension needs to become the path that exposes local host skills without losing the behavior already owned by core skill loading. Host skill discovery is not just `$CODEX_HOME/skills`: it also includes config layers, bundled-skill settings, plugin roots, runtime extra roots, and the filesystem for the selected primary environment. Rather than making the extension reload host skills and risk drifting from that authoritative load, this PR bridges the already-loaded per-turn skills outcome into the extension. That lets the extension advertise host skills and inject explicit `$skill` prompts while preserving the same roots, disabled/hidden state, rendered paths, and environment-backed file reads that the legacy path uses. ## What Changed - Adds `HostLoadedSkills` in `core-skills` to wrap the turn's `SkillLoadOutcome` and read `SKILL.md` through the filesystem that loaded that skill. - Stores `HostLoadedSkills` in turn extension data for normal turns and review turns, so the skills extension can consume the loaded host catalog without reloading it. - Adds `HostSkillProvider` under `ext/skills/src/provider/host.rs`, mapping host-loaded skill metadata into the skills-extension catalog/read contract. - Registers the host provider by default from `codex_skills_extension::install()`. - Preserves host skill metadata such as dependencies, disabled state, hidden-from-prompt policy, and slash-normalized display paths. - Passes host-loaded skills through `SkillListQuery` and `SkillReadRequest` so explicit skill invocation reads only resources from the loaded host catalog. - Adds integration coverage for a real legacy `$CODEX_HOME/skills/.../SKILL.md` skill being listed and injected through the installed extension. ## Testing - Added `installed_extension_loads_host_skills_from_legacy_roots` in `ext/skills/tests/skills_extension.rs`. - `just test -p codex-skills-extension`
543 lines
16 KiB
Rust
543 lines
16 KiB
Rust
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_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));
|
|
match fs
|
|
.read_file_text(&skill.path_to_skills_md, /*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;
|