Files

292 lines
9.6 KiB
Go

package pg
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/lib/pq"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file.
var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`)
func (s *PGSkillStore) LoadSkill(ctx context.Context, name string) (string, bool) {
var slug string
var version int
var filePath *string
// Tenant filter: system skills visible globally, custom skills scoped to tenant.
q := "SELECT slug, version, file_path FROM skills WHERE slug = $1 AND status = 'active'"
args := []any{name}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = true OR tenant_id = $2)"
args = append(args, tid)
}
err := s.db.QueryRowContext(ctx, q, args...).Scan(&slug, &version, &filePath)
if err != nil {
return "", false
}
info := buildSkillInfo("", "", slug, nil, version, s.baseDir, filePath)
content, err := readSkillFile(info.Path)
if err != nil {
return "", false
}
// Resolve {baseDir} placeholder so SKILL.md script-invocation lines hold
// absolute paths regardless of agent CWD. Mirrors skills.Loader.LoadSkill
// substitution; without it, agents reading SKILL.md via this RPC see the
// literal placeholder.
content = strings.ReplaceAll(content, "{baseDir}", info.BaseDir)
return content, true
}
func (s *PGSkillStore) LoadForContext(ctx context.Context, allowList []string) string {
skills := s.FilterSkills(ctx, allowList)
if len(skills) == 0 {
return ""
}
var parts []string
for _, sk := range skills {
content, ok := s.LoadSkill(ctx, sk.Name)
if !ok {
continue
}
parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", sk.Name, content))
}
if len(parts) == 0 {
return ""
}
var result strings.Builder
result.WriteString("## Available Skills\n\n")
for i, p := range parts {
if i > 0 {
result.WriteString("\n\n---\n\n")
}
result.WriteString(p)
}
return result.String()
}
func (s *PGSkillStore) BuildSummary(ctx context.Context, allowList []string) string {
skills := s.FilterSkills(ctx, allowList)
if len(skills) == 0 {
return ""
}
var result strings.Builder
result.WriteString("<available_skills>\n")
for _, sk := range skills {
result.WriteString(" <skill>\n")
result.WriteString(fmt.Sprintf(" <name>%s</name>\n", sk.Name))
result.WriteString(fmt.Sprintf(" <description>%s</description>\n", sk.Description))
result.WriteString(fmt.Sprintf(" <location>%s</location>\n", sk.Path))
result.WriteString(" </skill>\n")
}
result.WriteString("</available_skills>")
return result.String()
}
func (s *PGSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) {
baseSelect := `SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, frontmatter, file_path
FROM skills WHERE `
scope := ""
args := []any{}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
args = append(args, tid)
scope = fmt.Sprintf(" AND (is_system = true OR tenant_id = $%d)", len(args))
}
scan := func(q string, qArgs ...any) (*store.SkillInfo, bool) {
var id uuid.UUID
var skillName, slug, visibility, ownerID, status string
var desc *string
var tags []string
var version int
var isSystem, enabled bool
var depsRaw, fmRaw []byte
var filePath *string
err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath)
if err != nil {
return nil, false
}
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath)
info.Visibility = visibility
info.OwnerID = ownerID
info.Tags = tags
info.IsSystem = isSystem
info.Status = status
info.Enabled = enabled
info.Author = parseFrontmatterAuthor(fmRaw)
info.CreatorAgent = parseFrontmatterCreatorAgent(fmRaw)
info.MissingDeps = parseDepsColumn(depsRaw)
enriched := []store.SkillInfo{info}
s.attachSkillAgentMetadata(ctx, enriched)
return &enriched[0], true
}
// "archived" means a system skill has missing dependencies (see seeder.go); it does not
// mean the skill is hidden from lookup. ListSkills() and GetSkillByID() both include
// active + archived skills, so name/slug lookup must match to avoid 404s on archived
// skills that are otherwise still enabled.
if id, err := uuid.Parse(name); err == nil {
return scan(baseSelect+"id = $"+fmt.Sprint(len(args)+1)+" AND status IN ('active', 'archived')"+scope, append(args, id)...)
}
if info, ok := scan(baseSelect+"slug = $"+fmt.Sprint(len(args)+1)+" AND status IN ('active', 'archived')"+scope, append(args, name)...); ok {
return info, true
}
return scan(baseSelect+"name = $"+fmt.Sprint(len(args)+1)+" AND status IN ('active', 'archived')"+scope+" ORDER BY id LIMIT 1", append(args, name)...)
}
func (s *PGSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo {
all := s.ListSkills(ctx)
var filtered []store.SkillInfo
if allowList == nil {
// No allowList → return all enabled skills visible to the caller
// (for agent injection). Private skills owned by others are hidden
// so they don't leak across tenant members.
for _, sk := range all {
if sk.Enabled && store.IsSkillVisibleTo(ctx, sk.OwnerID, sk.Visibility, sk.IsSystem) {
filtered = append(filtered, sk)
}
}
return filtered
}
if len(allowList) == 0 {
return nil
}
allowed := make(map[string]bool, len(allowList))
for _, name := range allowList {
allowed[name] = true
}
for _, sk := range all {
if sk.Enabled && allowed[sk.Slug] {
filtered = append(filtered, sk)
}
}
return filtered
}
// GetSkillByID returns a SkillInfo for any skill by UUID, regardless of status or enabled flag.
// Used by admin operations (e.g. toggle) that need full skill info.
// Tenant filter: system skills visible globally, custom skills scoped to tenant.
func (s *PGSkillStore) GetSkillByID(ctx context.Context, id uuid.UUID) (store.SkillInfo, bool) {
var name, slug, visibility, ownerID, status string
var desc *string
var tags []string
var version int
var isSystem, enabled bool
var depsRaw []byte
var filePath *string
q := `SELECT name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
FROM skills WHERE id = $1`
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = true OR tenant_id = $2)"
args = append(args, tid)
}
err := s.db.QueryRowContext(ctx, q, args...).Scan(&name, &slug, &desc, &visibility, &ownerID, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &filePath)
if err != nil {
return store.SkillInfo{}, false
}
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
info.Visibility = visibility
info.OwnerID = ownerID
info.Tags = tags
info.IsSystem = isSystem
info.Status = status
info.Enabled = enabled
info.MissingDeps = parseDepsColumn(depsRaw)
return info, true
}
// GetSkillOwnerID returns the owner_id for a skill by UUID.
// Returns ("", false) if the skill does not exist.
func (s *PGSkillStore) GetSkillOwnerID(ctx context.Context, id uuid.UUID) (string, bool) {
q := "SELECT owner_id FROM skills WHERE id = $1"
args := []any{id}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = true OR tenant_id = $2)"
args = append(args, tid)
}
var ownerID string
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil {
return "", false
}
return ownerID, true
}
// GetSkillOwnerIDBySlug returns the owner_id for a skill by slug.
// Returns ("", false) if the skill does not exist or is archived.
func (s *PGSkillStore) GetSkillOwnerIDBySlug(ctx context.Context, slug string) (string, bool) {
q := "SELECT owner_id FROM skills WHERE slug = $1 AND status = 'active'"
args := []any{slug}
if !store.IsCrossTenant(ctx) {
tid := store.TenantIDFromContext(ctx)
if tid == uuid.Nil {
tid = store.MasterTenantID
}
q += " AND (is_system = true OR tenant_id = $2)"
args = append(args, tid)
}
var ownerID string
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&ownerID); err != nil {
return "", false
}
return ownerID, true
}
// buildSkillInfo constructs a SkillInfo. If filePath (from DB file_path column) is set,
// it is used as BaseDir; otherwise BaseDir is constructed from baseDir/slug/version.
// This ensures skills seeded under a previous data directory are still resolved correctly.
func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir string, filePath *string) store.SkillInfo {
d := ""
if desc != nil {
d = *desc
}
skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version)
if filePath != nil && *filePath != "" {
skillDir = store.SkillBaseDir(*filePath)
}
return store.SkillInfo{
ID: id,
Name: name,
Slug: slug,
Path: store.SkillMarkdownPath(skillDir),
BaseDir: skillDir,
Source: "managed",
Description: d,
Version: version,
}
}
// readSkillFile reads a SKILL.md file, normalizes line endings, and strips frontmatter.
func readSkillFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
content := strings.ReplaceAll(string(data), "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
content = skillFrontmatterRe.ReplaceAllString(content, "")
return content, nil
}