package tools import ( "context" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "git.warky.dev/wdevs/amcs/internal/config" "git.warky.dev/wdevs/amcs/internal/session" "git.warky.dev/wdevs/amcs/internal/store" thoughttypes "git.warky.dev/wdevs/amcs/internal/types" ) type LearningsTool struct { store *store.DB sessions *session.ActiveProjects cfg config.SearchConfig } type AddLearningInput struct { Summary string `json:"summary" jsonschema:"short curated learning summary"` Details string `json:"details,omitempty" jsonschema:"optional detailed learning body"` Category string `json:"category,omitempty"` Area string `json:"area,omitempty"` Status string `json:"status,omitempty"` Priority string `json:"priority,omitempty"` Confidence string `json:"confidence,omitempty"` ActionRequired *bool `json:"action_required,omitempty"` SourceType string `json:"source_type,omitempty"` SourceRef string `json:"source_ref,omitempty"` Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"` RelatedThoughtID *int64 `json:"related_thought_id,omitempty"` RelatedSkillID *int64 `json:"related_skill_id,omitempty"` ReviewedBy *string `json:"reviewed_by,omitempty"` DuplicateOfLearningID *int64 `json:"duplicate_of_learning_id,omitempty"` SupersedesLearningID *int64 `json:"supersedes_learning_id,omitempty"` Tags []string `json:"tags,omitempty"` } type AddLearningOutput struct { Learning thoughttypes.Learning `json:"learning"` } type GetLearningInput struct { ID int64 `json:"id" jsonschema:"learning id"` } type GetLearningOutput struct { Learning thoughttypes.Learning `json:"learning"` } type ListLearningsInput struct { Limit int `json:"limit,omitempty"` Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"` Category string `json:"category,omitempty"` Area string `json:"area,omitempty"` Status string `json:"status,omitempty"` Priority string `json:"priority,omitempty"` Tag string `json:"tag,omitempty"` Query string `json:"query,omitempty"` } type ListLearningsOutput struct { Learnings []thoughttypes.Learning `json:"learnings"` } func NewLearningsTool(db *store.DB, sessions *session.ActiveProjects, cfg config.SearchConfig) *LearningsTool { return &LearningsTool{store: db, sessions: sessions, cfg: cfg} } func (t *LearningsTool) Add(ctx context.Context, req *mcp.CallToolRequest, in AddLearningInput) (*mcp.CallToolResult, AddLearningOutput, error) { summary := strings.TrimSpace(in.Summary) if summary == "" { return nil, AddLearningOutput{}, errRequiredField("summary") } if err := t.ensureConfigured(); err != nil { return nil, AddLearningOutput{}, err } project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false) if err != nil { return nil, AddLearningOutput{}, err } learning := thoughttypes.Learning{ Summary: summary, Details: strings.TrimSpace(in.Details), Category: defaultString(strings.TrimSpace(in.Category), "insight"), Area: defaultString(strings.TrimSpace(in.Area), "other"), Status: thoughttypes.LearningStatus(defaultString(strings.TrimSpace(in.Status), string(thoughttypes.LearningStatusPending))), Priority: thoughttypes.LearningPriority(defaultString(strings.TrimSpace(in.Priority), string(thoughttypes.LearningPriorityMedium))), Confidence: thoughttypes.LearningEvidenceLevel(defaultString(strings.TrimSpace(in.Confidence), string(thoughttypes.LearningEvidenceHypothesis))), SourceType: strings.TrimSpace(in.SourceType), SourceRef: strings.TrimSpace(in.SourceRef), RelatedThoughtID: in.RelatedThoughtID, RelatedSkillID: in.RelatedSkillID, ReviewedBy: in.ReviewedBy, DuplicateOfLearningID: in.DuplicateOfLearningID, SupersedesLearningID: in.SupersedesLearningID, Tags: normalizeStringSlice(in.Tags), } if in.ActionRequired != nil { learning.ActionRequired = *in.ActionRequired } if project != nil { learning.ProjectID = &project.NumericID } created, err := t.store.CreateLearning(ctx, learning) if err != nil { return nil, AddLearningOutput{}, err } return nil, AddLearningOutput{Learning: created}, nil } func (t *LearningsTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetLearningInput) (*mcp.CallToolResult, GetLearningOutput, error) { if err := t.ensureConfigured(); err != nil { return nil, GetLearningOutput{}, err } learning, err := t.store.GetLearning(ctx, in.ID) if err != nil { return nil, GetLearningOutput{}, err } return nil, GetLearningOutput{Learning: learning}, nil } func (t *LearningsTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListLearningsInput) (*mcp.CallToolResult, ListLearningsOutput, error) { if err := t.ensureConfigured(); err != nil { return nil, ListLearningsOutput{}, err } project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false) if err != nil { return nil, ListLearningsOutput{}, err } filter := thoughttypes.LearningFilter{ Limit: normalizeLimit(in.Limit, t.cfg), Category: strings.TrimSpace(in.Category), Area: strings.TrimSpace(in.Area), Status: strings.TrimSpace(in.Status), Priority: strings.TrimSpace(in.Priority), Tag: strings.TrimSpace(in.Tag), Query: strings.TrimSpace(in.Query), } if project != nil { filter.ProjectID = &project.NumericID } items, err := t.store.ListLearnings(ctx, filter) if err != nil { return nil, ListLearningsOutput{}, err } return nil, ListLearningsOutput{Learnings: items}, nil } func (t *LearningsTool) ensureConfigured() error { if t == nil || t.store == nil { return errInvalidInput("learnings tool is not configured") } return nil } func defaultString(value string, fallback string) string { if value == "" { return fallback } return value } func normalizeStringSlice(values []string) []string { if len(values) == 0 { return []string{} } out := make([]string, 0, len(values)) seen := map[string]struct{}{} for _, value := range values { trimmed := strings.TrimSpace(value) if trimmed == "" { continue } if _, ok := seen[trimmed]; ok { continue } seen[trimmed] = struct{}{} out = append(out, trimmed) } return out }