Skip to main content

construct/gateway/
api_skills.rs

1//! REST API handlers for skill management (`/api/skills`).
2//!
3//! Proxies to Kumiho FastAPI for persistent skill storage.  Each skill is a
4//! Kumiho item of kind `"skilldef"` in the `<memory_project>/Skills` space.
5//!
6//! ## Storage layout
7//!
8//! - **Revision metadata** — lightweight summary fields: `description`, `domain`,
9//!   `tags`, `created_by`.  No full content here to keep `list_items` under
10//!   Kumiho's 4 MB gRPC limit.
11//! - **Artifact** (`SKILL.md`) — a `file://` reference to the local markdown
12//!   file at `~/.construct/workspace/skills/<slug>.md`.  Content is read from
13//!   disk on demand for detail views / edits.
14//! - **Backward compat** — older artifacts that have `content` in their metadata
15//!   are handled transparently; the detail endpoint reads from file first, then
16//!   falls back to artifact metadata, then revision metadata.
17
18use 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
33// ── Response cache (avoids N+1 Kumiho calls on rapid dashboard polls) ───
34
35struct 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
74/// Space name within the project.
75const SKILL_SPACE_NAME: &str = "Skills";
76/// Artifact name for skill markdown content.
77const SKILL_ARTIFACT_NAME: &str = "SKILL.md";
78/// Item kind for skill definitions.
79const SKILL_KIND: &str = "skilldef";
80/// Local directory where skill markdown files are stored.
81const SKILLS_DIR: &str = ".construct/workspace/skills";
82
83/// Memory project name from config (skills are behavioral knowledge).
84fn skill_project(state: &AppState) -> String {
85    state.config.lock().kumiho.memory_project.clone()
86}
87
88/// Full space path for skills, e.g. "/CognitiveMemory/Skills".
89fn skill_space_path(state: &AppState) -> String {
90    format!("/{}/{}", skill_project(state), SKILL_SPACE_NAME)
91}
92
93// ── Query / request types ───────────────────────────────────────────────
94
95#[derive(Deserialize)]
96pub struct SkillListQuery {
97    /// Include deprecated (disabled) skills.
98    #[serde(default)]
99    pub include_deprecated: bool,
100    /// Full-text search query.  When present, uses Kumiho search instead of list.
101    pub q: Option<String>,
102    /// Page number (1-based). Default: 1.
103    pub page: Option<u32>,
104    /// Items per page. Default: 9, max: 50.
105    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// ── Response types ──────────────────────────────────────────────────────
125
126#[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
140// ── Helpers ─────────────────────────────────────────────────────────────
141
142/// Convert Kumiho error to an HTTP response.
143fn 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
167/// Build lightweight revision metadata (no content — that goes into the artifact).
168fn 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
181/// Build a `SkillResponse` from an item + its latest revision metadata.
182///
183/// For list views, `content` will be empty (artifact not fetched).
184/// For detail views, pass `artifact_content` with the full markdown.
185fn 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    // Content priority: explicit artifact_content > revision metadata (backward compat)
200    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
219/// Fetch latest revision for each item and build list responses.
220///
221/// Content is NOT fetched here (list view) — only lightweight metadata.
222/// Batches revision fetches in parallel chunks to stay under Kumiho's gRPC limit.
223const 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    // Fetch revisions in parallel chunks to avoid exceeding gRPC message
236    // size limits while keeping total latency low.
237    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    // Fetch latest for any items missing a published revision — use batch
253    // calls instead of individual per-item requests to avoid Cloudflare
254    // rate-limiting that causes the 30s gateway timeout to fire.
255    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            // List view: no artifact content, but include revision metadata content
271            // as a truncated preview for backward-compat with older skills.
272            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
281/// Resolve the local skills directory.
282fn 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
287/// Fetch full skill content from local file (via artifact location), falling
288/// back to artifact metadata, then revision metadata.
289async fn fetch_skill_content(
290    client: &super::kumiho_client::KumihoClient,
291    rev: &RevisionResponse,
292) -> String {
293    // Try artifact first (new storage)
294    if let Ok(artifacts) = client.get_artifacts(&rev.kref).await {
295        for art in &artifacts {
296            if art.name == SKILL_ARTIFACT_NAME {
297                // Priority 1: read from local file via artifact location
298                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                // Priority 2: artifact metadata (legacy/clawhub installs)
312                if let Some(content) = art.metadata.get("content") {
313                    return content.clone();
314                }
315            }
316        }
317    }
318    // Priority 3: revision metadata (very old skills)
319    rev.metadata.get("content").cloned().unwrap_or_default()
320}
321
322/// Store skill content as a local file and create a `SKILL.md` artifact
323/// pointing to it.  The file is written to `~/.construct/workspace/skills/<slug>.md`.
324async 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    // Write content to local file
337    tokio::fs::write(&file_path, content)
338        .await
339        .map_err(|e| KumihoError::Decode(format!("Failed to write skill file: {e}")))?;
340
341    // Create artifact referencing the local file
342    let metadata = HashMap::new();
343    client
344        .create_artifact(revision_kref, SKILL_ARTIFACT_NAME, &location, metadata)
345        .await?;
346    Ok(())
347}
348
349// ── Handlers ────────────────────────────────────────────────────────────
350
351/// GET /api/skills
352pub 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    // Check cache first for non-search list requests
362    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    // Search mode — use Kumiho fulltext search
389    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    // List mode — with lightweight revision metadata, direct list_items
424    // should stay well under the 4 MB gRPC limit.  Fall back to name_filter
425    // queries if the direct list fails (e.g., due to stale data).
426    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            // Fallback: name_filter queries covering all "skilldef" items
431            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    // Pagination
458    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
477/// GET /api/skills/:kref — fetch a single skill with full content.
478pub 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    // Fetch the published revision
491    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    // Fetch full content from artifact (or fallback to revision metadata)
497    let content = fetch_skill_content(&client, &rev).await;
498
499    // Build a minimal item from what we know
500    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
521/// POST /api/skills
522pub 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    // 1. Ensure project + space exist (idempotent)
536    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    // 2. Create item (slugify name for kref-safe identifier)
544    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    // 3. Create revision with lightweight metadata (no content)
554    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    // 4. Store full content as SKILL.md artifact (BEFORE publishing)
561    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    // 5. Tag as published (after artifact is attached)
567    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
578/// PUT /api/skills/:kref
579///
580/// The kref is passed as `*kref` to capture the full `kref://...` path.
581pub 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    // Derive slug from kref for file naming
595    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    // Create new revision with lightweight metadata (no content)
604    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    // Store full content as SKILL.md artifact (BEFORE publishing)
611    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    // Tag as published (after artifact is attached)
616    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
633/// POST /api/skills/deprecate
634pub 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
657/// DELETE /api/skills/:kref
658pub 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}