1 Commits

Author SHA1 Message Date
warkanum 81a3470407 feat(tools): link thoughts and learnings
CI / build-and-test (pull_request) Failing after 1m2s
CI / build-and-test (push) Failing after 3m10s
2026-07-14 15:18:57 +02:00
17 changed files with 479 additions and 164 deletions
+1
View File
@@ -36,3 +36,4 @@ ui/.svelte-kit/
internal/app/ui/dist/*
!internal/app/ui/dist/placeholder.txt
.codex
.worktrees/
+1 -1
View File
@@ -478,7 +478,7 @@ metadata_retry:
include_archived: false
```
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty. All five tools include a `retrieval_mode` field in their response (`"semantic"` or `"text"`) so callers can see which path was taken.
**Search fallback**: when no embeddings exist for the active model in scope, `search_thoughts`, `recall_context`, `get_project_context`, `summarize_thoughts`, and `related_thoughts` automatically fall back to Postgres full-text search so results are never silently empty.
## Client Setup
+1
View File
@@ -208,6 +208,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
ProjectPersonas: tools.NewProjectPersonasTool(db, activeProjects),
WorldModel: tools.NewWorldModelTool(db, activeProjects),
ThoughtLearningLinks: tools.NewThoughtLearningLinksTool(db),
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),
+34
View File
@@ -46,6 +46,7 @@ type ToolSet struct {
Plans *tools.PlansTool
ProjectPersonas *tools.ProjectPersonasTool
WorldModel *tools.WorldModelTool
ThoughtLearningLinks *tools.ThoughtLearningLinksTool
}
// Handlers groups the HTTP handlers produced for an MCP server instance.
@@ -91,6 +92,7 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
registerProjectTools,
registerWorldModelTools,
registerLearningTools,
registerThoughtLearningLinkTools,
registerPlanTools,
registerFileTools,
registerMaintenanceTools,
@@ -308,6 +310,34 @@ func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet Tool
return nil
}
func registerThoughtLearningLinkTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
if err := addTool(server, logger, &mcp.Tool{
Name: "link_thought_learning",
Description: "Create or update an explicit association between a raw thought and a curated learning.",
}, toolSet.ThoughtLearningLinks.Link); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "unlink_thought_learning",
Description: "Remove an explicit association between a thought and a learning.",
}, toolSet.ThoughtLearningLinks.Unlink); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "get_thought_learnings",
Description: "List curated learnings explicitly linked to a thought.",
}, toolSet.ThoughtLearningLinks.GetThoughtLearnings); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "get_learning_thoughts",
Description: "List raw thoughts explicitly linked to a learning.",
}, toolSet.ThoughtLearningLinks.GetLearningThoughts); err != nil {
return err
}
return nil
}
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
if err := addTool(server, logger, &mcp.Tool{
Name: "create_plan",
@@ -755,6 +785,10 @@ func BuildToolCatalog() []tools.ToolEntry {
{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"},
{Name: "link_thought_learning", Description: "Create or update a lightweight explicit association between a raw thought and a curated learning.", Category: "projects"},
{Name: "unlink_thought_learning", Description: "Remove a lightweight explicit association between a thought and a learning.", Category: "projects"},
{Name: "get_thought_learnings", Description: "List curated learnings explicitly associated with a thought.", Category: "projects"},
{Name: "get_learning_thoughts", Description: "List raw source thoughts explicitly associated with a learning.", Category: "projects"},
// plans
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
@@ -214,5 +214,6 @@ func streamableTestToolSet() ToolSet {
Plans: new(tools.PlansTool),
ProjectPersonas: new(tools.ProjectPersonasTool),
WorldModel: new(tools.WorldModelTool),
ThoughtLearningLinks: new(tools.ThoughtLearningLinksTool),
}
}
+148
View File
@@ -0,0 +1,148 @@
package store
import (
"context"
"fmt"
"github.com/google/uuid"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
// LinkThoughtLearning creates or updates an association between a thought (by GUID) and a learning (by numeric ID).
// If the pair already exists, the relation label is updated.
func (db *DB) LinkThoughtLearning(ctx context.Context, thoughtGUID uuid.UUID, learningID int64, relation string) error {
_, err := db.pool.Exec(ctx, `
insert into thought_learning_links (thought_id, learning_id, relation)
select t.id, $2, $3
from thoughts t
where t.guid = $1
on conflict (thought_id, learning_id) do update set relation = excluded.relation
`, thoughtGUID, learningID, relation)
if err != nil {
return fmt.Errorf("link thought learning: %w", err)
}
return nil
}
// UnlinkThoughtLearning removes the association between a thought (by GUID) and a learning (by numeric ID).
func (db *DB) UnlinkThoughtLearning(ctx context.Context, thoughtGUID uuid.UUID, learningID int64) error {
_, err := db.pool.Exec(ctx, `
delete from thought_learning_links
where thought_id = (select id from thoughts where guid = $1)
and learning_id = $2
`, thoughtGUID, learningID)
if err != nil {
return fmt.Errorf("unlink thought learning: %w", err)
}
return nil
}
// GetLinkedLearnings returns all learnings explicitly associated with a thought (by GUID).
func (db *DB) GetLinkedLearnings(ctx context.Context, thoughtGUID uuid.UUID) ([]thoughttypes.LinkedLearning, error) {
rows, err := db.pool.Query(ctx, `
select l.id, l.guid, l.summary, l.details, l.category, l.area, l.status, l.priority, l.confidence,
l.action_required, l.source_type, l.source_ref, l.project_id, l.related_thought_id,
l.related_skill_id, l.reviewed_by, l.reviewed_at, l.duplicate_of_learning_id,
l.supersedes_learning_id, l.tags::text[], l.created_at, l.updated_at,
tll.relation, tll.created_at as linked_at
from thought_learning_links tll
join learnings l on l.id = tll.learning_id
where tll.thought_id = (select id from thoughts where guid = $1)
order by tll.created_at desc
`, thoughtGUID)
if err != nil {
return nil, fmt.Errorf("query linked learnings: %w", err)
}
defer rows.Close()
items := make([]thoughttypes.LinkedLearning, 0)
for rows.Next() {
var tags []string
var linked thoughttypes.LinkedLearning
var m generatedmodels.ModelPublicLearnings
if err := rows.Scan(
&m.ID,
&m.GUID,
&m.Summary,
&m.Details,
&m.Category,
&m.Area,
&m.Status,
&m.Priority,
&m.Confidence,
&m.ActionRequired,
&m.SourceType,
&m.SourceRef,
&m.ProjectID,
&m.RelatedThoughtID,
&m.RelatedSkillID,
&m.ReviewedBy,
&m.ReviewedAt,
&m.DuplicateOfLearningID,
&m.SupersedesLearningID,
&tags,
&m.CreatedAt,
&m.UpdatedAt,
&linked.Relation,
&linked.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan linked learning: %w", err)
}
linked.Learning = learningFromModel(m, tags)
items = append(items, linked)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate linked learnings: %w", err)
}
return items, nil
}
// GetLinkedThoughtsForLearning returns all thoughts explicitly associated with a learning (by numeric ID).
func (db *DB) GetLinkedThoughtsForLearning(ctx context.Context, learningID int64) ([]thoughttypes.LinkedLearningThought, error) {
rows, err := db.pool.Query(ctx, `
select t.id, t.guid, t.content, t.metadata, t.project_id, t.archived_at, t.created_at, t.updated_at,
tll.relation, tll.created_at as linked_at
from thought_learning_links tll
join thoughts t on t.id = tll.thought_id
where tll.learning_id = $1
order by tll.created_at desc
`, learningID)
if err != nil {
return nil, fmt.Errorf("query linked thoughts for learning: %w", err)
}
defer rows.Close()
items := make([]thoughttypes.LinkedLearningThought, 0)
for rows.Next() {
var linked thoughttypes.LinkedLearningThought
var m generatedmodels.ModelPublicThoughts
if err := rows.Scan(
&m.ID,
&m.GUID,
&m.Content,
&m.Metadata,
&m.ProjectID,
&m.ArchivedAt,
&m.CreatedAt,
&m.UpdatedAt,
&linked.Relation,
&linked.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan linked thought for learning: %w", err)
}
thought, err := thoughtFromModel(m)
if err != nil {
return nil, fmt.Errorf("map linked thought for learning: %w", err)
}
linked.Thought = thought
items = append(items, linked)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate linked thoughts for learning: %w", err)
}
return items, nil
}
+1 -5
View File
@@ -39,7 +39,6 @@ type ProjectContextOutput struct {
Project thoughttypes.Project `json:"project"`
Context string `json:"context"`
Items []ContextItem `json:"items"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewContextTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *ContextTool {
@@ -71,14 +70,12 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
})
}
var retrievalMode string
query := strings.TrimSpace(in.Query)
if query != "" {
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, &project.NumericID, nil)
if err != nil {
return nil, ProjectContextOutput{}, err
}
retrievalMode = mode
for _, result := range semantic {
key := fmt.Sprint(result.ID)
if _, ok := seen[key]; ok {
@@ -106,6 +103,5 @@ func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in P
Project: *project,
Context: contextBlock,
Items: items,
RetrievalMode: retrievalMode,
}, nil
}
+2 -5
View File
@@ -46,7 +46,6 @@ type RelatedThought struct {
type RelatedOutput struct {
Related []RelatedThought `json:"related"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewLinksTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig) *LinksTool {
@@ -118,13 +117,11 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
includeSemantic = *in.IncludeSemantic
}
var retrievalMode string
if includeSemantic {
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, thought.Content, t.search.DefaultLimit, t.search.DefaultThreshold, thought.ProjectID, &thought.GUID)
if err != nil {
return nil, RelatedOutput{}, err
}
retrievalMode = mode
for _, item := range semantic {
key := fmt.Sprint(item.ID)
if _, ok := seen[key]; ok {
@@ -141,5 +138,5 @@ func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in Rela
}
}
return nil, RelatedOutput{Related: related, RetrievalMode: retrievalMode}, nil
return nil, RelatedOutput{Related: related}, nil
}
+1 -3
View File
@@ -29,7 +29,6 @@ type RecallInput struct {
type RecallOutput struct {
Context string `json:"context"`
Items []ContextItem `json:"items"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewRecallTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *RecallTool {
@@ -54,7 +53,7 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
projectID = &project.NumericID
}
semantic, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
semantic, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
if err != nil {
return nil, RecallOutput{}, err
}
@@ -103,6 +102,5 @@ func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Re
return nil, RecallOutput{
Context: formatContextBlock(header, lines),
Items: items,
RetrievalMode: mode,
}, nil
}
+5 -13
View File
@@ -11,16 +11,10 @@ import (
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const (
RetrievalModeSemantic = "semantic"
RetrievalModeText = "text"
)
// semanticSearch runs vector similarity search if embeddings exist for the
// primary embedding model in the given scope, otherwise falls back to Postgres
// full-text search. Search always uses the primary model so query vectors
// match rows stored under the primary model name.
// It returns the results and the retrieval mode used ("semantic" or "text").
func semanticSearch(
ctx context.Context,
db *store.DB,
@@ -31,22 +25,20 @@ func semanticSearch(
threshold float64,
projectID *int64,
excludeID *uuid.UUID,
) ([]thoughttypes.SearchResult, string, error) {
) ([]thoughttypes.SearchResult, error) {
model := embeddings.PrimaryModel()
hasEmbeddings, err := db.HasEmbeddingsForModel(ctx, model, projectID)
if err != nil {
return nil, "", err
return nil, err
}
if hasEmbeddings {
embedding, err := embeddings.EmbedPrimary(ctx, query)
if err != nil {
return nil, "", err
return nil, err
}
results, err := db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
return results, RetrievalModeSemantic, err
return db.SearchSimilarThoughts(ctx, embedding, model, threshold, limit, projectID, excludeID)
}
results, err := db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
return results, RetrievalModeText, err
return db.SearchThoughtsText(ctx, query, limit, projectID, excludeID)
}
-60
View File
@@ -1,60 +0,0 @@
package tools
import "testing"
func TestRetrievalModeConstants(t *testing.T) {
if RetrievalModeSemantic != "semantic" {
t.Fatalf("RetrievalModeSemantic = %q, want %q", RetrievalModeSemantic, "semantic")
}
if RetrievalModeText != "text" {
t.Fatalf("RetrievalModeText = %q, want %q", RetrievalModeText, "text")
}
}
func TestSearchOutputIncludesRetrievalMode(t *testing.T) {
out := SearchOutput{RetrievalMode: RetrievalModeSemantic}
if out.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("SearchOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
}
}
func TestRelatedOutputIncludesRetrievalMode(t *testing.T) {
out := RelatedOutput{RetrievalMode: RetrievalModeText}
if out.RetrievalMode != RetrievalModeText {
t.Fatalf("RelatedOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeText)
}
// retrieval_mode is omitempty — empty string means semantic search was skipped
out2 := RelatedOutput{}
if out2.RetrievalMode != "" {
t.Fatalf("RelatedOutput.RetrievalMode = %q, want empty when include_semantic is false", out2.RetrievalMode)
}
}
func TestSummarizeOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
withQuery := SummarizeOutput{Summary: "s", Count: 1, RetrievalMode: RetrievalModeSemantic}
if withQuery.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeSemantic)
}
withoutQuery := SummarizeOutput{Summary: "s", Count: 1}
if withoutQuery.RetrievalMode != "" {
t.Fatalf("SummarizeOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
}
}
func TestProjectContextOutputIncludesRetrievalModeOnlyWhenQueryUsed(t *testing.T) {
withQuery := ProjectContextOutput{RetrievalMode: RetrievalModeText}
if withQuery.RetrievalMode != RetrievalModeText {
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want %q", withQuery.RetrievalMode, RetrievalModeText)
}
withoutQuery := ProjectContextOutput{}
if withoutQuery.RetrievalMode != "" {
t.Fatalf("ProjectContextOutput.RetrievalMode = %q, want empty when no query", withoutQuery.RetrievalMode)
}
}
func TestRecallOutputIncludesRetrievalMode(t *testing.T) {
out := RecallOutput{RetrievalMode: RetrievalModeSemantic}
if out.RetrievalMode != RetrievalModeSemantic {
t.Fatalf("RecallOutput.RetrievalMode = %q, want %q", out.RetrievalMode, RetrievalModeSemantic)
}
}
+2 -3
View File
@@ -29,7 +29,6 @@ type SearchInput struct {
type SearchOutput struct {
Results []thoughttypes.SearchResult `json:"results"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewSearchTool(db *store.DB, embeddings *ai.EmbeddingRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SearchTool {
@@ -56,10 +55,10 @@ func (t *SearchTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in Se
_ = t.store.TouchProject(ctx, project.NumericID)
}
results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, threshold, projectID, nil)
if err != nil {
return nil, SearchOutput{}, err
}
return nil, SearchOutput{Results: results, RetrievalMode: mode}, nil
return nil, SearchOutput{Results: results}, nil
}
+2 -5
View File
@@ -30,7 +30,6 @@ type SummarizeInput struct {
type SummarizeOutput struct {
Summary string `json:"summary"`
Count int `json:"count"`
RetrievalMode string `json:"retrieval_mode,omitempty"`
}
func NewSummarizeTool(db *store.DB, embeddings *ai.EmbeddingRunner, metadata *ai.MetadataRunner, search config.SearchConfig, sessions *session.ActiveProjects) *SummarizeTool {
@@ -48,17 +47,15 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
lines := make([]string, 0, limit)
count := 0
var retrievalMode string
if query != "" {
var projectID *int64
if project != nil {
projectID = &project.NumericID
}
results, mode, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
results, err := semanticSearch(ctx, t.store, t.embeddings, t.search, query, limit, t.search.DefaultThreshold, projectID, nil)
if err != nil {
return nil, SummarizeOutput{}, err
}
retrievalMode = mode
for i, result := range results {
lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity))
}
@@ -88,5 +85,5 @@ func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in
_ = t.store.TouchProject(ctx, project.NumericID)
}
return nil, SummarizeOutput{Summary: summary, Count: count, RetrievalMode: retrievalMode}, nil
return nil, SummarizeOutput{Summary: summary, Count: count}, nil
}
+152
View File
@@ -0,0 +1,152 @@
package tools
import (
"context"
"strconv"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type ThoughtLearningLinksTool struct {
store *store.DB
}
type LinkThoughtLearningInput struct {
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought to link"`
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning to link"`
Relation string `json:"relation,omitempty" jsonschema:"relationship label, e.g. source, derived_from, related; defaults to source"`
}
type LinkThoughtLearningOutput struct {
Linked bool `json:"linked"`
}
type UnlinkThoughtLearningInput struct {
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought"`
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning"`
}
type UnlinkThoughtLearningOutput struct {
Unlinked bool `json:"unlinked"`
}
type GetThoughtLearningsInput struct {
ThoughtID string `json:"thought_id" jsonschema:"UUID of the thought"`
}
type GetThoughtLearningsOutput struct {
Learnings []thoughttypes.LinkedLearning `json:"learnings"`
}
type GetLearningThoughtsInput struct {
LearningID int64 `json:"learning_id" jsonschema:"numeric id of the learning"`
}
type GetLearningThoughtsOutput struct {
Thoughts []thoughttypes.LinkedLearningThought `json:"thoughts"`
}
func NewThoughtLearningLinksTool(db *store.DB) *ThoughtLearningLinksTool {
return &ThoughtLearningLinksTool{store: db}
}
func (t *ThoughtLearningLinksTool) Link(ctx context.Context, _ *mcp.CallToolRequest, in LinkThoughtLearningInput) (*mcp.CallToolResult, LinkThoughtLearningOutput, error) {
if err := t.ensureConfigured(); err != nil {
return nil, LinkThoughtLearningOutput{}, err
}
thoughtGUID, err := parseUUID(in.ThoughtID)
if err != nil {
return nil, LinkThoughtLearningOutput{}, err
}
if in.LearningID <= 0 {
return nil, LinkThoughtLearningOutput{}, errRequiredField("learning_id")
}
relation := strings.TrimSpace(in.Relation)
if relation == "" {
relation = "source"
}
if _, err := t.store.GetThought(ctx, thoughtGUID); err != nil {
return nil, LinkThoughtLearningOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID)
}
if _, err := t.store.GetLearning(ctx, in.LearningID); err != nil {
return nil, LinkThoughtLearningOutput{}, errEntityNotFound("learning", "learning_id", strconv.FormatInt(in.LearningID, 10))
}
if err := t.store.LinkThoughtLearning(ctx, thoughtGUID, in.LearningID, relation); err != nil {
return nil, LinkThoughtLearningOutput{}, err
}
return nil, LinkThoughtLearningOutput{Linked: true}, nil
}
func (t *ThoughtLearningLinksTool) Unlink(ctx context.Context, _ *mcp.CallToolRequest, in UnlinkThoughtLearningInput) (*mcp.CallToolResult, UnlinkThoughtLearningOutput, error) {
if err := t.ensureConfigured(); err != nil {
return nil, UnlinkThoughtLearningOutput{}, err
}
thoughtGUID, err := parseUUID(in.ThoughtID)
if err != nil {
return nil, UnlinkThoughtLearningOutput{}, err
}
if in.LearningID <= 0 {
return nil, UnlinkThoughtLearningOutput{}, errRequiredField("learning_id")
}
if err := t.store.UnlinkThoughtLearning(ctx, thoughtGUID, in.LearningID); err != nil {
return nil, UnlinkThoughtLearningOutput{}, err
}
return nil, UnlinkThoughtLearningOutput{Unlinked: true}, nil
}
func (t *ThoughtLearningLinksTool) GetThoughtLearnings(ctx context.Context, _ *mcp.CallToolRequest, in GetThoughtLearningsInput) (*mcp.CallToolResult, GetThoughtLearningsOutput, error) {
if err := t.ensureConfigured(); err != nil {
return nil, GetThoughtLearningsOutput{}, err
}
thoughtGUID, err := parseUUID(in.ThoughtID)
if err != nil {
return nil, GetThoughtLearningsOutput{}, err
}
if _, err := t.store.GetThought(ctx, thoughtGUID); err != nil {
return nil, GetThoughtLearningsOutput{}, errEntityNotFound("thought", "thought_id", in.ThoughtID)
}
learnings, err := t.store.GetLinkedLearnings(ctx, thoughtGUID)
if err != nil {
return nil, GetThoughtLearningsOutput{}, err
}
return nil, GetThoughtLearningsOutput{Learnings: learnings}, nil
}
func (t *ThoughtLearningLinksTool) GetLearningThoughts(ctx context.Context, _ *mcp.CallToolRequest, in GetLearningThoughtsInput) (*mcp.CallToolResult, GetLearningThoughtsOutput, error) {
if err := t.ensureConfigured(); err != nil {
return nil, GetLearningThoughtsOutput{}, err
}
if in.LearningID <= 0 {
return nil, GetLearningThoughtsOutput{}, errRequiredField("learning_id")
}
if _, err := t.store.GetLearning(ctx, in.LearningID); err != nil {
return nil, GetLearningThoughtsOutput{}, errEntityNotFound("learning", "learning_id", strconv.FormatInt(in.LearningID, 10))
}
thoughts, err := t.store.GetLinkedThoughtsForLearning(ctx, in.LearningID)
if err != nil {
return nil, GetLearningThoughtsOutput{}, err
}
return nil, GetLearningThoughtsOutput{Thoughts: thoughts}, nil
}
func (t *ThoughtLearningLinksTool) ensureConfigured() error {
if t == nil || t.store == nil {
return errInvalidInput("thought learning links tool is not configured")
}
return nil
}
+19
View File
@@ -39,3 +39,22 @@ type LinkedThought struct {
Direction string `json:"direction"`
CreatedAt time.Time `json:"created_at"`
}
type ThoughtLearningLink struct {
ThoughtID int64 `json:"thought_id"`
LearningID int64 `json:"learning_id"`
Relation string `json:"relation"`
CreatedAt time.Time `json:"created_at"`
}
type LinkedLearning struct {
Learning Learning `json:"learning"`
Relation string `json:"relation"`
CreatedAt time.Time `json:"created_at"`
}
type LinkedLearningThought struct {
Thought Thought `json:"thought"`
Relation string `json:"relation"`
CreatedAt time.Time `json:"created_at"`
}
+26
View File
@@ -0,0 +1,26 @@
-- Many-to-many join table between thoughts and learnings.
-- Allows a learning to reference multiple source thoughts and a thought to
-- expose multiple related learnings, queryable in both directions.
CREATE SEQUENCE IF NOT EXISTS public.identity_thought_learning_links_id
INCREMENT 1
MINVALUE 1
MAXVALUE 9223372036854775807
START 1
CACHE 1;
CREATE TABLE IF NOT EXISTS public.thought_learning_links (
id bigint NOT NULL DEFAULT nextval('public.identity_thought_learning_links_id'),
thought_id bigint NOT NULL REFERENCES public.thoughts(id) ON DELETE CASCADE,
learning_id bigint NOT NULL REFERENCES public.learnings(id) ON DELETE CASCADE,
relation text NOT NULL DEFAULT 'source',
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT thought_learning_links_pkey PRIMARY KEY (id),
CONSTRAINT thought_learning_links_unique UNIQUE (thought_id, learning_id)
);
CREATE INDEX IF NOT EXISTS idx_thought_learning_links_thought_id
ON public.thought_learning_links (thought_id);
CREATE INDEX IF NOT EXISTS idx_thought_learning_links_learning_id
ON public.thought_learning_links (learning_id);
+14
View File
@@ -32,6 +32,20 @@ Table thought_links {
}
}
Table thought_learning_links {
id bigserial [pk]
thought_id bigint [not null, ref: > thoughts.id]
learning_id bigint [not null, ref: > learnings.id]
relation text [not null, default: `'source'`]
created_at timestamptz [not null, default: `now()`]
indexes {
(thought_id, learning_id) [unique]
thought_id
learning_id
}
}
Table embeddings {
id bigserial [pk]
guid uuid [unique, not null, default: `gen_random_uuid()`]