package skills import ( "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "strings" "time" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // Store persists skill definitions in SQLite (the skills table). It is safe for // concurrent use — the underlying *sql.DB serializes writes — and every query is // parameterized, so values are never interpolated into SQL. type Store struct { db *sql.DB log *slog.Logger } // createSkillsTable creates the skills schema. id is an opaque primary key; // name is the stable, unique skill identifier used for Upsert/Get/Delete // lookups. The desc column is double-quoted because DESC is a SQL keyword in // ordering context. const createSkillsTable = ` CREATE TABLE IF NOT EXISTS skills ( id TEXT PRIMARY KEY, name TEXT NOT NULL, "desc" TEXT NOT NULL DEFAULT '', prompt TEXT NOT NULL DEFAULT '', steps_json TEXT NOT NULL DEFAULT '[]', refs_json TEXT NOT NULL DEFAULT '[]', guardrails_json TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_name ON skills (name); ` // upsertSkillSQL inserts a skill, updating the existing row when the name // already exists; created_at is preserved across an update. const upsertSkillSQL = ` INSERT INTO skills (id, name, "desc", prompt, steps_json, refs_json, guardrails_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET "desc" = excluded."desc", prompt = excluded.prompt, steps_json = excluded.steps_json, refs_json = excluded.refs_json, guardrails_json = excluded.guardrails_json, updated_at = excluded.updated_at ` const ( getSkillSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills WHERE name = ?` listSkillsSQL = `SELECT name, "desc", prompt, steps_json, refs_json, guardrails_json FROM skills ORDER BY name` deleteSkillSQL = `DELETE FROM skills WHERE name = ?` ) // idByteLen is the size in bytes of the random skill id (128-bit). const idByteLen = 16 // NewStore builds a Store, creating the skills table if needed. It returns // ErrNilDB when db is nil. ctx is used for the schema creation statement. func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) { if db == nil { return nil, fmt.Errorf("skills: store: %w", ErrNilDB) } s := &Store{db: db, log: sourcedLog(log)} if _, err := db.ExecContext(ctx, createSkillsTable); err != nil { return nil, fmt.Errorf("skills: store: create skills table: %w", err) } s.log.Info("skills table ready") return s, nil } // Upsert stores skill, inserting a new row or updating the existing row that // shares its name. It returns ErrInvalidSkill when skill is nil or has a blank // name. func (s *Store) Upsert(ctx context.Context, skill sdk.Skill) error { if skill == nil || strings.TrimSpace(skill.Name()) == "" { return fmt.Errorf("skills: store: upsert: %w", ErrInvalidSkill) } name := skill.Name() stepsJSON, err := marshalJSON(skill.Steps()) if err != nil { return fmt.Errorf("skills: store: upsert %q: steps: %w", name, err) } refsJSON, err := marshalJSON(skill.Refs()) if err != nil { return fmt.Errorf("skills: store: upsert %q: refs: %w", name, err) } guardrailsJSON, err := marshalJSON(skill.Guardrails()) if err != nil { return fmt.Errorf("skills: store: upsert %q: guardrails: %w", name, err) } now := time.Now().UTC().Format(time.RFC3339Nano) if _, err := s.db.ExecContext(ctx, upsertSkillSQL, newID(), name, skill.Description(), skill.Prompt(), stepsJSON, refsJSON, guardrailsJSON, now, now, ); err != nil { return fmt.Errorf("skills: store: upsert %q: %w", name, err) } s.log.Info("skill upserted", "skill", name) return nil } // Get returns the skill registered under name, or ErrNotFound when absent. func (s *Store) Get(ctx context.Context, name string) (*Skill, error) { row := s.db.QueryRowContext(ctx, getSkillSQL, name) skill, err := scanSkill(row, name) if err != nil { return nil, err } return skill, nil } // List returns every stored skill, ordered by name. func (s *Store) List(ctx context.Context) ([]*Skill, error) { rows, err := s.db.QueryContext(ctx, listSkillsSQL) if err != nil { return nil, fmt.Errorf("skills: store: list: %w", err) } defer rows.Close() out := make([]*Skill, 0) for rows.Next() { skill, err := scanSkill(rows, "") if err != nil { return nil, err } out = append(out, skill) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("skills: store: list: iterate: %w", err) } return out, nil } // Delete removes the skill named name. It returns ErrNotFound when no such skill // exists. func (s *Store) Delete(ctx context.Context, name string) error { res, err := s.db.ExecContext(ctx, deleteSkillSQL, name) if err != nil { return fmt.Errorf("skills: store: delete %q: %w", name, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("skills: store: delete %q: rows affected: %w", name, err) } if n == 0 { return fmt.Errorf("skills: store: delete %q: %w", name, ErrNotFound) } s.log.Info("skill deleted", "skill", name) return nil } // rowScanner is the subset of *sql.Row and *sql.Rows that scanSkill needs. type rowScanner interface{ Scan(dest ...any) error } // scanSkill reads one skills row and builds a *Skill. key is the lookup name // used only in error messages. func scanSkill(sc rowScanner, key string) (*Skill, error) { var name, desc, prompt, stepsJSON, refsJSON, guardrailsJSON string if err := sc.Scan(&name, &desc, &prompt, &stepsJSON, &refsJSON, &guardrailsJSON); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("skills: store: get %q: %w", key, ErrNotFound) } return nil, fmt.Errorf("skills: store: scan skill: %w", err) } steps, err := unmarshalSteps(stepsJSON) if err != nil { return nil, fmt.Errorf("skills: store: skill %q: steps: %w", name, err) } refs, err := unmarshalRefs(refsJSON) if err != nil { return nil, fmt.Errorf("skills: store: skill %q: refs: %w", name, err) } guardrails, err := unmarshalGuardrails(guardrailsJSON) if err != nil { return nil, fmt.Errorf("skills: store: skill %q: guardrails: %w", name, err) } skill, err := New(name, desc, prompt, steps, refs, guardrails) if err != nil { return nil, fmt.Errorf("skills: store: skill %q: %w", name, err) } return skill, nil } func marshalJSON(v any) (string, error) { b, err := json.Marshal(v) if err != nil { return "", err } return string(b), nil } func unmarshalSteps(s string) ([]sdk.Step, error) { var out []sdk.Step if err := json.Unmarshal([]byte(s), &out); err != nil { return nil, err } return out, nil } func unmarshalRefs(s string) ([]sdk.Ref, error) { var out []sdk.Ref if err := json.Unmarshal([]byte(s), &out); err != nil { return nil, err } return out, nil } func unmarshalGuardrails(s string) ([]sdk.Guardrail, error) { var out []sdk.Guardrail if err := json.Unmarshal([]byte(s), &out); err != nil { return nil, err } return out, nil } // newID returns a random 128-bit hex identifier for a skill row. func newID() string { var b [idByteLen]byte if _, err := rand.Read(b[:]); err != nil { // crypto/rand is effectively infallible; fall back to a nanosecond // timestamp so the store never blocks on a missing identifier. return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b[:]) }