1use super::AppState;
19use super::api::require_auth;
20use super::api_agents::build_kumiho_client;
21use super::kumiho_client::{ItemResponse, KumihoError, RevisionResponse, slugify};
22use axum::{
23 extract::{Path, Query, State},
24 http::{HeaderMap, StatusCode},
25 response::{IntoResponse, Json},
26};
27use parking_lot::Mutex;
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::sync::OnceLock;
31use std::time::Instant;
32
33struct SkillCache {
36 skills: Vec<SkillResponse>,
37 include_deprecated: bool,
38 fetched_at: Instant,
39}
40
41static SKILL_CACHE: OnceLock<Mutex<Option<SkillCache>>> = OnceLock::new();
42const CACHE_TTL_SECS: u64 = 30;
43
44fn get_cached_skills(include_deprecated: bool) -> Option<Vec<SkillResponse>> {
45 let lock = SKILL_CACHE.get_or_init(|| Mutex::new(None));
46 let cache = lock.lock();
47 if let Some(ref c) = *cache {
48 if c.include_deprecated == include_deprecated
49 && c.fetched_at.elapsed().as_secs() < CACHE_TTL_SECS
50 {
51 return Some(c.skills.clone());
52 }
53 }
54 None
55}
56
57fn set_cached_skills(skills: &[SkillResponse], include_deprecated: bool) {
58 let lock = SKILL_CACHE.get_or_init(|| Mutex::new(None));
59 let mut cache = lock.lock();
60 *cache = Some(SkillCache {
61 skills: skills.to_vec(),
62 include_deprecated,
63 fetched_at: Instant::now(),
64 });
65}
66
67pub fn invalidate_skill_cache() {
68 if let Some(lock) = SKILL_CACHE.get() {
69 let mut cache = lock.lock();
70 *cache = None;
71 }
72}
73
74const SKILL_SPACE_NAME: &str = "Skills";
76const SKILL_ARTIFACT_NAME: &str = "SKILL.md";
78const SKILL_KIND: &str = "skilldef";
80const SKILLS_DIR: &str = ".construct/workspace/skills";
82
83fn skill_project(state: &AppState) -> String {
85 state.config.lock().kumiho.memory_project.clone()
86}
87
88fn skill_space_path(state: &AppState) -> String {
90 format!("/{}/{}", skill_project(state), SKILL_SPACE_NAME)
91}
92
93#[derive(Deserialize)]
96pub struct SkillListQuery {
97 #[serde(default)]
99 pub include_deprecated: bool,
100 pub q: Option<String>,
102 pub page: Option<u32>,
104 pub per_page: Option<u32>,
106}
107
108#[derive(Deserialize)]
109pub struct CreateSkillBody {
110 pub name: String,
111 pub description: String,
112 pub content: String,
113 pub domain: String,
114 #[serde(default)]
115 pub tags: Option<Vec<String>>,
116}
117
118#[derive(Deserialize)]
119pub struct DeprecateBody {
120 pub kref: String,
121 pub deprecated: bool,
122}
123
124#[derive(Serialize, Clone)]
127pub struct SkillResponse {
128 pub kref: String,
129 pub name: String,
130 pub item_name: String,
131 pub deprecated: bool,
132 pub created_at: Option<String>,
133 pub description: String,
134 pub content: String,
135 pub domain: String,
136 pub tags: Vec<String>,
137 pub revision_number: i32,
138}
139
140fn kumiho_err(e: KumihoError) -> (StatusCode, Json<serde_json::Value>) {
144 match &e {
145 KumihoError::Unreachable(_) => (
146 StatusCode::SERVICE_UNAVAILABLE,
147 Json(serde_json::json!({ "error": format!("Kumiho service unavailable: {e}") })),
148 ),
149 KumihoError::Api { status, body } => {
150 let code = if *status == 401 || *status == 403 {
151 StatusCode::BAD_GATEWAY
152 } else {
153 StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY)
154 };
155 (
156 code,
157 Json(serde_json::json!({ "error": format!("Kumiho upstream: {body}") })),
158 )
159 }
160 KumihoError::Decode(msg) => (
161 StatusCode::BAD_GATEWAY,
162 Json(serde_json::json!({ "error": format!("Bad response from Kumiho: {msg}") })),
163 ),
164 }
165}
166
167fn skill_revision_metadata(body: &CreateSkillBody) -> HashMap<String, String> {
169 let mut meta = HashMap::new();
170 meta.insert("description".to_string(), body.description.clone());
171 meta.insert("domain".to_string(), body.domain.clone());
172 meta.insert("created_by".to_string(), "construct-dashboard".to_string());
173 if let Some(ref tags) = body.tags {
174 if !tags.is_empty() {
175 meta.insert("tags".to_string(), tags.join(","));
176 }
177 }
178 meta
179}
180
181fn to_skill_response(
186 item: &ItemResponse,
187 rev: Option<&RevisionResponse>,
188 artifact_content: Option<&str>,
189) -> SkillResponse {
190 let meta = rev.map(|r| &r.metadata);
191 let get = |key: &str| -> String { meta.and_then(|m| m.get(key)).cloned().unwrap_or_default() };
192 let tags_str = get("tags");
193 let tags: Vec<String> = if tags_str.is_empty() {
194 Vec::new()
195 } else {
196 tags_str.split(',').map(|s| s.trim().to_string()).collect()
197 };
198
199 let content = match artifact_content {
201 Some(c) => c.to_string(),
202 None => get("content"),
203 };
204
205 SkillResponse {
206 kref: item.kref.clone(),
207 name: item.item_name.clone(),
208 item_name: item.item_name.clone(),
209 deprecated: item.deprecated,
210 created_at: item.created_at.clone(),
211 description: get("description"),
212 content,
213 domain: get("domain"),
214 tags,
215 revision_number: rev.map(|r| r.number).unwrap_or(0),
216 }
217}
218
219const BATCH_CHUNK_SIZE: usize = 20;
224
225async fn enrich_items(
226 client: &super::kumiho_client::KumihoClient,
227 items: Vec<ItemResponse>,
228) -> Vec<SkillResponse> {
229 if items.is_empty() {
230 return Vec::new();
231 }
232
233 let krefs: Vec<String> = items.iter().map(|i| i.kref.clone()).collect();
234
235 let mut set = tokio::task::JoinSet::new();
238 for chunk in krefs.chunks(BATCH_CHUNK_SIZE) {
239 let chunk_vec: Vec<String> = chunk.to_vec();
240 let c = client.clone();
241 set.spawn(async move { c.batch_get_revisions(&chunk_vec, "published").await });
242 }
243
244 let mut rev_map: std::collections::HashMap<String, RevisionResponse> =
245 std::collections::HashMap::new();
246 while let Some(res) = set.join_next().await {
247 if let Ok(Ok(batch)) = res {
248 rev_map.extend(batch);
249 }
250 }
251
252 let missing: Vec<String> = krefs
256 .iter()
257 .filter(|k| !rev_map.contains_key(*k))
258 .cloned()
259 .collect();
260 if !missing.is_empty() {
261 if let Ok(latest_map) = client.batch_get_revisions(&missing, "latest").await {
262 rev_map.extend(latest_map);
263 }
264 }
265
266 items
267 .iter()
268 .map(|item| {
269 let rev = rev_map.get(&item.kref);
270 let mut skill = to_skill_response(item, rev, None);
273 if skill.content.len() > 200 {
274 skill.content = format!("{}...", &skill.content[..200]);
275 }
276 skill
277 })
278 .collect()
279}
280
281fn skills_dir() -> std::path::PathBuf {
283 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
284 std::path::PathBuf::from(home).join(SKILLS_DIR)
285}
286
287async fn fetch_skill_content(
290 client: &super::kumiho_client::KumihoClient,
291 rev: &RevisionResponse,
292) -> String {
293 if let Ok(artifacts) = client.get_artifacts(&rev.kref).await {
295 for art in &artifacts {
296 if art.name == SKILL_ARTIFACT_NAME {
297 let location = &art.location;
299 let file_path = if let Some(path) = location.strip_prefix("file://") {
300 Some(std::path::PathBuf::from(path))
301 } else if location.starts_with('/') {
302 Some(std::path::PathBuf::from(location))
303 } else {
304 None
305 };
306 if let Some(ref path) = file_path {
307 if let Ok(content) = tokio::fs::read_to_string(path).await {
308 return content;
309 }
310 }
311 if let Some(content) = art.metadata.get("content") {
313 return content.clone();
314 }
315 }
316 }
317 }
318 rev.metadata.get("content").cloned().unwrap_or_default()
320}
321
322async fn store_skill_artifact(
325 client: &super::kumiho_client::KumihoClient,
326 revision_kref: &str,
327 _item_kref: &str,
328 slug: &str,
329 content: &str,
330) -> std::result::Result<(), KumihoError> {
331 let dir = skills_dir();
332 let _ = tokio::fs::create_dir_all(&dir).await;
333 let file_path = dir.join(format!("{slug}.md"));
334 let location = format!("file://{}", file_path.display());
335
336 tokio::fs::write(&file_path, content)
338 .await
339 .map_err(|e| KumihoError::Decode(format!("Failed to write skill file: {e}")))?;
340
341 let metadata = HashMap::new();
343 client
344 .create_artifact(revision_kref, SKILL_ARTIFACT_NAME, &location, metadata)
345 .await?;
346 Ok(())
347}
348
349pub async fn handle_list_skills(
353 State(state): State<AppState>,
354 headers: HeaderMap,
355 Query(query): Query<SkillListQuery>,
356) -> impl IntoResponse {
357 if let Err(e) = require_auth(&state, &headers) {
358 return e.into_response();
359 }
360
361 if query.q.is_none() {
363 if let Some(cached) = get_cached_skills(query.include_deprecated) {
364 let total_count = cached.len() as u32;
365 let per_page = query.per_page.unwrap_or(9).min(50).max(1);
366 let page = query.page.unwrap_or(1).max(1);
367 let skip = ((page - 1) * per_page) as usize;
368 let skills: Vec<_> = cached
369 .into_iter()
370 .skip(skip)
371 .take(per_page as usize)
372 .collect();
373 return Json(serde_json::json!({
374 "skills": skills,
375 "total_count": total_count,
376 "page": page,
377 "per_page": per_page,
378 }))
379 .into_response();
380 }
381 }
382
383 let client = build_kumiho_client(&state);
384
385 let project_name = skill_project(&state);
386 let space_path = skill_space_path(&state);
387
388 if let Some(ref q) = query.q {
390 let items_result = client
391 .search_items(q, &project_name, SKILL_KIND, query.include_deprecated)
392 .await
393 .map(|results| results.into_iter().map(|sr| sr.item).collect::<Vec<_>>());
394
395 return match items_result {
396 Ok(items) => {
397 let skills = enrich_items(&client, items).await;
398 let total_count = skills.len() as u32;
399 let per_page = query.per_page.unwrap_or(9).min(50).max(1);
400 let page = query.page.unwrap_or(1).max(1);
401 let skip = ((page - 1) * per_page) as usize;
402 let skills: Vec<_> = skills
403 .into_iter()
404 .skip(skip)
405 .take(per_page as usize)
406 .collect();
407 Json(serde_json::json!({
408 "skills": skills,
409 "total_count": total_count,
410 "page": page,
411 "per_page": per_page,
412 }))
413 .into_response()
414 }
415 Err(ref e) if matches!(e, KumihoError::Api { status: 404, .. }) => Json(
416 serde_json::json!({ "skills": [], "total_count": 0, "page": 1, "per_page": 9 }),
417 )
418 .into_response(),
419 Err(e) => kumiho_err(e).into_response(),
420 };
421 }
422
423 let include_deprecated = query.include_deprecated;
427 let items: Vec<ItemResponse> = match client.list_items(&space_path, include_deprecated).await {
428 Ok(items) => items,
429 Err(_) => {
430 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
432 let mut fallback_items: Vec<ItemResponse> = Vec::new();
433 for filter in &["a", "d"] {
434 if let Ok(batch) = client
435 .list_items_filtered(&space_path, filter, include_deprecated)
436 .await
437 {
438 for item in batch {
439 if seen.insert(item.kref.clone()) {
440 fallback_items.push(item);
441 }
442 }
443 }
444 }
445 fallback_items
446 }
447 };
448
449 if items.is_empty() {
450 let _ = client.ensure_project(&project_name).await;
451 let _ = client.ensure_space(&project_name, SKILL_SPACE_NAME).await;
452 }
453
454 let skills = enrich_items(&client, items).await;
455 set_cached_skills(&skills, query.include_deprecated);
456
457 let total_count = skills.len() as u32;
459 let per_page = query.per_page.unwrap_or(9).min(50).max(1);
460 let page = query.page.unwrap_or(1).max(1);
461 let skip = ((page - 1) * per_page) as usize;
462 let skills: Vec<_> = skills
463 .into_iter()
464 .skip(skip)
465 .take(per_page as usize)
466 .collect();
467
468 Json(serde_json::json!({
469 "skills": skills,
470 "total_count": total_count,
471 "page": page,
472 "per_page": per_page,
473 }))
474 .into_response()
475}
476
477pub async fn handle_get_skill(
479 State(state): State<AppState>,
480 headers: HeaderMap,
481 Path(kref): Path<String>,
482) -> impl IntoResponse {
483 if let Err(e) = require_auth(&state, &headers) {
484 return e.into_response();
485 }
486
487 let kref = format!("kref://{kref}");
488 let client = build_kumiho_client(&state);
489
490 let rev = match client.get_published_or_latest(&kref).await {
492 Ok(rev) => rev,
493 Err(e) => return kumiho_err(e).into_response(),
494 };
495
496 let content = fetch_skill_content(&client, &rev).await;
498
499 let item_name = kref
501 .rsplit('/')
502 .next()
503 .unwrap_or_default()
504 .trim_end_matches(".skilldef")
505 .trim_end_matches(".skill")
506 .to_string();
507 let item = ItemResponse {
508 kref: kref.clone(),
509 name: item_name.clone(),
510 item_name,
511 kind: SKILL_KIND.to_string(),
512 deprecated: false,
513 created_at: None,
514 metadata: HashMap::new(),
515 };
516
517 let skill = to_skill_response(&item, Some(&rev), Some(&content));
518 Json(serde_json::json!({ "skill": skill })).into_response()
519}
520
521pub async fn handle_create_skill(
523 State(state): State<AppState>,
524 headers: HeaderMap,
525 Json(body): Json<CreateSkillBody>,
526) -> impl IntoResponse {
527 if let Err(e) = require_auth(&state, &headers) {
528 return e.into_response();
529 }
530
531 let client = build_kumiho_client(&state);
532 let project_name = skill_project(&state);
533 let space_path = skill_space_path(&state);
534
535 if let Err(e) = client.ensure_project(&project_name).await {
537 return kumiho_err(e).into_response();
538 }
539 if let Err(e) = client.ensure_space(&project_name, SKILL_SPACE_NAME).await {
540 return kumiho_err(e).into_response();
541 }
542
543 let slug = slugify(&body.name);
545 let item = match client
546 .create_item(&space_path, &slug, SKILL_KIND, HashMap::new())
547 .await
548 {
549 Ok(item) => item,
550 Err(e) => return kumiho_err(e).into_response(),
551 };
552
553 let metadata = skill_revision_metadata(&body);
555 let rev = match client.create_revision(&item.kref, metadata).await {
556 Ok(rev) => rev,
557 Err(e) => return kumiho_err(e).into_response(),
558 };
559
560 if let Err(e) = store_skill_artifact(&client, &rev.kref, &item.kref, &slug, &body.content).await
562 {
563 tracing::warn!("Failed to create SKILL.md artifact for {}: {e}", item.kref);
564 }
565
566 let _ = client.tag_revision(&rev.kref, "published").await;
568
569 invalidate_skill_cache();
570 let skill = to_skill_response(&item, Some(&rev), Some(&body.content));
571 (
572 StatusCode::CREATED,
573 Json(serde_json::json!({ "skill": skill })),
574 )
575 .into_response()
576}
577
578pub async fn handle_update_skill(
582 State(state): State<AppState>,
583 headers: HeaderMap,
584 Path(kref): Path<String>,
585 Json(body): Json<CreateSkillBody>,
586) -> impl IntoResponse {
587 if let Err(e) = require_auth(&state, &headers) {
588 return e.into_response();
589 }
590
591 let kref = format!("kref://{kref}");
592 let client = build_kumiho_client(&state);
593
594 let slug = kref
596 .rsplit('/')
597 .next()
598 .unwrap_or_default()
599 .trim_end_matches(".skilldef")
600 .trim_end_matches(".skill")
601 .to_string();
602
603 let metadata = skill_revision_metadata(&body);
605 let rev = match client.create_revision(&kref, metadata).await {
606 Ok(rev) => rev,
607 Err(e) => return kumiho_err(e).into_response(),
608 };
609
610 if let Err(e) = store_skill_artifact(&client, &rev.kref, &kref, &slug, &body.content).await {
612 tracing::warn!("Failed to create SKILL.md artifact for {kref}: {e}");
613 }
614
615 let _ = client.tag_revision(&rev.kref, "published").await;
617
618 invalidate_skill_cache();
619
620 let item = ItemResponse {
621 kref: kref.clone(),
622 name: body.name.clone(),
623 item_name: body.name.clone(),
624 kind: SKILL_KIND.to_string(),
625 deprecated: false,
626 created_at: None,
627 metadata: HashMap::new(),
628 };
629 let skill = to_skill_response(&item, Some(&rev), Some(&body.content));
630 Json(serde_json::json!({ "skill": skill })).into_response()
631}
632
633pub async fn handle_deprecate_skill(
635 State(state): State<AppState>,
636 headers: HeaderMap,
637 Json(body): Json<DeprecateBody>,
638) -> impl IntoResponse {
639 if let Err(e) = require_auth(&state, &headers) {
640 return e.into_response();
641 }
642
643 let kref = body.kref.clone();
644 let client = build_kumiho_client(&state);
645
646 match client.deprecate_item(&kref, body.deprecated).await {
647 Ok(item) => {
648 invalidate_skill_cache();
649 let rev = client.get_published_or_latest(&kref).await.ok();
650 let skill = to_skill_response(&item, rev.as_ref(), None);
651 Json(serde_json::json!({ "skill": skill })).into_response()
652 }
653 Err(e) => kumiho_err(e).into_response(),
654 }
655}
656
657pub async fn handle_delete_skill(
659 State(state): State<AppState>,
660 headers: HeaderMap,
661 Path(kref): Path<String>,
662) -> impl IntoResponse {
663 if let Err(e) = require_auth(&state, &headers) {
664 return e.into_response();
665 }
666
667 let kref = format!("kref://{kref}");
668 let client = build_kumiho_client(&state);
669
670 match client.delete_item(&kref).await {
671 Ok(()) => {
672 invalidate_skill_cache();
673 StatusCode::NO_CONTENT.into_response()
674 }
675 Err(e) => kumiho_err(e).into_response(),
676 }
677}