diff --git a/internal/app/app.go b/internal/app/app.go index 3359c30..305727f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -203,6 +203,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st Archive: tools.NewArchiveTool(db), Projects: tools.NewProjectsTool(db, activeProjects), Version: tools.NewVersionTool(cfg.MCP.ServerName, info), + Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search), Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects), Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects), Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects), diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index c9e55b5..5f53f5f 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -40,6 +40,7 @@ type ToolSet struct { Skills *tools.SkillsTool ChatHistory *tools.ChatHistoryTool Describe *tools.DescribeTool + Learnings *tools.LearningsTool } // Handlers groups the HTTP handlers produced for an MCP server instance. @@ -83,6 +84,7 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS registerSystemTools, registerThoughtTools, registerProjectTools, + registerLearningTools, registerFileTools, registerMaintenanceTools, registerSkillTools, @@ -249,6 +251,28 @@ func registerProjectTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS return nil } +func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { + if err := addTool(server, logger, &mcp.Tool{ + Name: "add_learning", + Description: "Create a curated learning record distinct from raw thoughts.", + }, toolSet.Learnings.Add); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "get_learning", + Description: "Retrieve a structured learning by id.", + }, toolSet.Learnings.Get); err != nil { + return err + } + if err := addTool(server, logger, &mcp.Tool{ + Name: "list_learnings", + Description: "List structured learnings with optional project, status, priority, tag, and text filters.", + }, toolSet.Learnings.List); err != nil { + return err + } + return nil +} + func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error { server.AddResourceTemplate(&mcp.ResourceTemplate{ Name: "stored_file", @@ -477,6 +501,11 @@ func BuildToolCatalog() []tools.ToolEntry { {Name: "get_active_project", Description: "Return the active project for the current MCP session. If your client does not preserve MCP sessions, pass project explicitly to project-scoped tools instead of relying on this.", Category: "projects"}, {Name: "get_project_context", Description: "Get recent and semantic context for a project. Uses the explicit project when provided, otherwise the active MCP session project. Falls back to full-text search when no embeddings exist.", Category: "projects"}, + // learnings + {Name: "add_learning", Description: "Create a curated learning record distinct from raw thoughts.", Category: "projects"}, + {Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"}, + {Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"}, + // files {Name: "upload_file", Description: "Stage a file and get an amcs://files/{id} resource URI. Use content_path (absolute server-side path, no size limit) for large or binary files, or content_base64 (≤10 MB) for small files. Pass thought_id/project to link immediately, or omit and pass the URI to save_file later.", Category: "files"}, {Name: "save_file", Description: "Store a file and optionally link it to a thought. Use content_base64 (≤10 MB) for small files, or content_uri (amcs://files/{id} from a prior upload_file) for previously staged files. For files larger than 10 MB, use upload_file with content_path first. If the goal is to retain the artifact, store the file directly instead of reading or summarising it first.", Category: "files"}, diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go index 4fcee5c..7c71dc1 100644 --- a/internal/mcpserver/server_test.go +++ b/internal/mcpserver/server_test.go @@ -29,6 +29,7 @@ func TestNewListsAllRegisteredTools(t *testing.T) { want := []string{ "add_guardrail", + "add_learning", "add_maintenance_task", "add_project_guardrail", "add_project_skill", @@ -43,6 +44,7 @@ func TestNewListsAllRegisteredTools(t *testing.T) { "describe_tools", "get_active_project", "get_chat_history", + "get_learning", "get_project_context", "get_thought", "get_upcoming_maintenance", @@ -51,6 +53,7 @@ func TestNewListsAllRegisteredTools(t *testing.T) { "list_chat_histories", "list_files", "list_guardrails", + "list_learnings", "list_project_guardrails", "list_project_skills", "list_projects", diff --git a/internal/store/learnings.go b/internal/store/learnings.go new file mode 100644 index 0000000..9089c93 --- /dev/null +++ b/internal/store/learnings.go @@ -0,0 +1,215 @@ +package store + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + thoughttypes "git.warky.dev/wdevs/amcs/internal/types" +) + +func (db *DB) CreateLearning(ctx context.Context, learning thoughttypes.Learning) (thoughttypes.Learning, error) { + row := db.pool.QueryRow(ctx, ` + insert into learnings ( + summary, details, category, area, status, priority, confidence, + action_required, source_type, source_ref, project_id, related_thought_id, + related_skill_id, reviewed_by, reviewed_at, duplicate_of_learning_id, + supersedes_learning_id, tags + ) values ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, $16, + $17, $18 + ) + returning id, created_at, updated_at + `, + strings.TrimSpace(learning.Summary), + strings.TrimSpace(learning.Details), + strings.TrimSpace(learning.Category), + strings.TrimSpace(learning.Area), + string(learning.Status), + string(learning.Priority), + string(learning.Confidence), + learning.ActionRequired, + nullableText(learning.SourceType), + nullableText(learning.SourceRef), + learning.ProjectID, + learning.RelatedThoughtID, + learning.RelatedSkillID, + nullableTextPtr(learning.ReviewedBy), + learning.ReviewedAt, + learning.DuplicateOfLearningID, + learning.SupersedesLearningID, + learning.Tags, + ) + + created := learning + if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil { + return thoughttypes.Learning{}, fmt.Errorf("create learning: %w", err) + } + return created, nil +} + +func (db *DB) GetLearning(ctx context.Context, id uuid.UUID) (thoughttypes.Learning, error) { + row := db.pool.QueryRow(ctx, ` + select id, summary, details, category, area, status, priority, confidence, + action_required, source_type, source_ref, project_id, related_thought_id, + related_skill_id, reviewed_by, reviewed_at, duplicate_of_learning_id, + supersedes_learning_id, tags, created_at, updated_at + from learnings + where id = $1 + `, id) + + learning, err := scanLearning(row) + if err != nil { + if err == pgx.ErrNoRows { + return thoughttypes.Learning{}, fmt.Errorf("learning not found: %s", id) + } + return thoughttypes.Learning{}, fmt.Errorf("get learning: %w", err) + } + return learning, nil +} + +func (db *DB) ListLearnings(ctx context.Context, filter thoughttypes.LearningFilter) ([]thoughttypes.Learning, error) { + args := make([]any, 0, 8) + conditions := make([]string, 0, 8) + + if filter.ProjectID != nil { + args = append(args, *filter.ProjectID) + conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args))) + } + if value := strings.TrimSpace(filter.Category); value != "" { + args = append(args, value) + conditions = append(conditions, fmt.Sprintf("category = $%d", len(args))) + } + if value := strings.TrimSpace(filter.Area); value != "" { + args = append(args, value) + conditions = append(conditions, fmt.Sprintf("area = $%d", len(args))) + } + if value := strings.TrimSpace(filter.Status); value != "" { + args = append(args, value) + conditions = append(conditions, fmt.Sprintf("status = $%d", len(args))) + } + if value := strings.TrimSpace(filter.Priority); value != "" { + args = append(args, value) + conditions = append(conditions, fmt.Sprintf("priority = $%d", len(args))) + } + if value := strings.TrimSpace(filter.Tag); value != "" { + args = append(args, value) + conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args))) + } + if value := strings.TrimSpace(filter.Query); value != "" { + args = append(args, value) + conditions = append(conditions, fmt.Sprintf("to_tsvector('simple', summary || ' ' || coalesce(details, '')) @@ websearch_to_tsquery('simple', $%d)", len(args))) + } + + query := ` + select id, summary, details, category, area, status, priority, confidence, + action_required, source_type, source_ref, project_id, related_thought_id, + related_skill_id, reviewed_by, reviewed_at, duplicate_of_learning_id, + supersedes_learning_id, tags, created_at, updated_at + from learnings + ` + if len(conditions) > 0 { + query += " where " + strings.Join(conditions, " and ") + } + query += " order by updated_at desc" + if filter.Limit > 0 { + args = append(args, filter.Limit) + query += fmt.Sprintf(" limit $%d", len(args)) + } + + rows, err := db.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list learnings: %w", err) + } + defer rows.Close() + + items := make([]thoughttypes.Learning, 0) + for rows.Next() { + item, err := scanLearning(rows) + if err != nil { + return nil, fmt.Errorf("scan learning: %w", err) + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate learnings: %w", err) + } + return items, nil +} + +type learningScanner interface { + Scan(dest ...any) error +} + +func scanLearning(row learningScanner) (thoughttypes.Learning, error) { + var learning thoughttypes.Learning + var sourceType pgtype.Text + var sourceRef pgtype.Text + var reviewedBy pgtype.Text + var tags []string + + err := row.Scan( + &learning.ID, + &learning.Summary, + &learning.Details, + &learning.Category, + &learning.Area, + &learning.Status, + &learning.Priority, + &learning.Confidence, + &learning.ActionRequired, + &sourceType, + &sourceRef, + &learning.ProjectID, + &learning.RelatedThoughtID, + &learning.RelatedSkillID, + &reviewedBy, + &learning.ReviewedAt, + &learning.DuplicateOfLearningID, + &learning.SupersedesLearningID, + &tags, + &learning.CreatedAt, + &learning.UpdatedAt, + ) + if err != nil { + return thoughttypes.Learning{}, err + } + + learning.SourceType = sourceType.String + learning.SourceRef = sourceRef.String + if reviewedBy.Valid { + value := reviewedBy.String + learning.ReviewedBy = &value + } + if tags == nil { + learning.Tags = []string{} + } else { + learning.Tags = tags + } + return learning, nil +} + +func nullableText(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func nullableTextPtr(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/internal/tools/learnings.go b/internal/tools/learnings.go new file mode 100644 index 0000000..b43e3ac --- /dev/null +++ b/internal/tools/learnings.go @@ -0,0 +1,174 @@ +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 +} diff --git a/internal/types/learning.go b/internal/types/learning.go new file mode 100644 index 0000000..f0b986a --- /dev/null +++ b/internal/types/learning.go @@ -0,0 +1,68 @@ +package types + +import ( + "time" + + "github.com/google/uuid" +) + +type LearningEvidenceLevel string + +const ( + LearningEvidenceHypothesis LearningEvidenceLevel = "hypothesis" + LearningEvidenceObserved LearningEvidenceLevel = "observed" + LearningEvidenceVerified LearningEvidenceLevel = "verified" +) + +type LearningStatus string + +const ( + LearningStatusPending LearningStatus = "pending" + LearningStatusInProgress LearningStatus = "in_progress" + LearningStatusResolved LearningStatus = "resolved" + LearningStatusWontFix LearningStatus = "wont_fix" + LearningStatusPromoted LearningStatus = "promoted" +) + +type LearningPriority string + +const ( + LearningPriorityLow LearningPriority = "low" + LearningPriorityMedium LearningPriority = "medium" + LearningPriorityHigh LearningPriority = "high" +) + +type Learning struct { + ID uuid.UUID `json:"id"` + Summary string `json:"summary"` + Details string `json:"details"` + Category string `json:"category"` + Area string `json:"area"` + Status LearningStatus `json:"status"` + Priority LearningPriority `json:"priority"` + Confidence LearningEvidenceLevel `json:"confidence"` + ActionRequired bool `json:"action_required"` + SourceType string `json:"source_type,omitempty"` + SourceRef string `json:"source_ref,omitempty"` + ProjectID *uuid.UUID `json:"project_id,omitempty"` + RelatedThoughtID *uuid.UUID `json:"related_thought_id,omitempty"` + RelatedSkillID *uuid.UUID `json:"related_skill_id,omitempty"` + ReviewedBy *string `json:"reviewed_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + DuplicateOfLearningID *uuid.UUID `json:"duplicate_of_learning_id,omitempty"` + SupersedesLearningID *uuid.UUID `json:"supersedes_learning_id,omitempty"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type LearningFilter struct { + Limit int + ProjectID *uuid.UUID + Category string + Area string + Status string + Priority string + Tag string + Query string +}