From c2fed4c2d564829659cd2c61573ac51347fc0576 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 7 Jun 2026 14:55:21 +0800 Subject: [PATCH] feat(ai-skills): add shared compose_system_prompt helper --- crates/op-ai-skills/src/compose.rs | 65 ++++++++++++++++++++++++++++++ crates/op-ai-skills/src/lib.rs | 2 + 2 files changed, 67 insertions(+) create mode 100644 crates/op-ai-skills/src/compose.rs diff --git a/crates/op-ai-skills/src/compose.rs b/crates/op-ai-skills/src/compose.rs new file mode 100644 index 000000000..d56ae5995 --- /dev/null +++ b/crates/op-ai-skills/src/compose.rs @@ -0,0 +1,65 @@ +//! The single skill-text expander shared by the desktop transport and the +//! backend proxy. Maps skill NAMES to their markdown content, concatenates +//! in order, and trims to a character budget so the same names always +//! yield byte-identical prompts on every host. + +use crate::loader::get_skill_by_name; + +/// Compose a system prompt from skill names. Unknown names are skipped +/// (the corpus is the source of truth). `budget_chars` caps the result +/// length (0 = unlimited); when exceeded, whole skills are dropped from +/// the END so earlier (higher-priority) skills survive intact. +pub fn compose_system_prompt(names: &[&str], budget_chars: usize) -> String { + let mut parts: Vec<&str> = Vec::new(); + for name in names { + if let Some(entry) = get_skill_by_name(name) { + parts.push(entry.content.as_str()); + } + } + let mut out = String::new(); + for part in parts { + let sep_len = if out.is_empty() { 0 } else { 2 }; + if budget_chars != 0 && out.len() + sep_len + part.len() > budget_chars { + break; + } + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str(part); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::loader::get_skill_by_name; + + #[test] + fn composes_known_codegen_skills_in_order() { + let prompt = compose_system_prompt(&["codegen-planning"], 0); + assert!(!prompt.is_empty()); + let direct = get_skill_by_name("codegen-planning").unwrap(); + assert!(prompt.contains(direct.content.trim_end())); + } + + #[test] + fn unknown_names_are_skipped() { + let only_known = compose_system_prompt(&["codegen-planning"], 0); + let with_bogus = compose_system_prompt(&["codegen-planning", "not-a-skill"], 0); + assert_eq!(only_known, with_bogus); + } + + #[test] + fn budget_drops_trailing_skills_whole() { + let first = get_skill_by_name("codegen-chunk").unwrap(); + let budget = first.content.len() + 1; // not enough for a second skill + let prompt = compose_system_prompt(&["codegen-chunk", "codegen-react"], budget); + assert_eq!(prompt, first.content); + } + + #[test] + fn empty_names_yield_empty_prompt() { + assert_eq!(compose_system_prompt(&[], 0), ""); + } +} diff --git a/crates/op-ai-skills/src/lib.rs b/crates/op-ai-skills/src/lib.rs index 0e5c59f74..de08417b8 100644 --- a/crates/op-ai-skills/src/lib.rs +++ b/crates/op-ai-skills/src/lib.rs @@ -19,6 +19,7 @@ use include_dir::{include_dir, Dir}; pub mod budget; +pub mod compose; pub mod frontmatter; pub mod loader; pub mod memory; @@ -27,6 +28,7 @@ pub mod resolver; pub mod style_guide; pub mod types; +pub use compose::compose_system_prompt; pub use loader::{get_skill_by_name, get_skill_registry, get_skills_by_phase, SkillEntry}; pub use resolve::resolve_skills; pub use types::{