1use crate::core::embedding::EmbeddingService;
2use crate::core::metadata::MetadataService;
3use crate::core::service::{EmbeddingConfig, ServiceError, SkillId};
4use crate::core::skill_manager::SkillManagementService;
5use crate::core::vector_index::VectorIndexService;
6use crate::security::path::validate_path_within_root;
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11const MAX_CONTENT_SIZE: u64 = 512_000;
12const PREVIEW_BODY_LINES: usize = 20;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum ResolveScope {
17 Local,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
21#[serde(rename_all = "snake_case")]
22pub enum ContentMode {
23 #[default]
24 None,
25 Preview,
26 Full,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ResolveContextRequest {
31 pub prompt: String,
32 pub limit: usize,
33 pub scope: ResolveScope,
34 #[serde(default)]
35 pub include_content: ContentMode,
36 #[serde(default = "default_true")]
37 pub resolve_paths: bool,
38}
39
40fn default_true() -> bool {
41 true
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ResolvedSkill {
46 pub skill_id: String,
47 pub name: String,
48 pub description: String,
49 pub score: f32,
50 pub skill_md_path: Option<String>,
51 pub skill_root_path: Option<String>,
52 pub references_dir_path: Option<String>,
53 pub assets_dir_path: Option<String>,
54 pub content_preview: Option<String>,
55 pub content_full: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ResolveContextResponse {
60 pub query: String,
61 pub scope: ResolveScope,
62 pub results: Vec<ResolvedSkill>,
63 pub allowed_roots: Vec<String>,
64}
65
66pub struct ContextResolver {
67 skill_manager: Arc<dyn SkillManagementService>,
68 metadata_service: Arc<dyn MetadataService>,
69 vector_index_service: Option<Arc<dyn VectorIndexService>>,
70 embedding_config: Option<EmbeddingConfig>,
71 skills_root: PathBuf,
72}
73
74impl ContextResolver {
75 pub fn new(
76 skill_manager: Arc<dyn SkillManagementService>,
77 metadata_service: Arc<dyn MetadataService>,
78 vector_index_service: Option<Arc<dyn VectorIndexService>>,
79 embedding_config: Option<EmbeddingConfig>,
80 skills_root: PathBuf,
81 ) -> Self {
82 Self {
83 skill_manager,
84 metadata_service,
85 vector_index_service,
86 embedding_config,
87 skills_root,
88 }
89 }
90
91 pub async fn resolve_context(
92 &self,
93 request: ResolveContextRequest,
94 ) -> Result<ResolveContextResponse, ServiceError> {
95 if request.prompt.trim().is_empty() {
96 return Err(ServiceError::Validation(
97 "RESOLVE_EMPTY_PROMPT: prompt cannot be empty or whitespace-only".to_string(),
98 ));
99 }
100
101 if request.limit == 0 {
102 return Err(ServiceError::Validation(
103 "RESOLVE_LIMIT_ZERO: limit must be greater than 0".to_string(),
104 ));
105 }
106
107 let search_results = self.perform_search(&request).await?;
108
109 let mut resolved = Vec::new();
110 for (skill_id_str, name, description, score) in search_results {
111 let skill_id = match SkillId::new(skill_id_str.clone()) {
112 Ok(id) => id,
113 Err(_) => continue,
114 };
115
116 let skill_def = match self.skill_manager.get_skill(&skill_id).await {
117 Ok(Some(def)) => def,
118 Ok(None) => {
119 tracing::warn!(
120 "RESOLVE_SKILL_NOT_INDEXED: skill '{}' found in search but missing from SkillManager",
121 skill_id_str
122 );
123 continue;
124 }
125 Err(e) => {
126 tracing::warn!(
127 "RESOLVE_SKILL_NOT_INDEXED: lookup failed for '{}': {}",
128 skill_id_str,
129 e
130 );
131 continue;
132 }
133 };
134
135 let (skill_md_path, skill_root_path, references_dir_path, assets_dir_path) =
136 if request.resolve_paths {
137 self.resolve_paths(&skill_def.skill_file)?
138 } else {
139 (None, None, None, None)
140 };
141
142 let (content_preview, content_full) = self
143 .read_content(&skill_def.skill_file, &request.include_content)
144 .await?;
145
146 resolved.push(ResolvedSkill {
147 skill_id: skill_id_str,
148 name,
149 description,
150 score,
151 skill_md_path,
152 skill_root_path,
153 references_dir_path,
154 assets_dir_path,
155 content_preview,
156 content_full,
157 });
158
159 if resolved.len() >= request.limit {
160 break;
161 }
162 }
163
164 let allowed_roots = vec![self.skills_root.to_string_lossy().to_string()];
165
166 Ok(ResolveContextResponse {
167 query: request.prompt,
168 scope: request.scope,
169 results: resolved,
170 allowed_roots,
171 })
172 }
173
174 async fn perform_search(
175 &self,
176 request: &ResolveContextRequest,
177 ) -> Result<Vec<(String, String, String, f32)>, ServiceError> {
178 let embedding_attempt = self
179 .try_embedding_search(&request.prompt, request.limit)
180 .await;
181
182 if let Ok(results) = embedding_attempt {
183 return Ok(results);
184 }
185
186 self.text_search(&request.prompt, request.limit).await
187 }
188
189 async fn try_embedding_search(
190 &self,
191 prompt: &str,
192 limit: usize,
193 ) -> Result<Vec<(String, String, String, f32)>, ServiceError> {
194 let embedding_config = self
195 .embedding_config
196 .as_ref()
197 .ok_or_else(|| ServiceError::Config("RESOLVE_EMBEDDING_CONFIG_MISSING".to_string()))?;
198
199 let vector_service = self
200 .vector_index_service
201 .as_ref()
202 .ok_or_else(|| ServiceError::Config("RESOLVE_EMBEDDING_CONFIG_MISSING".to_string()))?;
203
204 let api_key = std::env::var("OPENAI_API_KEY")
205 .map_err(|_| ServiceError::Config("RESOLVE_EMBEDDING_CONFIG_MISSING".to_string()))?;
206
207 if api_key.trim().is_empty() {
208 return Err(ServiceError::Config(
209 "RESOLVE_EMBEDDING_CONFIG_MISSING".to_string(),
210 ));
211 }
212
213 let embedding_service =
214 crate::OpenAIEmbeddingService::from_config(embedding_config, api_key);
215 let query_embedding = embedding_service
216 .embed_query(prompt)
217 .await
218 .map_err(|e| ServiceError::Custom(format!("Embedding failed: {}", e)))?;
219
220 let matches = vector_service
221 .search_similar(&query_embedding, limit)
222 .await
223 .map_err(|e| ServiceError::Custom(format!("Vector search failed: {}", e)))?;
224
225 Ok(matches
226 .into_iter()
227 .map(|m| {
228 let name = m
229 .skill
230 .frontmatter_json
231 .get("name")
232 .and_then(|v| v.as_str())
233 .unwrap_or(&m.skill.id)
234 .to_string();
235 let description = m
236 .skill
237 .frontmatter_json
238 .get("description")
239 .and_then(|v| v.as_str())
240 .unwrap_or("")
241 .to_string();
242 (m.skill.id, name, description, m.similarity)
243 })
244 .collect())
245 }
246
247 async fn text_search(
248 &self,
249 prompt: &str,
250 limit: usize,
251 ) -> Result<Vec<(String, String, String, f32)>, ServiceError> {
252 let meta_list = self.metadata_service.search_skills(prompt).await?;
253
254 Ok(meta_list
255 .into_iter()
256 .take(limit)
257 .map(|m| {
258 let name = if m.name.is_empty() {
259 m.id.as_str().to_string()
260 } else {
261 m.name
262 };
263 (m.id.into_string(), name, m.description, 1.0)
264 })
265 .collect())
266 }
267
268 #[allow(clippy::type_complexity)]
269 fn resolve_paths(
270 &self,
271 skill_file: &Path,
272 ) -> Result<
273 (
274 Option<String>,
275 Option<String>,
276 Option<String>,
277 Option<String>,
278 ),
279 ServiceError,
280 > {
281 let skill_md = self.canonicalize_within_root(skill_file)?;
282 let root = skill_file.parent();
283 let skill_root = match root {
284 Some(r) => self.canonicalize_within_root(r)?,
285 None => None,
286 };
287
288 let references_dir = root.and_then(|r| {
289 let p = r.join("references");
290 if p.is_dir() {
291 self.canonicalize_within_root(&p).ok().flatten()
292 } else {
293 None
294 }
295 });
296 let assets_dir = root.and_then(|r| {
297 let p = r.join("assets");
298 if p.is_dir() {
299 self.canonicalize_within_root(&p).ok().flatten()
300 } else {
301 None
302 }
303 });
304
305 Ok((skill_md, skill_root, references_dir, assets_dir))
306 }
307
308 fn canonicalize_within_root(&self, path: &Path) -> Result<Option<String>, ServiceError> {
309 match validate_path_within_root(path, &self.skills_root) {
310 Ok(canonical) => Ok(Some(canonical.to_string_lossy().to_string())),
311 Err(e) => {
312 tracing::warn!(
313 "RESOLVE_PATH_ESCAPE: path '{}' validation failed: {}",
314 path.display(),
315 e
316 );
317 Ok(None)
318 }
319 }
320 }
321
322 async fn read_content(
323 &self,
324 skill_file: &Path,
325 mode: &ContentMode,
326 ) -> Result<(Option<String>, Option<String>), ServiceError> {
327 if mode == &ContentMode::None {
328 return Ok((None, None));
329 }
330
331 if !skill_file.exists() {
332 tracing::warn!(
333 "RESOLVE_READ_FAILED: skill file does not exist: {}",
334 skill_file.display()
335 );
336 return Ok((None, None));
337 }
338
339 let metadata = match tokio::fs::metadata(skill_file).await {
340 Ok(m) => m,
341 Err(e) => {
342 tracing::warn!(
343 "RESOLVE_READ_FAILED: cannot stat '{}': {}",
344 skill_file.display(),
345 e
346 );
347 return Ok((None, None));
348 }
349 };
350
351 let content = match tokio::fs::read_to_string(skill_file).await {
352 Ok(c) => c,
353 Err(e) => {
354 tracing::warn!(
355 "RESOLVE_READ_FAILED: cannot read '{}': {}",
356 skill_file.display(),
357 e
358 );
359 return Ok((None, None));
360 }
361 };
362
363 match mode {
364 ContentMode::None => Ok((None, None)),
365 ContentMode::Preview => {
366 let preview = self.extract_preview(&content);
367 Ok((Some(preview), None))
368 }
369 ContentMode::Full => {
370 if metadata.len() > MAX_CONTENT_SIZE {
371 tracing::warn!(
372 "RESOLVE_CONTENT_TOO_LARGE: '{}' exceeds {} bytes",
373 skill_file.display(),
374 MAX_CONTENT_SIZE
375 );
376 Ok((None, None))
377 } else {
378 Ok((None, Some(content)))
379 }
380 }
381 }
382 }
383
384 fn extract_preview(&self, content: &str) -> String {
385 let lines: Vec<&str> = content.lines().collect();
386 let mut frontmatter_end = 0usize;
387 let mut in_fm = false;
388 let mut fm_started = false;
389
390 for (i, line) in lines.iter().enumerate() {
391 if line.trim() == "---" {
392 if !fm_started {
393 fm_started = true;
394 in_fm = true;
395 } else if in_fm {
396 frontmatter_end = i + 1;
397 in_fm = false;
398 }
399 }
400 }
401
402 let start = if frontmatter_end > 0 {
403 frontmatter_end
404 } else {
405 0
406 };
407
408 let mut result_lines: Vec<&str> = Vec::new();
409
410 if frontmatter_end > 0 {
411 result_lines.extend_from_slice(&lines[..frontmatter_end]);
412 }
413
414 let body_lines: Vec<&&str> = lines[start..].iter().take(PREVIEW_BODY_LINES).collect();
415 result_lines.extend(body_lines.iter().map(|s| **s));
416
417 result_lines.join("\n")
418 }
419}
420
421#[cfg(test)]
422#[allow(clippy::unwrap_used)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn test_content_mode_default_is_none() {
428 assert_eq!(ContentMode::default(), ContentMode::None);
429 }
430
431 #[test]
432 fn test_resolve_scope_serde() {
433 let scope = ResolveScope::Local;
434 let json = serde_json::to_string(&scope).unwrap();
435 assert_eq!(json, "\"local\"");
436 let back: ResolveScope = serde_json::from_str(&json).unwrap();
437 assert_eq!(back, ResolveScope::Local);
438 }
439
440 #[test]
441 fn test_content_mode_serde() {
442 let mode = ContentMode::Preview;
443 let json = serde_json::to_string(&mode).unwrap();
444 assert_eq!(json, "\"preview\"");
445 let back: ContentMode = serde_json::from_str(&json).unwrap();
446 assert_eq!(back, ContentMode::Preview);
447 }
448
449 #[test]
450 fn test_extract_preview_frontmatter_plus_body() {
451 let content = "---\nname: test\ndescription: a test\n---\nline1\nline2\nline3\n";
452 let temp_dir = tempfile::TempDir::new().unwrap();
453 let skills_root = temp_dir.path().to_path_buf();
454
455 let resolver = ContextResolver::new(
456 Arc::new(crate::core::skill_manager::SkillManager::new()),
457 Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
458 crate::core::skill_manager::SkillManager::new(),
459 ))),
460 None,
461 None,
462 skills_root,
463 );
464
465 let preview = resolver.extract_preview(content);
466 assert!(preview.contains("name: test"));
467 assert!(preview.contains("line1"));
468 assert!(preview.contains("line2"));
469 assert!(preview.contains("line3"));
470 }
471
472 #[test]
473 fn test_extract_preview_truncates_at_20_body_lines() {
474 let body: Vec<String> = (1..=50).map(|i| format!("body line {}", i)).collect();
475 let content = format!("---\nname: test\n---\n{}", body.join("\n"));
476
477 let temp_dir = tempfile::TempDir::new().unwrap();
478 let skills_root = temp_dir.path().to_path_buf();
479
480 let resolver = ContextResolver::new(
481 Arc::new(crate::core::skill_manager::SkillManager::new()),
482 Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
483 crate::core::skill_manager::SkillManager::new(),
484 ))),
485 None,
486 None,
487 skills_root,
488 );
489
490 let preview = resolver.extract_preview(&content);
491 assert!(preview.contains("body line 20"));
492 assert!(!preview.contains("body line 21"));
493 }
494
495 #[tokio::test]
496 async fn test_resolve_empty_prompt_returns_error() {
497 let temp_dir = tempfile::TempDir::new().unwrap();
498 let resolver = ContextResolver::new(
499 Arc::new(crate::core::skill_manager::SkillManager::new()),
500 Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
501 crate::core::skill_manager::SkillManager::new(),
502 ))),
503 None,
504 None,
505 temp_dir.path().to_path_buf(),
506 );
507
508 let request = ResolveContextRequest {
509 prompt: " ".to_string(),
510 limit: 5,
511 scope: ResolveScope::Local,
512 include_content: ContentMode::None,
513 resolve_paths: true,
514 };
515
516 let result = resolver.resolve_context(request).await;
517 assert!(result.is_err());
518 let err = result.unwrap_err();
519 assert!(err.to_string().contains("RESOLVE_EMPTY_PROMPT"));
520 }
521
522 #[tokio::test]
523 async fn test_resolve_limit_zero_returns_error() {
524 let temp_dir = tempfile::TempDir::new().unwrap();
525 let resolver = ContextResolver::new(
526 Arc::new(crate::core::skill_manager::SkillManager::new()),
527 Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
528 crate::core::skill_manager::SkillManager::new(),
529 ))),
530 None,
531 None,
532 temp_dir.path().to_path_buf(),
533 );
534
535 let request = ResolveContextRequest {
536 prompt: "test".to_string(),
537 limit: 0,
538 scope: ResolveScope::Local,
539 include_content: ContentMode::None,
540 resolve_paths: true,
541 };
542
543 let result = resolver.resolve_context(request).await;
544 assert!(result.is_err());
545 let err = result.unwrap_err();
546 assert!(err.to_string().contains("RESOLVE_LIMIT_ZERO"));
547 }
548
549 #[tokio::test]
550 async fn test_resolve_context_returns_allowed_roots() {
551 let temp_dir = tempfile::TempDir::new().unwrap();
552 let skills_root = temp_dir.path().to_path_buf();
553
554 let resolver = ContextResolver::new(
555 Arc::new(crate::core::skill_manager::SkillManager::new()),
556 Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
557 crate::core::skill_manager::SkillManager::new(),
558 ))),
559 None,
560 None,
561 skills_root.clone(),
562 );
563
564 let request = ResolveContextRequest {
565 prompt: "anything".to_string(),
566 limit: 5,
567 scope: ResolveScope::Local,
568 include_content: ContentMode::None,
569 resolve_paths: true,
570 };
571
572 let response = resolver.resolve_context(request).await.unwrap();
573 assert_eq!(response.allowed_roots.len(), 1);
574 assert_eq!(response.query, "anything");
575 assert!(response.results.is_empty());
576 }
577
578 #[tokio::test]
579 async fn test_resolve_paths_within_root() {
580 let temp_dir = tempfile::TempDir::new().unwrap();
581 let skills_dir = temp_dir.path().join("skills");
582 std::fs::create_dir_all(&skills_dir).unwrap();
583
584 let skill_dir = skills_dir.join("my-skill");
585 std::fs::create_dir_all(&skill_dir).unwrap();
586 let skill_file = skill_dir.join("SKILL.md");
587 std::fs::write(
588 &skill_file,
589 "---\nname: test\ndescription: test\n---\ncontent",
590 )
591 .unwrap();
592
593 let resolver = ContextResolver::new(
594 Arc::new(crate::core::skill_manager::SkillManager::new()),
595 Arc::new(crate::core::metadata::MetadataServiceImpl::new(Arc::new(
596 crate::core::skill_manager::SkillManager::new(),
597 ))),
598 None,
599 None,
600 skills_dir.clone(),
601 );
602
603 let (md_path, root_path, refs, assets) = resolver.resolve_paths(&skill_file).unwrap();
604
605 assert!(md_path.is_some());
606 let md = md_path.unwrap();
607 assert!(md.contains("my-skill"));
608
609 assert!(root_path.is_some());
610 assert!(refs.is_none());
611 assert!(assets.is_none());
612 }
613}