Some checks failed
CI / build-and-test (push) Failing after -31m18s
- Created tables for agent_personas, agent_parts, agent_traits, and character_arcs. - Established relationships between personas, parts, skills, guardrails, and traits. - Added arc stages and their corresponding parts, along with a persona_arc table to track current stages. - Implemented cascading delete rules for referential integrity.
1199 lines
37 KiB
Go
1199 lines
37 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
ext "git.warky.dev/wdevs/amcs/internal/types"
|
|
)
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Helpers
|
|
// ──────────────────────────────────────────────
|
|
|
|
func nilToEmptyStrings(s []string) []string {
|
|
if s == nil {
|
|
return []string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Personas
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (db *DB) CreatePersona(ctx context.Context, p ext.Persona) (ext.Persona, error) {
|
|
if p.Tags == nil {
|
|
p.Tags = []string{}
|
|
}
|
|
row := db.pool.QueryRow(ctx, `
|
|
insert into agent_personas (name, description, summary, detail, tags)
|
|
values ($1, $2, $3, $4, $5)
|
|
returning id, guid, compiled_summary, compiled_detail, compiled_at, created_at, updated_at
|
|
`, p.Name, p.Description, p.Summary, p.Detail, p.Tags)
|
|
|
|
created := p
|
|
var id int64
|
|
var guid uuid.UUID
|
|
var cs, cd string
|
|
var compiledAt *time.Time
|
|
var createdAt, updatedAt time.Time
|
|
if err := row.Scan(&id, &guid, &cs, &cd, &compiledAt, &createdAt, &updatedAt); err != nil {
|
|
return ext.Persona{}, fmt.Errorf("create persona: %w", err)
|
|
}
|
|
created.ID = id
|
|
created.GUID = guid
|
|
created.CompiledSummary = cs
|
|
created.CompiledDetail = cd
|
|
created.CompiledAt = compiledAt
|
|
created.CreatedAt = createdAt
|
|
created.UpdatedAt = updatedAt
|
|
return created, nil
|
|
}
|
|
|
|
func (db *DB) UpdatePersona(ctx context.Context, name string, updates map[string]any) (ext.Persona, error) {
|
|
if len(updates) == 0 {
|
|
return db.GetPersonaByName(ctx, name)
|
|
}
|
|
allowed := map[string]bool{"name": true, "description": true, "summary": true, "detail": true, "tags": true}
|
|
setClauses := []string{}
|
|
args := []any{}
|
|
for col, val := range updates {
|
|
if !allowed[col] {
|
|
return ext.Persona{}, fmt.Errorf("unknown field: %s", col)
|
|
}
|
|
args = append(args, val)
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
|
|
}
|
|
args = append(args, name)
|
|
q := fmt.Sprintf(`
|
|
update agent_personas set %s, updated_at = now()
|
|
where name = $%d
|
|
returning id, guid, name, description, summary, detail, compiled_summary, compiled_detail, compiled_at, tags::text[], created_at, updated_at
|
|
`, strings.Join(setClauses, ", "), len(args))
|
|
|
|
row := db.pool.QueryRow(ctx, q, args...)
|
|
return scanPersona(row)
|
|
}
|
|
|
|
func (db *DB) DeletePersona(ctx context.Context, name string) error {
|
|
tag, err := db.pool.Exec(ctx, `delete from agent_personas where name = $1`, name)
|
|
if err != nil {
|
|
return fmt.Errorf("delete persona: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("persona not found: %s", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) ListPersonas(ctx context.Context, tag string) ([]ext.Persona, error) {
|
|
q := `select id, guid, name, description, summary, detail, compiled_summary, compiled_detail, compiled_at, tags::text[], created_at, updated_at from agent_personas`
|
|
args := []any{}
|
|
if t := strings.TrimSpace(tag); t != "" {
|
|
args = append(args, t)
|
|
q += fmt.Sprintf(" where $%d = any(tags)", len(args))
|
|
}
|
|
q += " order by name"
|
|
rows, err := db.pool.Query(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list personas: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var personas []ext.Persona
|
|
for rows.Next() {
|
|
p, err := scanPersona(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan persona: %w", err)
|
|
}
|
|
personas = append(personas, p)
|
|
}
|
|
return personas, rows.Err()
|
|
}
|
|
|
|
func (db *DB) GetPersonaByName(ctx context.Context, name string) (ext.Persona, error) {
|
|
row := db.pool.QueryRow(ctx, `
|
|
select id, guid, name, description, summary, detail, compiled_summary, compiled_detail, compiled_at, tags::text[], created_at, updated_at
|
|
from agent_personas where name = $1
|
|
`, name)
|
|
p, err := scanPersona(row)
|
|
if err != nil {
|
|
return ext.Persona{}, fmt.Errorf("get persona: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// GetPersona assembles the full persona with parts, skills, guardrails, traits,
|
|
// and arc state. Assembly priority per part_type: overrides > arc stage > persona base.
|
|
func (db *DB) GetPersona(ctx context.Context, name string, detail bool, overrides map[string]string) (ext.PersonaFull, error) {
|
|
// 1. Persona metadata
|
|
persona, err := db.GetPersonaByName(ctx, name)
|
|
if err != nil {
|
|
return ext.PersonaFull{}, err
|
|
}
|
|
|
|
// 2. Base persona parts (ordered)
|
|
baseParts, err := db.listPersonaParts(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaFull{}, err
|
|
}
|
|
|
|
// 3. Arc state + stage parts
|
|
arcState, stageParts, err := db.getArcStateAndParts(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaFull{}, err
|
|
}
|
|
|
|
// 4. Assemble: start with base, override with arc stage parts per type
|
|
assembled := assembleParts(baseParts, stageParts, "arc_stage")
|
|
|
|
// 5. Apply runtime overrides (fetch by name, replace per type)
|
|
if len(overrides) > 0 {
|
|
overrideParts, err := db.fetchPartsByNames(ctx, overrideValues(overrides))
|
|
if err != nil {
|
|
return ext.PersonaFull{}, err
|
|
}
|
|
// Build name→part lookup
|
|
byName := make(map[string]rawPart, len(overrideParts))
|
|
for _, p := range overrideParts {
|
|
byName[p.Name] = p
|
|
}
|
|
for partType, partName := range overrides {
|
|
op, ok := byName[partName]
|
|
if !ok {
|
|
return ext.PersonaFull{}, fmt.Errorf("override part not found: %s", partName)
|
|
}
|
|
if op.PartType != partType {
|
|
return ext.PersonaFull{}, fmt.Errorf("override part %q has type %q, expected %q", partName, op.PartType, partType)
|
|
}
|
|
// Remove all parts of this type, then append the override
|
|
without := assembled[:0]
|
|
for _, ap := range assembled {
|
|
if ap.PartType != partType {
|
|
without = append(without, ap)
|
|
}
|
|
}
|
|
op.Source = "override"
|
|
assembled = append(without, op)
|
|
}
|
|
}
|
|
|
|
// 6. Skills, guardrails, traits
|
|
skills, err := db.listPersonaSkills(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaFull{}, err
|
|
}
|
|
guardrails, err := db.listPersonaGuardrails(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaFull{}, err
|
|
}
|
|
traits, err := db.listPersonaTraits(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaFull{}, err
|
|
}
|
|
|
|
// 7. Build output
|
|
body := persona.Summary
|
|
if detail && persona.Detail != "" {
|
|
body = persona.Detail
|
|
}
|
|
|
|
out := ext.PersonaFull{
|
|
Name: persona.Name,
|
|
Description: persona.Description,
|
|
Body: body,
|
|
CompiledSummary: persona.CompiledSummary,
|
|
Tags: persona.Tags,
|
|
Arc: arcState,
|
|
Detail: detail,
|
|
}
|
|
|
|
for _, rp := range assembled {
|
|
partBody := rp.Summary
|
|
if detail && rp.Content != "" {
|
|
partBody = rp.Content
|
|
}
|
|
out.Parts = append(out.Parts, ext.AssembledPart{
|
|
Name: rp.Name,
|
|
PartType: rp.PartType,
|
|
Description: rp.Description,
|
|
Body: partBody,
|
|
Tags: rp.Tags,
|
|
Source: rp.Source,
|
|
})
|
|
}
|
|
|
|
for _, s := range skills {
|
|
entry := ext.PersonaSkillEntry{
|
|
ID: s.ID,
|
|
Name: s.Name,
|
|
Description: s.Description,
|
|
Tags: s.Tags,
|
|
}
|
|
if detail {
|
|
entry.Content = s.Content
|
|
}
|
|
out.Skills = append(out.Skills, entry)
|
|
}
|
|
|
|
for _, g := range guardrails {
|
|
entry := ext.PersonaGuardrailEntry{
|
|
ID: g.ID,
|
|
Name: g.Name,
|
|
Description: g.Description,
|
|
Tags: g.Tags,
|
|
}
|
|
if detail {
|
|
entry.Content = g.Content
|
|
entry.Severity = g.Severity
|
|
}
|
|
out.Guardrails = append(out.Guardrails, entry)
|
|
}
|
|
|
|
for _, t := range traits {
|
|
entry := ext.PersonaTraitEntry{
|
|
ID: t.ID,
|
|
Name: t.Name,
|
|
TraitType: t.TraitType,
|
|
Description: t.Description,
|
|
Tags: t.Tags,
|
|
}
|
|
if detail {
|
|
entry.Instruction = t.Instruction
|
|
}
|
|
out.Traits = append(out.Traits, entry)
|
|
}
|
|
|
|
if out.Parts == nil {
|
|
out.Parts = []ext.AssembledPart{}
|
|
}
|
|
if out.Skills == nil {
|
|
out.Skills = []ext.PersonaSkillEntry{}
|
|
}
|
|
if out.Guardrails == nil {
|
|
out.Guardrails = []ext.PersonaGuardrailEntry{}
|
|
}
|
|
if out.Traits == nil {
|
|
out.Traits = []ext.PersonaTraitEntry{}
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// GetPersonaManifest returns a lightweight structure-only view of the persona.
|
|
func (db *DB) GetPersonaManifest(ctx context.Context, name string) (ext.PersonaManifest, error) {
|
|
persona, err := db.GetPersonaByName(ctx, name)
|
|
if err != nil {
|
|
return ext.PersonaManifest{}, err
|
|
}
|
|
|
|
baseParts, err := db.listPersonaParts(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaManifest{}, err
|
|
}
|
|
arcState, stageParts, err := db.getArcStateAndParts(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaManifest{}, err
|
|
}
|
|
assembled := assembleParts(baseParts, stageParts, "arc_stage")
|
|
|
|
skills, err := db.listPersonaSkills(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaManifest{}, err
|
|
}
|
|
guardrails, err := db.listPersonaGuardrails(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaManifest{}, err
|
|
}
|
|
traits, err := db.listPersonaTraits(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.PersonaManifest{}, err
|
|
}
|
|
|
|
manifest := ext.PersonaManifest{
|
|
Name: persona.Name,
|
|
Description: persona.Description,
|
|
Arc: arcState,
|
|
OnDemandTools: []string{
|
|
"get_agent_part(name) — load full content of a specific part",
|
|
"get_agent_trait(name) — load instruction for a specific trait",
|
|
"get_skill(name) — load skill content",
|
|
"get_guardrail(name) — load guardrail content",
|
|
"compile_persona(name) — load pre-merged compiled_summary if full context needed",
|
|
"get_agent_persona(name, detail=true) — load full persona with all content",
|
|
},
|
|
}
|
|
|
|
for _, rp := range assembled {
|
|
manifest.Parts = append(manifest.Parts, ext.ManifestPart{
|
|
Name: rp.Name,
|
|
PartType: rp.PartType,
|
|
Description: rp.Description,
|
|
})
|
|
}
|
|
for _, s := range skills {
|
|
manifest.Skills = append(manifest.Skills, ext.ManifestSkill{
|
|
ID: s.ID,
|
|
Name: s.Name,
|
|
Description: s.Description,
|
|
})
|
|
}
|
|
for _, g := range guardrails {
|
|
manifest.Guardrails = append(manifest.Guardrails, ext.ManifestGuardrail{
|
|
ID: g.ID,
|
|
Name: g.Name,
|
|
Description: g.Description,
|
|
Severity: g.Severity,
|
|
})
|
|
}
|
|
for _, t := range traits {
|
|
manifest.Traits = append(manifest.Traits, ext.ManifestTrait{
|
|
Name: t.Name,
|
|
TraitType: t.TraitType,
|
|
Description: t.Description,
|
|
})
|
|
}
|
|
|
|
if manifest.Parts == nil {
|
|
manifest.Parts = []ext.ManifestPart{}
|
|
}
|
|
if manifest.Skills == nil {
|
|
manifest.Skills = []ext.ManifestSkill{}
|
|
}
|
|
if manifest.Guardrails == nil {
|
|
manifest.Guardrails = []ext.ManifestGuardrail{}
|
|
}
|
|
if manifest.Traits == nil {
|
|
manifest.Traits = []ext.ManifestTrait{}
|
|
}
|
|
|
|
return manifest, nil
|
|
}
|
|
|
|
// CompilePersona regenerates compiled_summary and compiled_detail from current parts and arc stage.
|
|
func (db *DB) CompilePersona(ctx context.Context, name string) (ext.Persona, error) {
|
|
persona, err := db.GetPersonaByName(ctx, name)
|
|
if err != nil {
|
|
return ext.Persona{}, err
|
|
}
|
|
|
|
baseParts, err := db.listPersonaParts(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.Persona{}, err
|
|
}
|
|
_, stageParts, err := db.getArcStateAndParts(ctx, persona.ID)
|
|
if err != nil {
|
|
return ext.Persona{}, err
|
|
}
|
|
assembled := assembleParts(baseParts, stageParts, "arc_stage")
|
|
|
|
summaryParts := []string{persona.Summary}
|
|
detailParts := []string{persona.Detail}
|
|
for _, rp := range assembled {
|
|
if rp.Summary != "" {
|
|
summaryParts = append(summaryParts, rp.Summary)
|
|
}
|
|
if rp.Content != "" {
|
|
detailParts = append(detailParts, rp.Content)
|
|
}
|
|
}
|
|
|
|
compiledSummary := strings.Join(summaryParts, "\n\n")
|
|
compiledDetail := strings.Join(detailParts, "\n\n")
|
|
|
|
row := db.pool.QueryRow(ctx, `
|
|
update agent_personas
|
|
set compiled_summary = $1, compiled_detail = $2, compiled_at = now(), updated_at = now()
|
|
where id = $3
|
|
returning id, guid, name, description, summary, detail, compiled_summary, compiled_detail, compiled_at, tags::text[], created_at, updated_at
|
|
`, compiledSummary, compiledDetail, persona.ID)
|
|
return scanPersona(row)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Parts
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (db *DB) CreatePart(ctx context.Context, p ext.Part) (ext.Part, error) {
|
|
if p.Tags == nil {
|
|
p.Tags = []string{}
|
|
}
|
|
row := db.pool.QueryRow(ctx, `
|
|
insert into agent_parts (name, part_type, description, summary, content, tags)
|
|
values ($1, $2, $3, $4, $5, $6)
|
|
returning id, guid, created_at, updated_at
|
|
`, p.Name, p.PartType, p.Description, p.Summary, p.Content, p.Tags)
|
|
|
|
created := p
|
|
var id int64
|
|
var guid uuid.UUID
|
|
var createdAt, updatedAt time.Time
|
|
if err := row.Scan(&id, &guid, &createdAt, &updatedAt); err != nil {
|
|
return ext.Part{}, fmt.Errorf("create part: %w", err)
|
|
}
|
|
created.ID = id
|
|
created.GUID = guid
|
|
created.CreatedAt = createdAt
|
|
created.UpdatedAt = updatedAt
|
|
return created, nil
|
|
}
|
|
|
|
func (db *DB) UpdatePart(ctx context.Context, name string, updates map[string]any) (ext.Part, error) {
|
|
if len(updates) == 0 {
|
|
return db.GetPartByName(ctx, name)
|
|
}
|
|
allowed := map[string]bool{"name": true, "part_type": true, "description": true, "summary": true, "content": true, "tags": true}
|
|
setClauses := []string{}
|
|
args := []any{}
|
|
for col, val := range updates {
|
|
if !allowed[col] {
|
|
return ext.Part{}, fmt.Errorf("unknown field: %s", col)
|
|
}
|
|
args = append(args, val)
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
|
|
}
|
|
args = append(args, name)
|
|
q := fmt.Sprintf(`
|
|
update agent_parts set %s, updated_at = now()
|
|
where name = $%d
|
|
returning id, guid, name, part_type, description, summary, content, tags::text[], created_at, updated_at
|
|
`, strings.Join(setClauses, ", "), len(args))
|
|
|
|
row := db.pool.QueryRow(ctx, q, args...)
|
|
return scanPart(row)
|
|
}
|
|
|
|
func (db *DB) DeletePart(ctx context.Context, name string) error {
|
|
tag, err := db.pool.Exec(ctx, `delete from agent_parts where name = $1`, name)
|
|
if err != nil {
|
|
return fmt.Errorf("delete part: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("part not found: %s", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) ListParts(ctx context.Context, partType, tag string) ([]ext.Part, error) {
|
|
args := []any{}
|
|
conditions := []string{}
|
|
if t := strings.TrimSpace(partType); t != "" {
|
|
args = append(args, t)
|
|
conditions = append(conditions, fmt.Sprintf("part_type = $%d", len(args)))
|
|
}
|
|
if t := strings.TrimSpace(tag); t != "" {
|
|
args = append(args, t)
|
|
conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args)))
|
|
}
|
|
q := `select id, guid, name, part_type, description, summary, content, tags::text[], created_at, updated_at from agent_parts`
|
|
if len(conditions) > 0 {
|
|
q += " where " + strings.Join(conditions, " and ")
|
|
}
|
|
q += " order by part_type, name"
|
|
rows, err := db.pool.Query(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list parts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var parts []ext.Part
|
|
for rows.Next() {
|
|
p, err := scanPart(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan part: %w", err)
|
|
}
|
|
parts = append(parts, p)
|
|
}
|
|
return parts, rows.Err()
|
|
}
|
|
|
|
func (db *DB) GetPartByName(ctx context.Context, name string) (ext.Part, error) {
|
|
row := db.pool.QueryRow(ctx, `
|
|
select id, guid, name, part_type, description, summary, content, tags::text[], created_at, updated_at
|
|
from agent_parts where name = $1
|
|
`, name)
|
|
p, err := scanPart(row)
|
|
if err != nil {
|
|
return ext.Part{}, fmt.Errorf("get part: %w", err)
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Persona-Part links
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (db *DB) AddPersonaPart(ctx context.Context, personaName, partName string, order, priority int) error {
|
|
persona, err := db.GetPersonaByName(ctx, personaName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
part, err := db.GetPartByName(ctx, partName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = db.pool.Exec(ctx, `
|
|
insert into agent_persona_parts (persona_id, part_id, part_order, priority)
|
|
values ($1, $2, $3, $4)
|
|
on conflict (persona_id, part_id) do update set part_order = excluded.part_order, priority = excluded.priority
|
|
`, persona.ID, part.ID, order, priority)
|
|
if err != nil {
|
|
return fmt.Errorf("add persona part: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) RemovePersonaPart(ctx context.Context, personaName, partName string) error {
|
|
tag, err := db.pool.Exec(ctx, `
|
|
delete from agent_persona_parts
|
|
where persona_id = (select id from agent_personas where name = $1)
|
|
and part_id = (select id from agent_parts where name = $2)
|
|
`, personaName, partName)
|
|
if err != nil {
|
|
return fmt.Errorf("remove persona part: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("persona-part link not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Persona-Skill links
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (db *DB) AddPersonaSkill(ctx context.Context, personaID, skillID int64) error {
|
|
_, err := db.pool.Exec(ctx, `
|
|
insert into agent_persona_skills (persona_id, skill_id)
|
|
values ($1, $2) on conflict do nothing
|
|
`, personaID, skillID)
|
|
if err != nil {
|
|
return fmt.Errorf("add persona skill: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) RemovePersonaSkill(ctx context.Context, personaID, skillID int64) error {
|
|
tag, err := db.pool.Exec(ctx, `
|
|
delete from agent_persona_skills where persona_id = $1 and skill_id = $2
|
|
`, personaID, skillID)
|
|
if err != nil {
|
|
return fmt.Errorf("remove persona skill: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("persona-skill link not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Persona-Guardrail links
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (db *DB) AddPersonaGuardrail(ctx context.Context, personaID, guardrailID int64) error {
|
|
_, err := db.pool.Exec(ctx, `
|
|
insert into agent_persona_guardrails (persona_id, guardrail_id)
|
|
values ($1, $2) on conflict do nothing
|
|
`, personaID, guardrailID)
|
|
if err != nil {
|
|
return fmt.Errorf("add persona guardrail: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) RemovePersonaGuardrail(ctx context.Context, personaID, guardrailID int64) error {
|
|
tag, err := db.pool.Exec(ctx, `
|
|
delete from agent_persona_guardrails where persona_id = $1 and guardrail_id = $2
|
|
`, personaID, guardrailID)
|
|
if err != nil {
|
|
return fmt.Errorf("remove persona guardrail: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("persona-guardrail link not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Traits
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (db *DB) CreateTrait(ctx context.Context, t ext.Trait) (ext.Trait, error) {
|
|
if t.Tags == nil {
|
|
t.Tags = []string{}
|
|
}
|
|
row := db.pool.QueryRow(ctx, `
|
|
insert into agent_traits (name, trait_type, description, instruction, tags)
|
|
values ($1, $2, $3, $4, $5)
|
|
returning id, guid, created_at, updated_at
|
|
`, t.Name, t.TraitType, t.Description, t.Instruction, t.Tags)
|
|
|
|
created := t
|
|
var id int64
|
|
var guid uuid.UUID
|
|
var createdAt, updatedAt time.Time
|
|
if err := row.Scan(&id, &guid, &createdAt, &updatedAt); err != nil {
|
|
return ext.Trait{}, fmt.Errorf("create trait: %w", err)
|
|
}
|
|
created.ID = id
|
|
created.GUID = guid
|
|
created.CreatedAt = createdAt
|
|
created.UpdatedAt = updatedAt
|
|
return created, nil
|
|
}
|
|
|
|
func (db *DB) UpdateTrait(ctx context.Context, name string, updates map[string]any) (ext.Trait, error) {
|
|
if len(updates) == 0 {
|
|
return db.GetTraitByName(ctx, name)
|
|
}
|
|
allowed := map[string]bool{"name": true, "trait_type": true, "description": true, "instruction": true, "tags": true}
|
|
setClauses := []string{}
|
|
args := []any{}
|
|
for col, val := range updates {
|
|
if !allowed[col] {
|
|
return ext.Trait{}, fmt.Errorf("unknown field: %s", col)
|
|
}
|
|
args = append(args, val)
|
|
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
|
|
}
|
|
args = append(args, name)
|
|
q := fmt.Sprintf(`
|
|
update agent_traits set %s, updated_at = now()
|
|
where name = $%d
|
|
returning id, guid, name, trait_type, description, instruction, tags::text[], created_at, updated_at
|
|
`, strings.Join(setClauses, ", "), len(args))
|
|
|
|
row := db.pool.QueryRow(ctx, q, args...)
|
|
return scanTrait(row)
|
|
}
|
|
|
|
func (db *DB) DeleteTrait(ctx context.Context, name string) error {
|
|
tag, err := db.pool.Exec(ctx, `delete from agent_traits where name = $1`, name)
|
|
if err != nil {
|
|
return fmt.Errorf("delete trait: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("trait not found: %s", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) ListTraits(ctx context.Context, traitType, tag string) ([]ext.Trait, error) {
|
|
args := []any{}
|
|
conditions := []string{}
|
|
if t := strings.TrimSpace(traitType); t != "" {
|
|
args = append(args, t)
|
|
conditions = append(conditions, fmt.Sprintf("trait_type = $%d", len(args)))
|
|
}
|
|
if t := strings.TrimSpace(tag); t != "" {
|
|
args = append(args, t)
|
|
conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args)))
|
|
}
|
|
q := `select id, guid, name, trait_type, description, instruction, tags::text[], created_at, updated_at from agent_traits`
|
|
if len(conditions) > 0 {
|
|
q += " where " + strings.Join(conditions, " and ")
|
|
}
|
|
q += " order by trait_type, name"
|
|
rows, err := db.pool.Query(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list traits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var traits []ext.Trait
|
|
for rows.Next() {
|
|
t, err := scanTrait(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan trait: %w", err)
|
|
}
|
|
traits = append(traits, t)
|
|
}
|
|
return traits, rows.Err()
|
|
}
|
|
|
|
func (db *DB) GetTraitByName(ctx context.Context, name string) (ext.Trait, error) {
|
|
row := db.pool.QueryRow(ctx, `
|
|
select id, guid, name, trait_type, description, instruction, tags::text[], created_at, updated_at
|
|
from agent_traits where name = $1
|
|
`, name)
|
|
t, err := scanTrait(row)
|
|
if err != nil {
|
|
return ext.Trait{}, fmt.Errorf("get trait: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func (db *DB) AddPersonaTrait(ctx context.Context, personaID, traitID int64) error {
|
|
_, err := db.pool.Exec(ctx, `
|
|
insert into agent_persona_traits (persona_id, trait_id)
|
|
values ($1, $2) on conflict do nothing
|
|
`, personaID, traitID)
|
|
if err != nil {
|
|
return fmt.Errorf("add persona trait: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) RemovePersonaTrait(ctx context.Context, personaID, traitID int64) error {
|
|
tag, err := db.pool.Exec(ctx, `
|
|
delete from agent_persona_traits where persona_id = $1 and trait_id = $2
|
|
`, personaID, traitID)
|
|
if err != nil {
|
|
return fmt.Errorf("remove persona trait: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("persona-trait link not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Character Arcs
|
|
// ──────────────────────────────────────────────
|
|
|
|
func (db *DB) CreateCharacterArc(ctx context.Context, arc ext.CharacterArc) (ext.CharacterArc, error) {
|
|
row := db.pool.QueryRow(ctx, `
|
|
insert into character_arcs (name, description, summary)
|
|
values ($1, $2, $3)
|
|
returning id, created_at, updated_at
|
|
`, arc.Name, arc.Description, arc.Summary)
|
|
|
|
created := arc
|
|
var id int64
|
|
var createdAt, updatedAt time.Time
|
|
if err := row.Scan(&id, &createdAt, &updatedAt); err != nil {
|
|
return ext.CharacterArc{}, fmt.Errorf("create character arc: %w", err)
|
|
}
|
|
created.ID = id
|
|
created.CreatedAt = createdAt
|
|
created.UpdatedAt = updatedAt
|
|
return created, nil
|
|
}
|
|
|
|
func (db *DB) ListCharacterArcs(ctx context.Context) ([]ext.CharacterArc, error) {
|
|
rows, err := db.pool.Query(ctx, `
|
|
select id, name, description, summary, created_at, updated_at
|
|
from character_arcs order by name
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list character arcs: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var arcs []ext.CharacterArc
|
|
for rows.Next() {
|
|
var a ext.CharacterArc
|
|
if err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Summary, &a.CreatedAt, &a.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("scan character arc: %w", err)
|
|
}
|
|
arcs = append(arcs, a)
|
|
}
|
|
return arcs, rows.Err()
|
|
}
|
|
|
|
func (db *DB) AddArcStage(ctx context.Context, arcName string, stage ext.ArcStage) (ext.ArcStage, error) {
|
|
row := db.pool.QueryRow(ctx, `
|
|
insert into arc_stages (arc_id, name, stage_order, description, condition)
|
|
values ((select id from character_arcs where name = $1), $2, $3, $4, $5)
|
|
returning id, arc_id, created_at
|
|
`, arcName, stage.Name, stage.StageOrder, stage.Description, stage.Condition)
|
|
|
|
created := stage
|
|
if err := row.Scan(&created.ID, &created.ArcID, &created.CreatedAt); err != nil {
|
|
return ext.ArcStage{}, fmt.Errorf("add arc stage: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
func (db *DB) AddStagePart(ctx context.Context, stageID int64, partName string) error {
|
|
_, err := db.pool.Exec(ctx, `
|
|
insert into arc_stage_parts (stage_id, part_id)
|
|
values ($1, (select id from agent_parts where name = $2))
|
|
on conflict do nothing
|
|
`, stageID, partName)
|
|
if err != nil {
|
|
return fmt.Errorf("add stage part: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) RemoveStagePart(ctx context.Context, stageID int64, partName string) error {
|
|
tag, err := db.pool.Exec(ctx, `
|
|
delete from arc_stage_parts
|
|
where stage_id = $1 and part_id = (select id from agent_parts where name = $2)
|
|
`, stageID, partName)
|
|
if err != nil {
|
|
return fmt.Errorf("remove stage part: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return fmt.Errorf("stage-part link not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) AssignPersonaArc(ctx context.Context, personaName, arcName, startStageName string) error {
|
|
_, err := db.pool.Exec(ctx, `
|
|
insert into persona_arc (persona_id, arc_id, current_stage_id)
|
|
values (
|
|
(select id from agent_personas where name = $1),
|
|
(select id from character_arcs where name = $2),
|
|
(select id from arc_stages where arc_id = (select id from character_arcs where name = $2) and name = $3)
|
|
)
|
|
on conflict (persona_id) do update
|
|
set arc_id = excluded.arc_id,
|
|
current_stage_id = excluded.current_stage_id,
|
|
updated_at = now()
|
|
`, personaName, arcName, startStageName)
|
|
if err != nil {
|
|
return fmt.Errorf("assign persona arc: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (db *DB) AdvancePersonaStage(ctx context.Context, personaName string) (ext.ArcStage, error) {
|
|
row := db.pool.QueryRow(ctx, `
|
|
with current as (
|
|
select pa.persona_id, pa.arc_id, pa.current_stage_id, s.stage_order
|
|
from persona_arc pa
|
|
join arc_stages s on s.id = pa.current_stage_id
|
|
where pa.persona_id = (select id from agent_personas where name = $1)
|
|
),
|
|
next_stage as (
|
|
select s.id, s.arc_id, s.name, s.stage_order, s.description, s.condition, s.created_at
|
|
from arc_stages s
|
|
join current c on c.arc_id = s.arc_id
|
|
where s.stage_order > c.stage_order
|
|
order by s.stage_order
|
|
limit 1
|
|
)
|
|
update persona_arc pa
|
|
set current_stage_id = next_stage.id, updated_at = now()
|
|
from next_stage, current
|
|
where pa.persona_id = current.persona_id
|
|
returning next_stage.id, next_stage.arc_id, next_stage.name, next_stage.stage_order, next_stage.description, next_stage.condition, next_stage.created_at
|
|
`, personaName)
|
|
|
|
var s ext.ArcStage
|
|
if err := row.Scan(&s.ID, &s.ArcID, &s.Name, &s.StageOrder, &s.Description, &s.Condition, &s.CreatedAt); err != nil {
|
|
return ext.ArcStage{}, fmt.Errorf("advance persona stage: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (db *DB) ResetPersonaStage(ctx context.Context, personaName string) (ext.ArcStage, error) {
|
|
row := db.pool.QueryRow(ctx, `
|
|
with first_stage as (
|
|
select s.id, s.arc_id, s.name, s.stage_order, s.description, s.condition, s.created_at
|
|
from arc_stages s
|
|
join persona_arc pa on pa.arc_id = s.arc_id
|
|
where pa.persona_id = (select id from agent_personas where name = $1)
|
|
order by s.stage_order
|
|
limit 1
|
|
)
|
|
update persona_arc pa
|
|
set current_stage_id = first_stage.id, updated_at = now()
|
|
from first_stage
|
|
where pa.persona_id = (select id from agent_personas where name = $1)
|
|
returning first_stage.id, first_stage.arc_id, first_stage.name, first_stage.stage_order, first_stage.description, first_stage.condition, first_stage.created_at
|
|
`, personaName)
|
|
|
|
var s ext.ArcStage
|
|
if err := row.Scan(&s.ID, &s.ArcID, &s.Name, &s.StageOrder, &s.Description, &s.Condition, &s.CreatedAt); err != nil {
|
|
return ext.ArcStage{}, fmt.Errorf("reset persona stage: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Internal helpers
|
|
// ──────────────────────────────────────────────
|
|
|
|
type rawPart struct {
|
|
ID int64
|
|
Name string
|
|
PartType string
|
|
Description string
|
|
Summary string
|
|
Content string
|
|
Tags []string
|
|
Order int
|
|
Priority int
|
|
Source string
|
|
}
|
|
|
|
func (db *DB) listPersonaParts(ctx context.Context, personaID int64) ([]rawPart, error) {
|
|
rows, err := db.pool.Query(ctx, `
|
|
select ap.id, ap.name, ap.part_type, ap.description, ap.summary, ap.content, ap.tags::text[],
|
|
app.part_order, app.priority
|
|
from agent_parts ap
|
|
join agent_persona_parts app on app.part_id = ap.id
|
|
where app.persona_id = $1
|
|
order by app.part_order, ap.part_type, ap.name
|
|
`, personaID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list persona parts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var parts []rawPart
|
|
for rows.Next() {
|
|
var rp rawPart
|
|
var tags []string
|
|
if err := rows.Scan(&rp.ID, &rp.Name, &rp.PartType, &rp.Description, &rp.Summary, &rp.Content, &tags, &rp.Order, &rp.Priority); err != nil {
|
|
return nil, fmt.Errorf("scan persona part: %w", err)
|
|
}
|
|
rp.Tags = nilToEmptyStrings(tags)
|
|
rp.Source = "persona"
|
|
parts = append(parts, rp)
|
|
}
|
|
return parts, rows.Err()
|
|
}
|
|
|
|
func (db *DB) getArcStateAndParts(ctx context.Context, personaID int64) (*ext.PersonaArcState, []rawPart, error) {
|
|
row := db.pool.QueryRow(ctx, `
|
|
select ca.name, s.id, s.name, s.stage_order, s.description, s.condition
|
|
from persona_arc pa
|
|
join character_arcs ca on ca.id = pa.arc_id
|
|
join arc_stages s on s.id = pa.current_stage_id
|
|
where pa.persona_id = $1
|
|
`, personaID)
|
|
|
|
var arcName, stageName, stageDesc, stageCond string
|
|
var stageID int64
|
|
var stageOrder int
|
|
if err := row.Scan(&arcName, &stageID, &stageName, &stageOrder, &stageDesc, &stageCond); err != nil {
|
|
// No arc assigned — not an error
|
|
return nil, nil, nil
|
|
}
|
|
|
|
arcState := &ext.PersonaArcState{
|
|
ArcName: arcName,
|
|
StageName: stageName,
|
|
StageOrder: stageOrder,
|
|
Description: stageDesc,
|
|
Condition: stageCond,
|
|
}
|
|
|
|
rows, err := db.pool.Query(ctx, `
|
|
select ap.id, ap.name, ap.part_type, ap.description, ap.summary, ap.content, ap.tags::text[]
|
|
from agent_parts ap
|
|
join arc_stage_parts asp on asp.part_id = ap.id
|
|
where asp.stage_id = $1
|
|
order by ap.part_type, ap.name
|
|
`, stageID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("list stage parts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var parts []rawPart
|
|
for rows.Next() {
|
|
var rp rawPart
|
|
var tags []string
|
|
if err := rows.Scan(&rp.ID, &rp.Name, &rp.PartType, &rp.Description, &rp.Summary, &rp.Content, &tags); err != nil {
|
|
return nil, nil, fmt.Errorf("scan stage part: %w", err)
|
|
}
|
|
rp.Tags = nilToEmptyStrings(tags)
|
|
rp.Source = "arc_stage"
|
|
parts = append(parts, rp)
|
|
}
|
|
return arcState, parts, rows.Err()
|
|
}
|
|
|
|
// assembleParts merges base parts with override parts. Override parts replace all
|
|
// base parts of the same part_type.
|
|
func assembleParts(base, overrides []rawPart, overrideSource string) []rawPart {
|
|
if len(overrides) == 0 {
|
|
return append([]rawPart{}, base...)
|
|
}
|
|
|
|
overrideTypes := make(map[string]bool)
|
|
for _, op := range overrides {
|
|
overrideTypes[op.PartType] = true
|
|
}
|
|
|
|
result := make([]rawPart, 0, len(base)+len(overrides))
|
|
for _, bp := range base {
|
|
if !overrideTypes[bp.PartType] {
|
|
result = append(result, bp)
|
|
}
|
|
}
|
|
for _, op := range overrides {
|
|
op.Source = overrideSource
|
|
result = append(result, op)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (db *DB) fetchPartsByNames(ctx context.Context, names []string) ([]rawPart, error) {
|
|
if len(names) == 0 {
|
|
return nil, nil
|
|
}
|
|
rows, err := db.pool.Query(ctx, `
|
|
select id, name, part_type, description, summary, content, tags::text[]
|
|
from agent_parts where name = any($1)
|
|
`, names)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch parts by names: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var parts []rawPart
|
|
for rows.Next() {
|
|
var rp rawPart
|
|
var tags []string
|
|
if err := rows.Scan(&rp.ID, &rp.Name, &rp.PartType, &rp.Description, &rp.Summary, &rp.Content, &tags); err != nil {
|
|
return nil, fmt.Errorf("scan part by name: %w", err)
|
|
}
|
|
rp.Tags = nilToEmptyStrings(tags)
|
|
parts = append(parts, rp)
|
|
}
|
|
return parts, rows.Err()
|
|
}
|
|
|
|
func (db *DB) listPersonaSkills(ctx context.Context, personaID int64) ([]AgentSkillRow, error) {
|
|
rows, err := db.pool.Query(ctx, `
|
|
select s.id, s.name, s.description, s.content, s.tags::text[]
|
|
from agent_skills s
|
|
join agent_persona_skills aps on aps.skill_id = s.id
|
|
where aps.persona_id = $1
|
|
order by s.name
|
|
`, personaID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list persona skills: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var skills []AgentSkillRow
|
|
for rows.Next() {
|
|
var s AgentSkillRow
|
|
var tags []string
|
|
if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.Content, &tags); err != nil {
|
|
return nil, fmt.Errorf("scan persona skill: %w", err)
|
|
}
|
|
s.Tags = nilToEmptyStrings(tags)
|
|
skills = append(skills, s)
|
|
}
|
|
return skills, rows.Err()
|
|
}
|
|
|
|
func (db *DB) listPersonaGuardrails(ctx context.Context, personaID int64) ([]AgentGuardrailRow, error) {
|
|
rows, err := db.pool.Query(ctx, `
|
|
select g.id, g.name, g.description, g.content, g.severity, g.tags::text[]
|
|
from agent_guardrails g
|
|
join agent_persona_guardrails apg on apg.guardrail_id = g.id
|
|
where apg.persona_id = $1
|
|
order by g.name
|
|
`, personaID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list persona guardrails: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var gs []AgentGuardrailRow
|
|
for rows.Next() {
|
|
var g AgentGuardrailRow
|
|
var tags []string
|
|
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Content, &g.Severity, &tags); err != nil {
|
|
return nil, fmt.Errorf("scan persona guardrail: %w", err)
|
|
}
|
|
g.Tags = nilToEmptyStrings(tags)
|
|
gs = append(gs, g)
|
|
}
|
|
return gs, rows.Err()
|
|
}
|
|
|
|
func (db *DB) listPersonaTraits(ctx context.Context, personaID int64) ([]ext.Trait, error) {
|
|
rows, err := db.pool.Query(ctx, `
|
|
select t.id, t.guid, t.name, t.trait_type, t.description, t.instruction, t.tags::text[], t.created_at, t.updated_at
|
|
from agent_traits t
|
|
join agent_persona_traits apt on apt.trait_id = t.id
|
|
where apt.persona_id = $1
|
|
order by t.trait_type, t.name
|
|
`, personaID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list persona traits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var traits []ext.Trait
|
|
for rows.Next() {
|
|
t, err := scanTrait(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scan persona trait: %w", err)
|
|
}
|
|
traits = append(traits, t)
|
|
}
|
|
return traits, rows.Err()
|
|
}
|
|
|
|
// Minimal row types used internally (avoid importing full ext types for joins)
|
|
type AgentSkillRow struct {
|
|
ID int64
|
|
Name string
|
|
Description string
|
|
Content string
|
|
Tags []string
|
|
}
|
|
|
|
type AgentGuardrailRow struct {
|
|
ID int64
|
|
Name string
|
|
Description string
|
|
Content string
|
|
Severity string
|
|
Tags []string
|
|
}
|
|
|
|
func overrideValues(m map[string]string) []string {
|
|
out := make([]string, 0, len(m))
|
|
for _, v := range m {
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Scanners
|
|
// ──────────────────────────────────────────────
|
|
|
|
type personaScanner interface{ Scan(dest ...any) error }
|
|
type partScanner interface{ Scan(dest ...any) error }
|
|
type traitScanner interface{ Scan(dest ...any) error }
|
|
|
|
func scanPersona(row personaScanner) (ext.Persona, error) {
|
|
var p ext.Persona
|
|
var tags []string
|
|
if err := row.Scan(&p.ID, &p.GUID, &p.Name, &p.Description, &p.Summary, &p.Detail,
|
|
&p.CompiledSummary, &p.CompiledDetail, &p.CompiledAt, &tags, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
return ext.Persona{}, err
|
|
}
|
|
p.Tags = nilToEmptyStrings(tags)
|
|
return p, nil
|
|
}
|
|
|
|
func scanPart(row partScanner) (ext.Part, error) {
|
|
var p ext.Part
|
|
var tags []string
|
|
if err := row.Scan(&p.ID, &p.GUID, &p.Name, &p.PartType, &p.Description, &p.Summary, &p.Content, &tags, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
return ext.Part{}, err
|
|
}
|
|
p.Tags = nilToEmptyStrings(tags)
|
|
return p, nil
|
|
}
|
|
|
|
func scanTrait(row traitScanner) (ext.Trait, error) {
|
|
var t ext.Trait
|
|
var tags []string
|
|
if err := row.Scan(&t.ID, &t.GUID, &t.Name, &t.TraitType, &t.Description, &t.Instruction, &tags, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
|
return ext.Trait{}, err
|
|
}
|
|
t.Tags = nilToEmptyStrings(tags)
|
|
return t, nil
|
|
}
|