feat(tools): implement CRUD operations for thoughts and projects

* Add tools for creating, retrieving, updating, and deleting thoughts.
* Implement project management tools for creating and listing projects.
* Introduce linking functionality between thoughts.
* Add search and recall capabilities for thoughts based on semantic queries.
* Implement statistics and summarization tools for thought analysis.
* Create database migrations for thoughts, projects, and links.
* Add helper functions for UUID parsing and project resolution.
This commit is contained in:
Hein
2026-03-24 15:38:59 +02:00
parent 64024193e9
commit 66370a7f0e
68 changed files with 4422 additions and 0 deletions

36
internal/tools/archive.go Normal file
View File

@@ -0,0 +1,36 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
)
type ArchiveTool struct {
store *store.DB
}
type ArchiveInput struct {
ID string `json:"id" jsonschema:"the thought id"`
}
type ArchiveOutput struct {
Archived bool `json:"archived"`
}
func NewArchiveTool(db *store.DB) *ArchiveTool {
return &ArchiveTool{store: db}
}
func (t *ArchiveTool) Handle(ctx context.Context, _ *mcp.CallToolRequest, in ArchiveInput) (*mcp.CallToolResult, ArchiveOutput, error) {
id, err := parseUUID(in.ID)
if err != nil {
return nil, ArchiveOutput{}, err
}
if err := t.store.ArchiveThought(ctx, id); err != nil {
return nil, ArchiveOutput{}, err
}
return nil, ArchiveOutput{Archived: true}, nil
}

95
internal/tools/capture.go Normal file
View File

@@ -0,0 +1,95 @@
package tools
import (
"context"
"log/slog"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"golang.org/x/sync/errgroup"
"git.warky.dev/wdevs/amcs/internal/ai"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/metadata"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type CaptureTool struct {
store *store.DB
provider ai.Provider
capture config.CaptureConfig
sessions *session.ActiveProjects
log *slog.Logger
}
type CaptureInput struct {
Content string `json:"content" jsonschema:"the thought or note to capture"`
Project string `json:"project,omitempty" jsonschema:"optional project name or id to associate with the thought"`
}
type CaptureOutput struct {
Thought thoughttypes.Thought `json:"thought"`
}
func NewCaptureTool(db *store.DB, provider ai.Provider, capture config.CaptureConfig, sessions *session.ActiveProjects, log *slog.Logger) *CaptureTool {
return &CaptureTool{store: db, provider: provider, capture: capture, sessions: sessions, log: log}
}
func (t *CaptureTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in CaptureInput) (*mcp.CallToolResult, CaptureOutput, error) {
content := strings.TrimSpace(in.Content)
if content == "" {
return nil, CaptureOutput{}, errInvalidInput("content is required")
}
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, CaptureOutput{}, err
}
var embedding []float32
rawMetadata := metadata.Fallback(t.capture)
group, groupCtx := errgroup.WithContext(ctx)
group.Go(func() error {
vector, err := t.provider.Embed(groupCtx, content)
if err != nil {
return err
}
embedding = vector
return nil
})
group.Go(func() error {
extracted, err := t.provider.ExtractMetadata(groupCtx, content)
if err != nil {
t.log.Warn("metadata extraction failed, using fallback", slog.String("provider", t.provider.Name()), slog.String("error", err.Error()))
return nil
}
rawMetadata = extracted
return nil
})
if err := group.Wait(); err != nil {
return nil, CaptureOutput{}, err
}
thought := thoughttypes.Thought{
Content: content,
Embedding: embedding,
Metadata: metadata.Normalize(rawMetadata, t.capture),
}
if project != nil {
thought.ProjectID = &project.ID
}
created, err := t.store.InsertThought(ctx, thought)
if err != nil {
return nil, CaptureOutput{}, err
}
if project != nil {
_ = t.store.TouchProject(ctx, project.ID)
}
return nil, CaptureOutput{Thought: created}, nil
}

31
internal/tools/common.go Normal file
View File

@@ -0,0 +1,31 @@
package tools
import (
"fmt"
"git.warky.dev/wdevs/amcs/internal/config"
)
func normalizeLimit(limit int, cfg config.SearchConfig) int {
if limit <= 0 {
return cfg.DefaultLimit
}
if limit > cfg.MaxLimit {
return cfg.MaxLimit
}
return limit
}
func normalizeThreshold(value float64, fallback float64) float64 {
if value <= 0 {
return fallback
}
if value > 1 {
return 1
}
return value
}
func errInvalidInput(message string) error {
return fmt.Errorf("invalid input: %s", message)
}

111
internal/tools/context.go Normal file
View File

@@ -0,0 +1,111 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/ai"
"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 ContextTool struct {
store *store.DB
provider ai.Provider
search config.SearchConfig
sessions *session.ActiveProjects
}
type ProjectContextInput struct {
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to the active session project"`
Query string `json:"query,omitempty" jsonschema:"optional semantic focus for project context"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of context items to return"`
}
type ContextItem struct {
ID string `json:"id"`
Content string `json:"content"`
Metadata thoughttypes.ThoughtMetadata `json:"metadata"`
Similarity float64 `json:"similarity,omitempty"`
Source string `json:"source"`
}
type ProjectContextOutput struct {
Project thoughttypes.Project `json:"project"`
Context string `json:"context"`
Items []ContextItem `json:"items"`
}
func NewContextTool(db *store.DB, provider ai.Provider, search config.SearchConfig, sessions *session.ActiveProjects) *ContextTool {
return &ContextTool{store: db, provider: provider, search: search, sessions: sessions}
}
func (t *ContextTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in ProjectContextInput) (*mcp.CallToolResult, ProjectContextOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, ProjectContextOutput{}, err
}
limit := normalizeLimit(in.Limit, t.search)
recent, err := t.store.RecentThoughts(ctx, &project.ID, limit, 0)
if err != nil {
return nil, ProjectContextOutput{}, err
}
items := make([]ContextItem, 0, limit*2)
seen := map[string]struct{}{}
for _, thought := range recent {
key := thought.ID.String()
seen[key] = struct{}{}
items = append(items, ContextItem{
ID: key,
Content: thought.Content,
Metadata: thought.Metadata,
Source: "recent",
})
}
query := strings.TrimSpace(in.Query)
if query != "" {
embedding, err := t.provider.Embed(ctx, query)
if err != nil {
return nil, ProjectContextOutput{}, err
}
semantic, err := t.store.SearchSimilarThoughts(ctx, embedding, t.search.DefaultThreshold, limit, &project.ID, nil)
if err != nil {
return nil, ProjectContextOutput{}, err
}
for _, result := range semantic {
key := result.ID.String()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
items = append(items, ContextItem{
ID: key,
Content: result.Content,
Metadata: result.Metadata,
Similarity: result.Similarity,
Source: "semantic",
})
}
}
lines := make([]string, 0, len(items))
for i, item := range items {
lines = append(lines, thoughtContextLine(i, item.Content, item.Metadata, item.Similarity))
}
contextBlock := formatContextBlock(fmt.Sprintf("Project context for %s", project.Name), lines)
_ = t.store.TouchProject(ctx, project.ID)
return nil, ProjectContextOutput{
Project: *project,
Context: contextBlock,
Items: items,
}, nil
}

36
internal/tools/delete.go Normal file
View File

@@ -0,0 +1,36 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
)
type DeleteTool struct {
store *store.DB
}
type DeleteInput struct {
ID string `json:"id" jsonschema:"the thought id"`
}
type DeleteOutput struct {
Deleted bool `json:"deleted"`
}
func NewDeleteTool(db *store.DB) *DeleteTool {
return &DeleteTool{store: db}
}
func (t *DeleteTool) Handle(ctx context.Context, _ *mcp.CallToolRequest, in DeleteInput) (*mcp.CallToolResult, DeleteOutput, error) {
id, err := parseUUID(in.ID)
if err != nil {
return nil, DeleteOutput{}, err
}
if err := t.store.DeleteThought(ctx, id); err != nil {
return nil, DeleteOutput{}, err
}
return nil, DeleteOutput{Deleted: true}, nil
}

40
internal/tools/get.go Normal file
View File

@@ -0,0 +1,40 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type GetTool struct {
store *store.DB
}
type GetInput struct {
ID string `json:"id" jsonschema:"the thought id"`
}
type GetOutput struct {
Thought thoughttypes.Thought `json:"thought"`
}
func NewGetTool(db *store.DB) *GetTool {
return &GetTool{store: db}
}
func (t *GetTool) Handle(ctx context.Context, _ *mcp.CallToolRequest, in GetInput) (*mcp.CallToolResult, GetOutput, error) {
id, err := parseUUID(in.ID)
if err != nil {
return nil, GetOutput{}, err
}
thought, err := t.store.GetThought(ctx, id)
if err != nil {
return nil, GetOutput{}, err
}
return nil, GetOutput{Thought: thought}, nil
}

87
internal/tools/helpers.go Normal file
View File

@@ -0,0 +1,87 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
func parseUUID(id string) (uuid.UUID, error) {
parsed, err := uuid.Parse(strings.TrimSpace(id))
if err != nil {
return uuid.Nil, fmt.Errorf("invalid id %q: %w", id, err)
}
return parsed, nil
}
func sessionID(req *mcp.CallToolRequest) (string, error) {
if req == nil || req.Session == nil || req.Session.ID() == "" {
return "", fmt.Errorf("tool requires an MCP session")
}
return req.Session.ID(), nil
}
func resolveProject(ctx context.Context, db *store.DB, sessions *session.ActiveProjects, req *mcp.CallToolRequest, raw string, required bool) (*thoughttypes.Project, error) {
projectRef := strings.TrimSpace(raw)
if projectRef == "" && sessions != nil && req != nil && req.Session != nil {
if activeID, ok := sessions.Get(req.Session.ID()); ok {
project, err := db.GetProject(ctx, activeID.String())
if err == nil {
return &project, nil
}
if err != pgx.ErrNoRows {
return nil, err
}
}
}
if projectRef == "" {
if required {
return nil, fmt.Errorf("project is required")
}
return nil, nil
}
project, err := db.GetProject(ctx, projectRef)
if err != nil {
if err == pgx.ErrNoRows {
return nil, fmt.Errorf("project %q not found", projectRef)
}
return nil, err
}
return &project, nil
}
func formatContextBlock(header string, lines []string) string {
if len(lines) == 0 {
return header + "\n\nNo matching thoughts."
}
return header + "\n\n" + strings.Join(lines, "\n\n")
}
func thoughtContextLine(index int, content string, metadata thoughttypes.ThoughtMetadata, similarity float64) string {
label := fmt.Sprintf("%d. %s", index+1, strings.TrimSpace(content))
parts := make([]string, 0, 3)
if len(metadata.Topics) > 0 {
parts = append(parts, "topics="+strings.Join(metadata.Topics, ", "))
}
if metadata.Type != "" {
parts = append(parts, "type="+metadata.Type)
}
if similarity > 0 {
parts = append(parts, fmt.Sprintf("similarity=%.3f", similarity))
}
if len(parts) == 0 {
return label
}
return label + "\n[" + strings.Join(parts, " | ") + "]"
}

145
internal/tools/links.go Normal file
View File

@@ -0,0 +1,145 @@
package tools
import (
"context"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/ai"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type LinksTool struct {
store *store.DB
provider ai.Provider
search config.SearchConfig
}
type LinkInput struct {
FromID string `json:"from_id" jsonschema:"the source thought id"`
ToID string `json:"to_id" jsonschema:"the target thought id"`
Relation string `json:"relation" jsonschema:"relationship label such as follows_up or references"`
}
type LinkOutput struct {
Linked bool `json:"linked"`
}
type RelatedInput struct {
ID string `json:"id" jsonschema:"the thought id"`
IncludeSemantic *bool `json:"include_semantic,omitempty" jsonschema:"whether to include semantic neighbors; defaults to true"`
}
type RelatedThought struct {
ID string `json:"id"`
Content string `json:"content"`
Metadata thoughttypes.ThoughtMetadata `json:"metadata"`
Relation string `json:"relation,omitempty"`
Direction string `json:"direction,omitempty"`
Similarity float64 `json:"similarity,omitempty"`
Source string `json:"source"`
}
type RelatedOutput struct {
Related []RelatedThought `json:"related"`
}
func NewLinksTool(db *store.DB, provider ai.Provider, search config.SearchConfig) *LinksTool {
return &LinksTool{store: db, provider: provider, search: search}
}
func (t *LinksTool) Link(ctx context.Context, _ *mcp.CallToolRequest, in LinkInput) (*mcp.CallToolResult, LinkOutput, error) {
fromID, err := parseUUID(in.FromID)
if err != nil {
return nil, LinkOutput{}, err
}
toID, err := parseUUID(in.ToID)
if err != nil {
return nil, LinkOutput{}, err
}
relation := strings.TrimSpace(in.Relation)
if relation == "" {
return nil, LinkOutput{}, errInvalidInput("relation is required")
}
if _, err := t.store.GetThought(ctx, fromID); err != nil {
return nil, LinkOutput{}, err
}
if _, err := t.store.GetThought(ctx, toID); err != nil {
return nil, LinkOutput{}, err
}
if err := t.store.InsertLink(ctx, thoughttypes.ThoughtLink{
FromID: fromID,
ToID: toID,
Relation: relation,
}); err != nil {
return nil, LinkOutput{}, err
}
return nil, LinkOutput{Linked: true}, nil
}
func (t *LinksTool) Related(ctx context.Context, _ *mcp.CallToolRequest, in RelatedInput) (*mcp.CallToolResult, RelatedOutput, error) {
id, err := parseUUID(in.ID)
if err != nil {
return nil, RelatedOutput{}, err
}
thought, err := t.store.GetThought(ctx, id)
if err != nil {
return nil, RelatedOutput{}, err
}
linked, err := t.store.LinkedThoughts(ctx, id)
if err != nil {
return nil, RelatedOutput{}, err
}
related := make([]RelatedThought, 0, len(linked)+t.search.DefaultLimit)
seen := map[string]struct{}{thought.ID.String(): {}}
for _, item := range linked {
key := item.Thought.ID.String()
seen[key] = struct{}{}
related = append(related, RelatedThought{
ID: key,
Content: item.Thought.Content,
Metadata: item.Thought.Metadata,
Relation: item.Relation,
Direction: item.Direction,
Source: "link",
})
}
includeSemantic := true
if in.IncludeSemantic != nil {
includeSemantic = *in.IncludeSemantic
}
if includeSemantic {
embedding, err := t.provider.Embed(ctx, thought.Content)
if err != nil {
return nil, RelatedOutput{}, err
}
semantic, err := t.store.SearchSimilarThoughts(ctx, embedding, t.search.DefaultThreshold, t.search.DefaultLimit, thought.ProjectID, &thought.ID)
if err != nil {
return nil, RelatedOutput{}, err
}
for _, item := range semantic {
key := item.ID.String()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
related = append(related, RelatedThought{
ID: key,
Content: item.Content,
Metadata: item.Metadata,
Similarity: item.Similarity,
Source: "semantic",
})
}
}
return nil, RelatedOutput{Related: related}, nil
}

68
internal/tools/list.go Normal file
View File

@@ -0,0 +1,68 @@
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 ListTool struct {
store *store.DB
search config.SearchConfig
sessions *session.ActiveProjects
}
type ListInput struct {
Limit int `json:"limit,omitempty" jsonschema:"maximum number of thoughts to return"`
Type string `json:"type,omitempty" jsonschema:"filter by thought type"`
Topic string `json:"topic,omitempty" jsonschema:"filter by topic"`
Person string `json:"person,omitempty" jsonschema:"filter by mentioned person"`
Days int `json:"days,omitempty" jsonschema:"only include thoughts from the last N days"`
IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"include archived thoughts when true"`
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope the listing"`
}
type ListOutput struct {
Thoughts []thoughttypes.Thought `json:"thoughts"`
}
func NewListTool(db *store.DB, search config.SearchConfig, sessions *session.ActiveProjects) *ListTool {
return &ListTool{store: db, search: search, sessions: sessions}
}
func (t *ListTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in ListInput) (*mcp.CallToolResult, ListOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, ListOutput{}, err
}
var projectID *uuid.UUID
if project != nil {
projectID = &project.ID
}
thoughts, err := t.store.ListThoughts(ctx, thoughttypes.ListFilter{
Limit: normalizeLimit(in.Limit, t.search),
Type: strings.TrimSpace(in.Type),
Topic: strings.TrimSpace(in.Topic),
Person: strings.TrimSpace(in.Person),
Days: in.Days,
IncludeArchived: in.IncludeArchived,
ProjectID: projectID,
})
if err != nil {
return nil, ListOutput{}, err
}
if project != nil {
_ = t.store.TouchProject(ctx, project.ID)
}
return nil, ListOutput{Thoughts: thoughts}, nil
}

View File

@@ -0,0 +1,92 @@
package tools
import (
"context"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type ProjectsTool struct {
store *store.DB
sessions *session.ActiveProjects
}
type CreateProjectInput struct {
Name string `json:"name" jsonschema:"the unique project name"`
Description string `json:"description,omitempty" jsonschema:"optional project description"`
}
type CreateProjectOutput struct {
Project thoughttypes.Project `json:"project"`
}
type ListProjectsInput struct{}
type ListProjectsOutput struct {
Projects []thoughttypes.ProjectSummary `json:"projects"`
}
type SetActiveProjectInput struct {
Project string `json:"project" jsonschema:"project name or id"`
}
type SetActiveProjectOutput struct {
Project thoughttypes.Project `json:"project"`
}
type GetActiveProjectInput struct{}
type GetActiveProjectOutput struct {
Project *thoughttypes.Project `json:"project,omitempty"`
}
func NewProjectsTool(db *store.DB, sessions *session.ActiveProjects) *ProjectsTool {
return &ProjectsTool{store: db, sessions: sessions}
}
func (t *ProjectsTool) Create(ctx context.Context, _ *mcp.CallToolRequest, in CreateProjectInput) (*mcp.CallToolResult, CreateProjectOutput, error) {
name := strings.TrimSpace(in.Name)
if name == "" {
return nil, CreateProjectOutput{}, errInvalidInput("name is required")
}
project, err := t.store.CreateProject(ctx, name, strings.TrimSpace(in.Description))
if err != nil {
return nil, CreateProjectOutput{}, err
}
return nil, CreateProjectOutput{Project: project}, nil
}
func (t *ProjectsTool) List(ctx context.Context, _ *mcp.CallToolRequest, _ ListProjectsInput) (*mcp.CallToolResult, ListProjectsOutput, error) {
projects, err := t.store.ListProjects(ctx)
if err != nil {
return nil, ListProjectsOutput{}, err
}
return nil, ListProjectsOutput{Projects: projects}, nil
}
func (t *ProjectsTool) SetActive(ctx context.Context, req *mcp.CallToolRequest, in SetActiveProjectInput) (*mcp.CallToolResult, SetActiveProjectOutput, error) {
sid, err := sessionID(req)
if err != nil {
return nil, SetActiveProjectOutput{}, err
}
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, SetActiveProjectOutput{}, err
}
t.sessions.Set(sid, project.ID)
_ = t.store.TouchProject(ctx, project.ID)
return nil, SetActiveProjectOutput{Project: *project}, nil
}
func (t *ProjectsTool) GetActive(ctx context.Context, req *mcp.CallToolRequest, _ GetActiveProjectInput) (*mcp.CallToolResult, GetActiveProjectOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, "", false)
if err != nil {
return nil, GetActiveProjectOutput{}, err
}
return nil, GetActiveProjectOutput{Project: project}, nil
}

111
internal/tools/recall.go Normal file
View File

@@ -0,0 +1,111 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/ai"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
)
type RecallTool struct {
store *store.DB
provider ai.Provider
search config.SearchConfig
sessions *session.ActiveProjects
}
type RecallInput struct {
Query string `json:"query" jsonschema:"semantic query for recalled context"`
Project string `json:"project,omitempty" jsonschema:"optional project name or id; falls back to the active session project"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of context items to return"`
}
type RecallOutput struct {
Context string `json:"context"`
Items []ContextItem `json:"items"`
}
func NewRecallTool(db *store.DB, provider ai.Provider, search config.SearchConfig, sessions *session.ActiveProjects) *RecallTool {
return &RecallTool{store: db, provider: provider, search: search, sessions: sessions}
}
func (t *RecallTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in RecallInput) (*mcp.CallToolResult, RecallOutput, error) {
query := strings.TrimSpace(in.Query)
if query == "" {
return nil, RecallOutput{}, errInvalidInput("query is required")
}
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, RecallOutput{}, err
}
limit := normalizeLimit(in.Limit, t.search)
embedding, err := t.provider.Embed(ctx, query)
if err != nil {
return nil, RecallOutput{}, err
}
var projectID *uuid.UUID
if project != nil {
projectID = &project.ID
}
semantic, err := t.store.SearchSimilarThoughts(ctx, embedding, t.search.DefaultThreshold, limit, projectID, nil)
if err != nil {
return nil, RecallOutput{}, err
}
recent, err := t.store.RecentThoughts(ctx, projectID, limit, 0)
if err != nil {
return nil, RecallOutput{}, err
}
items := make([]ContextItem, 0, limit*2)
seen := map[string]struct{}{}
for _, result := range semantic {
key := result.ID.String()
seen[key] = struct{}{}
items = append(items, ContextItem{
ID: key,
Content: result.Content,
Metadata: result.Metadata,
Similarity: result.Similarity,
Source: "semantic",
})
}
for _, thought := range recent {
key := thought.ID.String()
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
items = append(items, ContextItem{
ID: key,
Content: thought.Content,
Metadata: thought.Metadata,
Source: "recent",
})
}
lines := make([]string, 0, len(items))
for i, item := range items {
lines = append(lines, thoughtContextLine(i, item.Content, item.Metadata, item.Similarity))
}
header := "Recalled context"
if project != nil {
header = fmt.Sprintf("Recalled context for %s", project.Name)
_ = t.store.TouchProject(ctx, project.ID)
}
return nil, RecallOutput{
Context: formatContextBlock(header, lines),
Items: items,
}, nil
}

69
internal/tools/search.go Normal file
View File

@@ -0,0 +1,69 @@
package tools
import (
"context"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/ai"
"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 SearchTool struct {
store *store.DB
provider ai.Provider
search config.SearchConfig
sessions *session.ActiveProjects
}
type SearchInput struct {
Query string `json:"query" jsonschema:"the semantic query to search for"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of results to return"`
Threshold float64 `json:"threshold,omitempty" jsonschema:"minimum similarity threshold between 0 and 1"`
Project string `json:"project,omitempty" jsonschema:"optional project name or id to scope the search"`
}
type SearchOutput struct {
Results []thoughttypes.SearchResult `json:"results"`
}
func NewSearchTool(db *store.DB, provider ai.Provider, search config.SearchConfig, sessions *session.ActiveProjects) *SearchTool {
return &SearchTool{store: db, provider: provider, search: search, sessions: sessions}
}
func (t *SearchTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in SearchInput) (*mcp.CallToolResult, SearchOutput, error) {
query := strings.TrimSpace(in.Query)
if query == "" {
return nil, SearchOutput{}, errInvalidInput("query is required")
}
limit := normalizeLimit(in.Limit, t.search)
threshold := normalizeThreshold(in.Threshold, t.search.DefaultThreshold)
embedding, err := t.provider.Embed(ctx, query)
if err != nil {
return nil, SearchOutput{}, err
}
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, SearchOutput{}, err
}
var results []thoughttypes.SearchResult
if project != nil {
results, err = t.store.SearchSimilarThoughts(ctx, embedding, threshold, limit, &project.ID, nil)
_ = t.store.TouchProject(ctx, project.ID)
} else {
results, err = t.store.SearchThoughts(ctx, embedding, threshold, limit, map[string]any{})
}
if err != nil {
return nil, SearchOutput{}, err
}
return nil, SearchOutput{Results: results}, nil
}

33
internal/tools/stats.go Normal file
View File

@@ -0,0 +1,33 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type StatsTool struct {
store *store.DB
}
type StatsInput struct{}
type StatsOutput struct {
Stats thoughttypes.ThoughtStats `json:"stats"`
}
func NewStatsTool(db *store.DB) *StatsTool {
return &StatsTool{store: db}
}
func (t *StatsTool) Handle(ctx context.Context, _ *mcp.CallToolRequest, _ StatsInput) (*mcp.CallToolResult, StatsOutput, error) {
stats, err := t.store.Stats(ctx)
if err != nil {
return nil, StatsOutput{}, err
}
return nil, StatsOutput{Stats: stats}, nil
}

View File

@@ -0,0 +1,93 @@
package tools
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/ai"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
)
type SummarizeTool struct {
store *store.DB
provider ai.Provider
search config.SearchConfig
sessions *session.ActiveProjects
}
type SummarizeInput struct {
Query string `json:"query,omitempty" jsonschema:"optional semantic focus for the summary"`
Project string `json:"project,omitempty" jsonschema:"optional project name or id; falls back to the active session project"`
Days int `json:"days,omitempty" jsonschema:"only include thoughts from the last N days when query is omitted"`
Limit int `json:"limit,omitempty" jsonschema:"maximum number of thoughts to summarize"`
}
type SummarizeOutput struct {
Summary string `json:"summary"`
Count int `json:"count"`
}
func NewSummarizeTool(db *store.DB, provider ai.Provider, search config.SearchConfig, sessions *session.ActiveProjects) *SummarizeTool {
return &SummarizeTool{store: db, provider: provider, search: search, sessions: sessions}
}
func (t *SummarizeTool) Handle(ctx context.Context, req *mcp.CallToolRequest, in SummarizeInput) (*mcp.CallToolResult, SummarizeOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, SummarizeOutput{}, err
}
limit := normalizeLimit(in.Limit, t.search)
query := strings.TrimSpace(in.Query)
lines := make([]string, 0, limit)
count := 0
if query != "" {
embedding, err := t.provider.Embed(ctx, query)
if err != nil {
return nil, SummarizeOutput{}, err
}
var projectID *uuid.UUID
if project != nil {
projectID = &project.ID
}
results, err := t.store.SearchSimilarThoughts(ctx, embedding, t.search.DefaultThreshold, limit, projectID, nil)
if err != nil {
return nil, SummarizeOutput{}, err
}
for i, result := range results {
lines = append(lines, thoughtContextLine(i, result.Content, result.Metadata, result.Similarity))
}
count = len(results)
} else {
var projectID *uuid.UUID
if project != nil {
projectID = &project.ID
}
thoughts, err := t.store.RecentThoughts(ctx, projectID, limit, in.Days)
if err != nil {
return nil, SummarizeOutput{}, err
}
for i, thought := range thoughts {
lines = append(lines, thoughtContextLine(i, thought.Content, thought.Metadata, 0))
}
count = len(thoughts)
}
userPrompt := formatContextBlock("Summarize the following thoughts into concise prose with themes, action items, and notable people.", lines)
systemPrompt := "You summarize note collections. Be concise, concrete, and structured in plain prose."
summary, err := t.provider.Summarize(ctx, systemPrompt, userPrompt)
if err != nil {
return nil, SummarizeOutput{}, err
}
if project != nil {
_ = t.store.TouchProject(ctx, project.ID)
}
return nil, SummarizeOutput{Summary: summary, Count: count}, nil
}

88
internal/tools/update.go Normal file
View File

@@ -0,0 +1,88 @@
package tools
import (
"context"
"log/slog"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/ai"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/metadata"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type UpdateTool struct {
store *store.DB
provider ai.Provider
capture config.CaptureConfig
log *slog.Logger
}
type UpdateInput struct {
ID string `json:"id" jsonschema:"the thought id"`
Content *string `json:"content,omitempty" jsonschema:"replacement content for the thought"`
Metadata thoughttypes.ThoughtMetadata `json:"metadata,omitempty" jsonschema:"metadata fields to merge into the thought"`
Project string `json:"project,omitempty" jsonschema:"optional project name or id to move the thought into"`
}
type UpdateOutput struct {
Thought thoughttypes.Thought `json:"thought"`
}
func NewUpdateTool(db *store.DB, provider ai.Provider, capture config.CaptureConfig, log *slog.Logger) *UpdateTool {
return &UpdateTool{store: db, provider: provider, capture: capture, log: log}
}
func (t *UpdateTool) Handle(ctx context.Context, _ *mcp.CallToolRequest, in UpdateInput) (*mcp.CallToolResult, UpdateOutput, error) {
id, err := parseUUID(in.ID)
if err != nil {
return nil, UpdateOutput{}, err
}
current, err := t.store.GetThought(ctx, id)
if err != nil {
return nil, UpdateOutput{}, err
}
content := current.Content
embedding := current.Embedding
mergedMetadata := current.Metadata
projectID := current.ProjectID
if in.Content != nil {
content = strings.TrimSpace(*in.Content)
if content == "" {
return nil, UpdateOutput{}, errInvalidInput("content must not be empty")
}
embedding, err = t.provider.Embed(ctx, content)
if err != nil {
return nil, UpdateOutput{}, err
}
extracted, extractErr := t.provider.ExtractMetadata(ctx, content)
if extractErr != nil {
t.log.Warn("metadata extraction failed during update, keeping current metadata", slog.String("error", extractErr.Error()))
} else {
mergedMetadata = metadata.Normalize(extracted, t.capture)
}
}
mergedMetadata = metadata.Merge(mergedMetadata, in.Metadata, t.capture)
if rawProject := strings.TrimSpace(in.Project); rawProject != "" {
project, err := t.store.GetProject(ctx, rawProject)
if err != nil {
return nil, UpdateOutput{}, err
}
projectID = &project.ID
}
updated, err := t.store.UpdateThought(ctx, id, content, embedding, mergedMetadata, projectID)
if err != nil {
return nil, UpdateOutput{}, err
}
return nil, UpdateOutput{Thought: updated}, nil
}