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

View File

@@ -0,0 +1,155 @@
package metadata
import (
"sort"
"strings"
"git.warky.dev/wdevs/amcs/internal/config"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
const (
DefaultType = "observation"
DefaultTopicFallback = "uncategorized"
maxTopics = 10
)
var allowedTypes = map[string]struct{}{
"observation": {},
"task": {},
"idea": {},
"reference": {},
"person_note": {},
}
func Fallback(capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
topicFallback := strings.TrimSpace(capture.MetadataDefaults.TopicFallback)
if topicFallback == "" {
topicFallback = DefaultTopicFallback
}
return thoughttypes.ThoughtMetadata{
People: []string{},
ActionItems: []string{},
DatesMentioned: []string{},
Topics: []string{topicFallback},
Type: normalizeType(capture.MetadataDefaults.Type),
Source: normalizeSource(capture.Source),
}
}
func Normalize(in thoughttypes.ThoughtMetadata, capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
out := thoughttypes.ThoughtMetadata{
People: normalizeList(in.People, 0),
ActionItems: normalizeList(in.ActionItems, 0),
DatesMentioned: normalizeList(in.DatesMentioned, 0),
Topics: normalizeList(in.Topics, maxTopics),
Type: normalizeType(in.Type),
Source: normalizeSource(in.Source),
}
if len(out.Topics) == 0 {
out.Topics = Fallback(capture).Topics
}
if out.Type == "" {
out.Type = Fallback(capture).Type
}
if out.Source == "" {
out.Source = Fallback(capture).Source
}
return out
}
func normalizeList(values []string, limit int) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.Join(strings.Fields(strings.TrimSpace(value)), " ")
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result = append(result, trimmed)
if limit > 0 && len(result) >= limit {
break
}
}
return result
}
func normalizeType(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" {
return DefaultType
}
if _, ok := allowedTypes[normalized]; ok {
return normalized
}
return DefaultType
}
func normalizeSource(value string) string {
normalized := strings.TrimSpace(value)
if normalized == "" {
return config.DefaultSource
}
return normalized
}
func Merge(base, patch thoughttypes.ThoughtMetadata, capture config.CaptureConfig) thoughttypes.ThoughtMetadata {
merged := base
if len(patch.People) > 0 {
merged.People = append(append([]string{}, merged.People...), patch.People...)
}
if len(patch.ActionItems) > 0 {
merged.ActionItems = append(append([]string{}, merged.ActionItems...), patch.ActionItems...)
}
if len(patch.DatesMentioned) > 0 {
merged.DatesMentioned = append(append([]string{}, merged.DatesMentioned...), patch.DatesMentioned...)
}
if len(patch.Topics) > 0 {
merged.Topics = append(append([]string{}, merged.Topics...), patch.Topics...)
}
if strings.TrimSpace(patch.Type) != "" {
merged.Type = patch.Type
}
if strings.TrimSpace(patch.Source) != "" {
merged.Source = patch.Source
}
return Normalize(merged, capture)
}
func SortedTopCounts(in map[string]int, limit int) []thoughttypes.KeyCount {
out := make([]thoughttypes.KeyCount, 0, len(in))
for key, count := range in {
if strings.TrimSpace(key) == "" {
continue
}
out = append(out, thoughttypes.KeyCount{Key: key, Count: count})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count == out[j].Count {
return out[i].Key < out[j].Key
}
return out[i].Count > out[j].Count
})
if limit > 0 && len(out) > limit {
return out[:limit]
}
return out
}

View File

@@ -0,0 +1,81 @@
package metadata
import (
"strings"
"testing"
"git.warky.dev/wdevs/amcs/internal/config"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
func testCaptureConfig() config.CaptureConfig {
return config.CaptureConfig{
Source: "mcp",
MetadataDefaults: config.CaptureMetadataDefault{
Type: "observation",
TopicFallback: "uncategorized",
},
}
}
func TestFallbackUsesConfiguredDefaults(t *testing.T) {
got := Fallback(testCaptureConfig())
if got.Type != "observation" {
t.Fatalf("Fallback type = %q, want observation", got.Type)
}
if len(got.Topics) != 1 || got.Topics[0] != "uncategorized" {
t.Fatalf("Fallback topics = %#v, want [uncategorized]", got.Topics)
}
if got.Source != "mcp" {
t.Fatalf("Fallback source = %q, want mcp", got.Source)
}
}
func TestNormalizeTrimsDedupesAndCapsTopics(t *testing.T) {
topics := []string{}
for i := 0; i < 12; i++ {
topics = append(topics, strings.TrimSpace(" topic "))
topics = append(topics, string(rune('a'+i)))
}
got := Normalize(thoughttypes.ThoughtMetadata{
People: []string{" Alice ", "alice", "", "Bob"},
Topics: topics,
Type: "INVALID",
}, testCaptureConfig())
if len(got.People) != 2 {
t.Fatalf("People len = %d, want 2", len(got.People))
}
if got.Type != "observation" {
t.Fatalf("Type = %q, want observation", got.Type)
}
if len(got.Topics) != maxTopics {
t.Fatalf("Topics len = %d, want %d", len(got.Topics), maxTopics)
}
}
func TestMergeAddsPatchAndNormalizes(t *testing.T) {
base := thoughttypes.ThoughtMetadata{
People: []string{"Alice"},
Topics: []string{"go"},
Type: "idea",
Source: "mcp",
}
patch := thoughttypes.ThoughtMetadata{
People: []string{" Bob ", "alice"},
Topics: []string{"testing"},
Type: "task",
}
got := Merge(base, patch, testCaptureConfig())
if got.Type != "task" {
t.Fatalf("Type = %q, want task", got.Type)
}
if len(got.People) != 2 {
t.Fatalf("People len = %d, want 2", len(got.People))
}
if len(got.Topics) != 2 {
t.Fatalf("Topics len = %d, want 2", len(got.Topics))
}
}