mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 02:16:56 +00:00
Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
292 lines
9.6 KiB
Go
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
|
|
}
|