feat(learnings): add store and MCP tool layer #34

Merged
warkanum merged 1 commits from feature/issue-4-learnings-store-layer into main 2026-04-22 12:45:29 +00:00
6 changed files with 490 additions and 0 deletions
Showing only changes of commit 3e832eea98 - Show all commits

View File

@@ -203,6 +203,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
Archive: tools.NewArchiveTool(db), Archive: tools.NewArchiveTool(db),
Projects: tools.NewProjectsTool(db, activeProjects), Projects: tools.NewProjectsTool(db, activeProjects),
Version: tools.NewVersionTool(cfg.MCP.ServerName, info), Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects), Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects), Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects), Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),

View File

@@ -40,6 +40,7 @@ type ToolSet struct {
Skills *tools.SkillsTool Skills *tools.SkillsTool
ChatHistory *tools.ChatHistoryTool ChatHistory *tools.ChatHistoryTool
Describe *tools.DescribeTool Describe *tools.DescribeTool
Learnings *tools.LearningsTool
} }
// Handlers groups the HTTP handlers produced for an MCP server instance. // 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, registerSystemTools,
registerThoughtTools, registerThoughtTools,
registerProjectTools, registerProjectTools,
registerLearningTools,
registerFileTools, registerFileTools,
registerMaintenanceTools, registerMaintenanceTools,
registerSkillTools, registerSkillTools,
@@ -249,6 +251,28 @@ func registerProjectTools(server *mcp.Server, logger *slog.Logger, toolSet ToolS
return nil 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 { func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
server.AddResourceTemplate(&mcp.ResourceTemplate{ server.AddResourceTemplate(&mcp.ResourceTemplate{
Name: "stored_file", 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_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"}, {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 // 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: "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"}, {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"},

View File

@@ -29,6 +29,7 @@ func TestNewListsAllRegisteredTools(t *testing.T) {
want := []string{ want := []string{
"add_guardrail", "add_guardrail",
"add_learning",
"add_maintenance_task", "add_maintenance_task",
"add_project_guardrail", "add_project_guardrail",
"add_project_skill", "add_project_skill",
@@ -43,6 +44,7 @@ func TestNewListsAllRegisteredTools(t *testing.T) {
"describe_tools", "describe_tools",
"get_active_project", "get_active_project",
"get_chat_history", "get_chat_history",
"get_learning",
"get_project_context", "get_project_context",
"get_thought", "get_thought",
"get_upcoming_maintenance", "get_upcoming_maintenance",
@@ -51,6 +53,7 @@ func TestNewListsAllRegisteredTools(t *testing.T) {
"list_chat_histories", "list_chat_histories",
"list_files", "list_files",
"list_guardrails", "list_guardrails",
"list_learnings",
"list_project_guardrails", "list_project_guardrails",
"list_project_skills", "list_project_skills",
"list_projects", "list_projects",

215
internal/store/learnings.go Normal file
View File

@@ -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
}

174
internal/tools/learnings.go Normal file
View File

@@ -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
}

View File

@@ -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
}