Files
amcs/internal/tools/learnings.go
sgcommand 3e832eea98
Some checks failed
CI / build-and-test (push) Failing after -32m34s
CI / build-and-test (pull_request) Failing after -32m27s
feat(learnings): add store and MCP tool layer
2026-04-22 14:00:12 +02:00

175 lines
6.1 KiB
Go

package tools
import (
"context"
"strings"
"github.com/google/uuid"
"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 *uuid.UUID `json:"related_thought_id,omitempty"`
RelatedSkillID *uuid.UUID `json:"related_skill_id,omitempty"`
ReviewedBy *string `json:"reviewed_by,omitempty"`
DuplicateOfLearningID *uuid.UUID `json:"duplicate_of_learning_id,omitempty"`
SupersedesLearningID *uuid.UUID `json:"supersedes_learning_id,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type AddLearningOutput struct {
Learning thoughttypes.Learning `json:"learning"`
}
type GetLearningInput struct {
ID uuid.UUID `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")
}
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.ID
}
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) {
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) {
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.ID
}
items, err := t.store.ListLearnings(ctx, filter)
if err != nil {
return nil, ListLearningsOutput{}, err
}
return nil, ListLearningsOutput{Learnings: items}, 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
}