Files
jarvis-home/cmd/homehub/tools.go
T
2026-08-29 17:02:35 +02:00

1265 lines
45 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
"homehub/internal/ollama"
"homehub/internal/store"
)
type toolError struct {
Code string `json:"code"`
Message string `json:"message"`
Candidates any `json:"candidates,omitempty"`
}
type toolEntity struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
type toolResult struct {
Success bool `json:"success"`
Tool string `json:"tool"`
RequestID string `json:"request_id"`
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error *toolError `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Mutated bool `json:"mutated,omitempty"`
Entity *toolEntity `json:"entity,omitempty"`
}
type temporalExpr struct {
Kind string `json:"kind"`
Date string `json:"date,omitempty"`
Time string `json:"time,omitempty"`
OffsetDays int `json:"offset_days,omitempty"`
Weekday string `json:"weekday,omitempty"`
AllDay bool `json:"all_day,omitempty"`
ForceNext bool `json:"force_next,omitempty"`
}
type toolRegistry struct {
app *app
defs []ollama.Tool
known map[string]bool
}
func newToolRegistry(a *app) *toolRegistry {
r := &toolRegistry{app: a, known: map[string]bool{}}
r.defs = []ollama.Tool{
toolDef("system_get_time", "Liefert die aktuelle lokale Serverzeit und Zeitzone. Nutze dieses Tool, wenn du Zeitbezüge prüfen musst.", obj(nil, nil)),
toolDef("calendar_list", "Liest Kalendertermine. Nutze es vor update/delete, wenn die konkrete ID nicht sicher bekannt ist.", obj(map[string]any{
"query": str("Optionaler Titel-/Suchtext"), "when": temporalSchema(false), "limit": integer("Maximale Treffer, Standard 20", 1, 50),
}, nil)),
toolDef("calendar_create", "Erstellt genau einen Kalendertermin. Relative Tage/Wochentage NICHT selbst ausrechnen, sondern symbolisch in when übergeben.", obj(map[string]any{
"title": str("Kurzer Termintitel"), "when": temporalSchema(true), "duration_minutes": integer("Dauer in Minuten; bei ganztägig weglassen", 1, 1440), "location": str("Ort"), "notes": str("Notizen"),
}, []string{"title", "when"})),
toolDef("calendar_update", "Ändert einen vorhandenen Kalendertermin anhand seiner ID.", obj(map[string]any{
"id": str("Event-ID aus calendar_list/create"), "title": str("Neuer Titel"), "when": temporalSchema(false), "duration_minutes": integer("Neue Dauer in Minuten", 1, 1440), "location": str("Neuer Ort"), "notes": str("Neue Notizen"),
}, []string{"id"})),
toolDef("calendar_delete", "Löscht exakt einen Kalendertermin anhand seiner ID. Bei unklarem Ziel zuerst calendar_list verwenden.", obj(map[string]any{"id": str("Event-ID")}, []string{"id"})),
toolDef("tasks_list", "Liest Aufgaben.", obj(map[string]any{"status": enum("Filter", "open", "done", "all"), "query": str("Optionaler Suchtext"), "limit": integer("Maximale Treffer", 1, 50)}, nil)),
toolDef("tasks_create", "Erstellt eine Aufgabe, keinen Kalendertermin.", obj(map[string]any{
"title": str("Aufgabentitel"), "notes": str("Notizen"), "due": temporalSchema(false), "priority": enum("Priorität", "low", "normal", "high"),
}, []string{"title"})),
toolDef("tasks_update", "Ändert oder erledigt eine vorhandene Aufgabe anhand ihrer ID.", obj(map[string]any{
"id": str("Task-ID"), "title": str("Neuer Titel"), "notes": str("Neue Notizen"), "due": temporalSchema(false), "priority": enum("Priorität", "low", "normal", "high"), "status": enum("Status", "open", "done"),
}, []string{"id"})),
toolDef("tasks_delete", "Löscht exakt eine Aufgabe anhand ihrer ID.", obj(map[string]any{"id": str("Task-ID")}, []string{"id"})),
toolDef("reminders_list", "Liest offene oder erledigte Erinnerungen.", obj(map[string]any{"done": boolean("Optional: true nur erledigte, false nur offene"), "limit": integer("Maximale Treffer", 1, 50)}, nil)),
toolDef("reminders_create", "Erstellt eine explizite Erinnerung.", obj(map[string]any{"title": str("Titel"), "when": temporalSchema(true), "notes": str("Notizen")}, []string{"title", "when"})),
toolDef("reminders_delete", "Löscht eine Erinnerung anhand ihrer ID.", obj(map[string]any{"id": str("Reminder-ID")}, []string{"id"})),
toolDef("groceries_list", "Liest die Einkaufsliste.", obj(map[string]any{"checked": boolean("Optionaler Filter für erledigt/nicht erledigt")}, nil)),
toolDef("groceries_add", "Fügt einen Artikel zur Einkaufsliste hinzu.", obj(map[string]any{"name": str("Artikel"), "quantity": str("Menge als Text/Zahl"), "unit": str("Einheit"), "category": str("Kategorie")}, []string{"name"})),
toolDef("groceries_update", "Ändert oder hakt einen Einkaufsartikel ab.", obj(map[string]any{"id": str("Grocery-ID"), "name": str("Name"), "quantity": str("Menge"), "unit": str("Einheit"), "category": str("Kategorie"), "checked": boolean("Erledigt")}, []string{"id"})),
toolDef("groceries_remove", "Entfernt einen Einkaufsartikel anhand seiner ID.", obj(map[string]any{"id": str("Grocery-ID")}, []string{"id"})),
toolDef("groceries_sync_mealplan", "Erzeugt bzw. aktualisiert die automatische Einkaufsliste aus den Zutaten der im Essensplan verknüpften Rezepte. Liefert auch Mahlzeiten ohne Rezeptzuordnung zurück.", obj(map[string]any{"days": integer("Planungshorizont ab heute in Tagen, Standard 14", 1, 60)}, nil)),
toolDef("recipes_list", "Liest das Rezeptbuch. Für generierte Essenspläne zuerst verwenden, damit bevorzugt vorhandene Rezepte geplant werden.", obj(map[string]any{"limit": integer("Maximale Treffer", 1, 100)}, nil)),
toolDef("recipes_search", "Sucht vorhandene Rezepte. Nutze es vor mealplan_create, wenn der Nutzer ein bekanntes Rezept/Dish meint.", obj(map[string]any{"query": str("Rezept-/Gerichtname"), "limit": integer("Maximale Treffer", 1, 20)}, []string{"query"})),
toolDef("recipes_get", "Liest ein Rezept mit Zutaten und Schritten anhand seiner ID.", obj(map[string]any{"id": str("Recipe-ID")}, []string{"id"})),
toolDef("recipes_create", "Speichert ein neues Rezept.", obj(map[string]any{
"title": str("Rezeptname"), "description": str("Beschreibung"), "servings": integer("Portionen", 1, 100), "prep_minutes": integer("Zubereitungszeit", 0, 1440),
"ingredients": map[string]any{"type": "array", "items": obj(map[string]any{"name": str("Zutat"), "quantity": str("Menge"), "unit": str("Einheit")}, []string{"name"})},
"steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, []string{"title"})),
toolDef("recipes_update", "Ändert ein vorhandenes Rezept anhand seiner ID. Nur übergebene Felder werden geändert; Zutaten und Schritte können vollständig ersetzt werden.", obj(map[string]any{
"id": str("Recipe-ID"), "title": str("Neuer Rezeptname"), "description": str("Neue Beschreibung"), "servings": integer("Portionen", 1, 100), "prep_minutes": integer("Zubereitungszeit", 0, 1440),
"ingredients": map[string]any{"type": "array", "items": obj(map[string]any{"name": str("Zutat"), "quantity": str("Menge"), "unit": str("Einheit")}, []string{"name"})},
"steps": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
"tags": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
}, []string{"id"})),
toolDef("mealplan_list", "Liest die Essensplanung, optional für einen Tag oder Mahlzeit.", obj(map[string]any{"when": temporalSchema(false), "meal": enum("Mahlzeit", "breakfast", "lunch", "dinner", "snack"), "limit": integer("Maximale Treffer", 1, 50)}, nil)),
toolDef("mealplan_create", "Plant ein Essen. Relative Tage/Wochentage symbolisch in when übergeben. recipe_id nur verwenden, wenn es aus recipes_search/get stammt.", obj(map[string]any{
"title": str("Gerichtname"), "when": temporalSchema(true), "meal": enum("Mahlzeit", "breakfast", "lunch", "dinner", "snack"), "recipe_id": str("Optionale vorhandene Recipe-ID"), "servings": integer("Portionen", 1, 100), "notes": str("Notizen"),
}, []string{"title", "when", "meal"})),
toolDef("mealplan_update", "Verschiebt oder ändert einen vorhandenen Essensplan-Eintrag anhand seiner ID.", obj(map[string]any{
"id": str("MealPlan-ID"), "title": str("Neuer Gerichtname"), "when": temporalSchema(false), "meal": enum("Mahlzeit", "breakfast", "lunch", "dinner", "snack"), "recipe_id": str("Recipe-ID"), "servings": integer("Portionen", 1, 100), "notes": str("Notizen"),
}, []string{"id"})),
toolDef("mealplan_delete", "Löscht einen Essensplan-Eintrag anhand seiner ID.", obj(map[string]any{"id": str("MealPlan-ID")}, []string{"id"})),
toolDef("kanban_list", "Liest Kanban-Karten.", obj(map[string]any{"column": enum("Spalte", "inbox", "next", "doing", "waiting", "done", "all")}, nil)),
toolDef("kanban_create", "Erstellt eine manuelle Kanban-Karte.", obj(map[string]any{"title": str("Titel"), "notes": str("Notizen"), "column": enum("Spalte", "inbox", "next", "doing", "waiting", "done"), "priority": enum("Priorität", "low", "normal", "high")}, []string{"title"})),
toolDef("kanban_move", "Verschiebt eine Kanban-Karte anhand ihrer ID und schützt sie als manuellen Override vor KI-Umsortierung.", obj(map[string]any{"id": str("Kanban-ID"), "column": enum("Zielspalte", "inbox", "next", "doing", "waiting", "done")}, []string{"id", "column"})),
toolDef("kanban_delete", "Löscht eine Kanban-Karte anhand ihrer ID.", obj(map[string]any{"id": str("Kanban-ID")}, []string{"id"})),
toolDef("rag_search", "Sucht semantisch in lokal indexierten Dokumenten. Nur für Dokumentwissen verwenden.", obj(map[string]any{"query": str("Suchfrage"), "limit": integer("Maximale Treffer", 1, 10)}, []string{"query"})),
}
for _, d := range r.defs {
r.known[d.Function.Name] = true
}
return r
}
func (r *toolRegistry) Definitions() []ollama.Tool { return append([]ollama.Tool(nil), r.defs...) }
func (r *toolRegistry) Has(name string) bool { return r.known[name] }
func toolDef(name, description string, params map[string]any) ollama.Tool {
return ollama.Tool{Type: "function", Function: ollama.ToolFunction{Name: name, Description: description, Parameters: params}}
}
func obj(props map[string]any, required []string) map[string]any {
if props == nil {
props = map[string]any{}
}
m := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
if len(required) > 0 {
m["required"] = required
}
return m
}
func str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} }
func boolean(desc string) map[string]any {
return map[string]any{"type": "boolean", "description": desc}
}
func integer(desc string, min, max int) map[string]any {
return map[string]any{"type": "integer", "description": desc, "minimum": min, "maximum": max}
}
func enum(desc string, vals ...string) map[string]any {
return map[string]any{"type": "string", "description": desc, "enum": vals}
}
func temporalSchema(required bool) map[string]any {
m := obj(map[string]any{
"kind": enum("absolute = konkretes Datum; relative = Tage ab heute; weekday = nächster genannter Wochentag", "absolute", "relative", "weekday"),
"date": str("Nur bei absolute: YYYY-MM-DD"),
"time": str("Lokale Uhrzeit HH:MM; weglassen, wenn keine Uhrzeit genannt wurde"),
"offset_days": map[string]any{"type": "integer", "description": "Nur bei relative: heute=0, morgen=1, übermorgen=2", "minimum": 0, "maximum": 3650},
"weekday": enum("Nur bei weekday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"),
"all_day": boolean("true, wenn ausdrücklich ganztägig oder keine Uhrzeit relevant ist"),
}, []string{"kind"})
if !required {
m["description"] = "Optionaler Zeitbezug"
}
return m
}
func (r *toolRegistry) Execute(ctx context.Context, name string, args map[string]any) toolResult {
requestID := id("tool")
res := toolResult{Tool: name, RequestID: requestID, Warnings: []string{}}
if !r.Has(name) {
res.Error = &toolError{Code: "UNKNOWN_TOOL", Message: "Tool ist nicht registriert: " + name}
return res
}
switch name {
case "system_get_time":
now := time.Now().In(r.app.timezone)
res.Success = true
res.Data = map[string]any{"now": now.Format(time.RFC3339), "timezone": r.app.timezone.String(), "weekday": strings.ToLower(now.Weekday().String())}
case "calendar_list":
res = r.calendarList(requestID, args)
case "calendar_create":
res = r.calendarCreate(requestID, args)
case "calendar_update":
res = r.calendarUpdate(requestID, args)
case "calendar_delete":
res = r.calendarDelete(requestID, args)
case "tasks_list":
res = r.tasksList(requestID, args)
case "tasks_create":
res = r.tasksCreate(requestID, args)
case "tasks_update":
res = r.tasksUpdate(requestID, args)
case "tasks_delete":
res = r.tasksDelete(requestID, args)
case "reminders_list":
res = r.remindersList(requestID, args)
case "reminders_create":
res = r.remindersCreate(requestID, args)
case "reminders_delete":
res = r.remindersDelete(requestID, args)
case "groceries_list":
res = r.groceriesList(requestID, args)
case "groceries_add":
res = r.groceriesAdd(requestID, args)
case "groceries_update":
res = r.groceriesUpdate(requestID, args)
case "groceries_remove":
res = r.groceriesRemove(requestID, args)
case "groceries_sync_mealplan":
res = r.groceriesSyncMealplan(requestID, args)
case "recipes_list":
res = r.recipesList(requestID, args)
case "recipes_search":
res = r.recipesSearch(requestID, args)
case "recipes_get":
res = r.recipesGet(requestID, args)
case "recipes_create":
res = r.recipesCreate(requestID, args)
case "recipes_update":
res = r.recipesUpdate(requestID, args)
case "mealplan_list":
res = r.mealplanList(requestID, args)
case "mealplan_create":
res = r.mealplanCreate(requestID, args)
case "mealplan_update":
res = r.mealplanUpdate(requestID, args)
case "mealplan_delete":
res = r.mealplanDelete(requestID, args)
case "kanban_list":
res = r.kanbanList(requestID, args)
case "kanban_create":
res = r.kanbanCreate(requestID, args)
case "kanban_move":
res = r.kanbanMove(requestID, args)
case "kanban_delete":
res = r.kanbanDelete(requestID, args)
case "rag_search":
res = r.ragSearch(ctx, requestID, args)
}
return res
}
func okTool(name, req string, data any) toolResult {
return toolResult{Success: true, Tool: name, RequestID: req, Data: data, Warnings: []string{}}
}
func failTool(name, req, code, msg string) toolResult {
return toolResult{Success: false, Tool: name, RequestID: req, Error: &toolError{Code: code, Message: msg}, Warnings: []string{}}
}
func decodeArgs(args map[string]any, out any) error {
b, err := json.Marshal(args)
if err != nil {
return err
}
return json.Unmarshal(b, out)
}
func (r *toolRegistry) resolveWhen(w temporalExpr) (time.Time, bool, error) {
now := time.Now().In(r.app.timezone)
kind := strings.ToLower(strings.TrimSpace(w.Kind))
var day time.Time
switch kind {
case "absolute":
if strings.TrimSpace(w.Date) == "" {
return time.Time{}, false, fmt.Errorf("date fehlt")
}
t, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(w.Date), r.app.timezone)
if err != nil {
return time.Time{}, false, fmt.Errorf("date muss YYYY-MM-DD sein")
}
day = t
case "relative":
if w.OffsetDays < 0 {
return time.Time{}, false, fmt.Errorf("offset_days darf nicht negativ sein")
}
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, r.app.timezone).AddDate(0, 0, w.OffsetDays)
case "weekday":
wd, ok := parseWeekday(w.Weekday)
if !ok {
return time.Time{}, false, fmt.Errorf("weekday ungültig")
}
delta := (int(wd) - int(now.Weekday()) + 7) % 7
if delta == 0 && w.ForceNext {
delta = 7
}
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, r.app.timezone).AddDate(0, 0, delta)
default:
return time.Time{}, false, fmt.Errorf("kind muss absolute, relative oder weekday sein")
}
allDay := w.AllDay || strings.TrimSpace(w.Time) == ""
if allDay {
return day, true, nil
}
parts := strings.Split(strings.TrimSpace(w.Time), ":")
if len(parts) != 2 {
return time.Time{}, false, fmt.Errorf("time muss HH:MM sein")
}
h, e1 := strconv.Atoi(parts[0])
m, e2 := strconv.Atoi(parts[1])
if e1 != nil || e2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return time.Time{}, false, fmt.Errorf("time ungültig")
}
return time.Date(day.Year(), day.Month(), day.Day(), h, m, 0, 0, r.app.timezone), false, nil
}
func parseWeekday(v string) (time.Weekday, bool) {
switch foldCommand(v) {
case "monday", "montag":
return time.Monday, true
case "tuesday", "dienstag":
return time.Tuesday, true
case "wednesday", "mittwoch":
return time.Wednesday, true
case "thursday", "donnerstag":
return time.Thursday, true
case "friday", "freitag":
return time.Friday, true
case "saturday", "samstag":
return time.Saturday, true
case "sunday", "sonntag":
return time.Sunday, true
}
return time.Sunday, false
}
func getString(args map[string]any, key string) string {
if v, ok := args[key].(string); ok {
return strings.TrimSpace(v)
}
return ""
}
func getInt(args map[string]any, key string, def int) int {
switch v := args[key].(type) {
case float64:
return int(v)
case int:
return v
case json.Number:
if n, err := v.Int64(); err == nil {
return int(n)
}
}
return def
}
func getBoolPtr(args map[string]any, key string) *bool {
if v, ok := args[key].(bool); ok {
return &v
}
return nil
}
func getTemporal(args map[string]any, key string) (temporalExpr, bool, error) {
v, ok := args[key]
if !ok || v == nil {
return temporalExpr{}, false, nil
}
var t temporalExpr
b, err := json.Marshal(v)
if err != nil {
return t, true, err
}
if err := json.Unmarshal(b, &t); err != nil {
return t, true, err
}
return t, true, nil
}
func (r *toolRegistry) calendarList(req string, args map[string]any) toolResult {
st := r.app.db.Snapshot()
xs := sortedEvents(st.Events)
query := getString(args, "query")
limit := getInt(args, "limit", 20)
var filterDay string
if w, ok, err := getTemporal(args, "when"); err != nil {
return failTool("calendar_list", req, "INVALID_ARGUMENTS", err.Error())
} else if ok {
t, _, err := r.resolveWhen(w)
if err != nil {
return failTool("calendar_list", req, "INVALID_TIME", err.Error())
}
filterDay = t.Format("2006-01-02")
}
out := make([]store.CalendarEvent, 0)
for _, e := range xs {
if filterDay != "" {
t, err := parseEventTime(e, r.app.timezone)
if err != nil || t.Format("2006-01-02") != filterDay {
continue
}
}
if query != "" && textSimilarity(query, e.Title+" "+e.Location+" "+e.Notes) < .2 {
continue
}
out = append(out, e)
if len(out) >= limit {
break
}
}
return okTool("calendar_list", req, out)
}
func (r *toolRegistry) calendarCreate(req string, args map[string]any) toolResult {
var p struct {
Title string `json:"title"`
When temporalExpr `json:"when"`
Duration int `json:"duration_minutes"`
Location string `json:"location"`
Notes string `json:"notes"`
}
if err := decodeArgs(args, &p); err != nil || strings.TrimSpace(p.Title) == "" {
return failTool("calendar_create", req, "INVALID_ARGUMENTS", "title und when sind erforderlich")
}
t, allDay, err := r.resolveWhen(p.When)
if err != nil {
return failTool("calendar_create", req, "INVALID_TIME", err.Error())
}
start := t.Format(time.RFC3339)
end := ""
if p.Duration > 0 && !allDay {
end = t.Add(time.Duration(p.Duration) * time.Minute).Format(time.RFC3339)
}
v := store.CalendarEvent{ID: id("event_tool"), Title: strings.TrimSpace(p.Title), Start: start, End: end, AllDay: allDay, Location: strings.TrimSpace(p.Location), Notes: strings.TrimSpace(p.Notes), Source: "jarvis-tool", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
for _, e := range r.app.db.Snapshot().Events {
if eventEquivalent(e, v, r.app.timezone) {
res := okTool("calendar_create", req, e)
res.Warnings = []string{"ALREADY_EXISTS"}
res.Entity = &toolEntity{Kind: "event", ID: e.ID}
return res
}
}
if err := r.app.db.Update(func(s *store.State) error { s.Events = append(s.Events, v); return nil }); err != nil {
return failTool("calendar_create", req, "STORE_ERROR", err.Error())
}
res := okTool("calendar_create", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "event", ID: v.ID}
return res
}
func (r *toolRegistry) calendarUpdate(req string, args map[string]any) toolResult {
idv := getString(args, "id")
if idv == "" {
return failTool("calendar_update", req, "INVALID_ARGUMENTS", "id fehlt")
}
st := r.app.db.Snapshot()
var cur *store.CalendarEvent
for i := range st.Events {
if st.Events[i].ID == idv {
x := st.Events[i]
cur = &x
break
}
}
if cur == nil {
return failTool("calendar_update", req, "NOT_FOUND", "Kalendertermin nicht gefunden")
}
v := *cur
if x := getString(args, "title"); x != "" {
v.Title = x
}
if x := getString(args, "location"); x != "" {
v.Location = x
}
if x := getString(args, "notes"); x != "" {
v.Notes = x
}
if w, ok, err := getTemporal(args, "when"); err != nil {
return failTool("calendar_update", req, "INVALID_ARGUMENTS", err.Error())
} else if ok {
t, ad, e := r.resolveWhen(w)
if e != nil {
return failTool("calendar_update", req, "INVALID_TIME", e.Error())
}
v.Start = t.Format(time.RFC3339)
v.AllDay = ad
v.End = ""
}
if d := getInt(args, "duration_minutes", 0); d > 0 && !v.AllDay {
if t, e := time.Parse(time.RFC3339, v.Start); e == nil {
v.End = t.Add(time.Duration(d) * time.Minute).Format(time.RFC3339)
}
}
v.UpdatedAt = store.Now()
if err := r.app.db.Update(func(s *store.State) error { upsertEvent(s, v); return nil }); err != nil {
return failTool("calendar_update", req, "STORE_ERROR", err.Error())
}
res := okTool("calendar_update", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "event", ID: v.ID}
return res
}
func (r *toolRegistry) calendarDelete(req string, args map[string]any) toolResult {
idv := getString(args, "id")
if idv == "" {
return failTool("calendar_delete", req, "INVALID_ARGUMENTS", "id fehlt")
}
st := r.app.db.Snapshot()
var found *store.CalendarEvent
for i := range st.Events {
if st.Events[i].ID == idv {
x := st.Events[i]
found = &x
break
}
}
if found == nil {
return failTool("calendar_delete", req, "NOT_FOUND", "Kalendertermin nicht gefunden")
}
_ = r.app.db.Update(func(s *store.State) error {
s.Events = filterEvents(s.Events, idv)
rem := s.Reminders[:0]
for _, x := range s.Reminders {
if !(x.LinkedType == "event" && x.LinkedID == idv) {
rem = append(rem, x)
}
}
s.Reminders = append([]store.Reminder(nil), rem...)
return nil
})
res := okTool("calendar_delete", req, map[string]any{"deleted": found})
res.Mutated = true
res.Entity = &toolEntity{Kind: "event", ID: idv}
return res
}
func (r *toolRegistry) tasksList(req string, args map[string]any) toolResult {
st := r.app.db.Snapshot()
status := getString(args, "status")
query := getString(args, "query")
limit := getInt(args, "limit", 20)
out := []store.Task{}
for _, t := range st.Tasks {
if status == "open" && t.Status == "done" {
continue
}
if status == "done" && t.Status != "done" {
continue
}
if query != "" && textSimilarity(query, t.Title+" "+t.Notes) < .2 {
continue
}
out = append(out, t)
if len(out) >= limit {
break
}
}
return okTool("tasks_list", req, out)
}
func (r *toolRegistry) tasksCreate(req string, args map[string]any) toolResult {
title := getString(args, "title")
if title == "" {
return failTool("tasks_create", req, "INVALID_ARGUMENTS", "title fehlt")
}
due := ""
if w, ok, err := getTemporal(args, "due"); err != nil {
return failTool("tasks_create", req, "INVALID_ARGUMENTS", err.Error())
} else if ok {
t, _, e := r.resolveWhen(w)
if e != nil {
return failTool("tasks_create", req, "INVALID_TIME", e.Error())
}
due = t.Format(time.RFC3339)
}
st := r.app.db.Snapshot()
for _, t := range st.Tasks {
if t.Status != "done" && normalizeTask(t.Title) == normalizeTask(title) {
res := okTool("tasks_create", req, t)
res.Warnings = []string{"ALREADY_EXISTS"}
res.Entity = &toolEntity{Kind: "task", ID: t.ID}
return res
}
}
source := getString(args, "source")
if source == "" {
source = "jarvis-tool"
}
confidence := 0.0
if v, ok := args["confidence"].(float64); ok {
confidence = v
}
v := store.Task{ID: id("task_tool"), Title: title, Notes: getString(args, "notes"), Due: due, Status: "open", Priority: normalizePriority(getString(args, "priority")), Source: source, Confidence: confidence, Fingerprint: getString(args, "fingerprint"), AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
_ = r.app.db.Update(func(s *store.State) error { s.Tasks = append([]store.Task{v}, s.Tasks...); return nil })
res := okTool("tasks_create", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "task", ID: v.ID}
return res
}
func (r *toolRegistry) tasksUpdate(req string, args map[string]any) toolResult {
idv := getString(args, "id")
st := r.app.db.Snapshot()
var cur *store.Task
for i := range st.Tasks {
if st.Tasks[i].ID == idv {
x := st.Tasks[i]
cur = &x
break
}
}
if cur == nil {
return failTool("tasks_update", req, "NOT_FOUND", "Aufgabe nicht gefunden")
}
v := *cur
if x := getString(args, "title"); x != "" {
v.Title = x
}
if x := getString(args, "notes"); x != "" {
v.Notes = x
}
if x := getString(args, "priority"); x != "" {
v.Priority = normalizePriority(x)
}
if x := getString(args, "status"); x == "open" || x == "done" {
v.Status = x
}
if w, ok, _ := getTemporal(args, "due"); ok {
t, _, e := r.resolveWhen(w)
if e != nil {
return failTool("tasks_update", req, "INVALID_TIME", e.Error())
}
v.Due = t.Format(time.RFC3339)
}
v.UpdatedAt = store.Now()
_ = r.app.db.Update(func(s *store.State) error {
for i := range s.Tasks {
if s.Tasks[i].ID == v.ID {
s.Tasks[i] = v
}
}
return nil
})
res := okTool("tasks_update", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "task", ID: v.ID}
return res
}
func (r *toolRegistry) tasksDelete(req string, args map[string]any) toolResult {
idv := getString(args, "id")
st := r.app.db.Snapshot()
var found *store.Task
for i := range st.Tasks {
if st.Tasks[i].ID == idv {
x := st.Tasks[i]
found = &x
break
}
}
if found == nil {
return failTool("tasks_delete", req, "NOT_FOUND", "Aufgabe nicht gefunden")
}
_ = r.app.db.Update(func(s *store.State) error {
out := s.Tasks[:0]
for _, x := range s.Tasks {
if x.ID != idv {
out = append(out, x)
}
}
s.Tasks = append([]store.Task(nil), out...)
cards := s.Kanban[:0]
for _, c := range s.Kanban {
if c.TaskID != idv {
cards = append(cards, c)
}
}
s.Kanban = append([]store.KanbanCard(nil), cards...)
return nil
})
res := okTool("tasks_delete", req, map[string]any{"deleted": found})
res.Mutated = true
res.Entity = &toolEntity{Kind: "task", ID: idv}
return res
}
func (r *toolRegistry) remindersList(req string, args map[string]any) toolResult {
st := r.app.db.Snapshot()
f := getBoolPtr(args, "done")
limit := getInt(args, "limit", 20)
out := []store.Reminder{}
for _, x := range st.Reminders {
if f != nil && x.Done != *f {
continue
}
out = append(out, x)
if len(out) >= limit {
break
}
}
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At })
return okTool("reminders_list", req, out)
}
func (r *toolRegistry) remindersCreate(req string, args map[string]any) toolResult {
title := getString(args, "title")
w, ok, err := getTemporal(args, "when")
if title == "" || err != nil || !ok {
return failTool("reminders_create", req, "INVALID_ARGUMENTS", "title und when erforderlich")
}
t, _, e := r.resolveWhen(w)
if e != nil {
return failTool("reminders_create", req, "INVALID_TIME", e.Error())
}
v := store.Reminder{ID: id("rem_tool"), Title: title, At: t.Format(time.RFC3339), Notes: getString(args, "notes"), Source: "jarvis-tool", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
_ = r.app.db.Update(func(s *store.State) error { s.Reminders = append(s.Reminders, v); return nil })
res := okTool("reminders_create", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "reminder", ID: v.ID}
return res
}
func (r *toolRegistry) remindersDelete(req string, args map[string]any) toolResult {
idv := getString(args, "id")
st := r.app.db.Snapshot()
var found *store.Reminder
for i := range st.Reminders {
if st.Reminders[i].ID == idv {
x := st.Reminders[i]
found = &x
break
}
}
if found == nil {
return failTool("reminders_delete", req, "NOT_FOUND", "Erinnerung nicht gefunden")
}
_ = r.app.db.Update(func(s *store.State) error {
out := s.Reminders[:0]
for _, x := range s.Reminders {
if x.ID != idv {
out = append(out, x)
}
}
s.Reminders = append([]store.Reminder(nil), out...)
return nil
})
res := okTool("reminders_delete", req, map[string]any{"deleted": found})
res.Mutated = true
res.Entity = &toolEntity{Kind: "reminder", ID: idv}
return res
}
func (r *toolRegistry) groceriesList(req string, args map[string]any) toolResult {
st := r.app.db.Snapshot()
f := getBoolPtr(args, "checked")
out := []store.GroceryItem{}
for _, x := range st.Groceries {
if f != nil && x.Checked != *f {
continue
}
out = append(out, x)
}
return okTool("groceries_list", req, out)
}
func (r *toolRegistry) groceriesAdd(req string, args map[string]any) toolResult {
name := getString(args, "name")
if name == "" {
return failTool("groceries_add", req, "INVALID_ARGUMENTS", "name fehlt")
}
v := store.GroceryItem{ID: id("grocery_tool"), Name: name, Quantity: getString(args, "quantity"), Unit: getString(args, "unit"), Category: getString(args, "category"), Source: "jarvis-tool", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
_ = r.app.db.Update(func(s *store.State) error { s.Groceries = append(s.Groceries, v); return nil })
res := okTool("groceries_add", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "grocery", ID: v.ID}
return res
}
func (r *toolRegistry) groceriesUpdate(req string, args map[string]any) toolResult {
idv := getString(args, "id")
st := r.app.db.Snapshot()
var cur *store.GroceryItem
for i := range st.Groceries {
if st.Groceries[i].ID == idv {
x := st.Groceries[i]
cur = &x
break
}
}
if cur == nil {
return failTool("groceries_update", req, "NOT_FOUND", "Einkaufsartikel nicht gefunden")
}
v := *cur
if x := getString(args, "name"); x != "" {
v.Name = x
}
if x := getString(args, "quantity"); x != "" {
v.Quantity = x
}
if x := getString(args, "unit"); x != "" {
v.Unit = x
}
if x := getString(args, "category"); x != "" {
v.Category = x
}
if b := getBoolPtr(args, "checked"); b != nil {
v.Checked = *b
}
v.UpdatedAt = store.Now()
_ = r.app.db.Update(func(s *store.State) error { upsertGrocery(s, v); return nil })
res := okTool("groceries_update", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "grocery", ID: v.ID}
return res
}
func (r *toolRegistry) groceriesRemove(req string, args map[string]any) toolResult {
idv := getString(args, "id")
st := r.app.db.Snapshot()
var found *store.GroceryItem
for i := range st.Groceries {
if st.Groceries[i].ID == idv {
x := st.Groceries[i]
found = &x
break
}
}
if found == nil {
return failTool("groceries_remove", req, "NOT_FOUND", "Einkaufsartikel nicht gefunden")
}
_ = r.app.db.Update(func(s *store.State) error {
out := s.Groceries[:0]
for _, x := range s.Groceries {
if x.ID != idv {
out = append(out, x)
}
}
s.Groceries = append([]store.GroceryItem(nil), out...)
return nil
})
res := okTool("groceries_remove", req, map[string]any{"deleted": found})
res.Mutated = true
res.Entity = &toolEntity{Kind: "grocery", ID: idv}
return res
}
func (r *toolRegistry) recipesList(req string, args map[string]any) toolResult {
limit := getInt(args, "limit", 50)
rs := append([]store.Recipe(nil), r.app.db.Snapshot().Recipes...)
sort.Slice(rs, func(i, j int) bool { return strings.ToLower(rs[i].Title) < strings.ToLower(rs[j].Title) })
if limit > 0 && len(rs) > limit {
rs = rs[:limit]
}
return okTool("recipes_list", req, rs)
}
func (r *toolRegistry) recipesSearch(req string, args map[string]any) toolResult {
q := getString(args, "query")
if q == "" {
return failTool("recipes_search", req, "INVALID_ARGUMENTS", "query fehlt")
}
limit := getInt(args, "limit", 10)
type scored struct {
R store.Recipe
S float64
}
ss := []scored{}
for _, x := range r.app.db.Snapshot().Recipes {
s := textSimilarity(q, x.Title+" "+strings.Join(x.Tags, " ")+" "+x.Description)
if s > .08 {
ss = append(ss, scored{x, s})
}
}
sort.Slice(ss, func(i, j int) bool { return ss[i].S > ss[j].S })
out := []map[string]any{}
for i, x := range ss {
if i >= limit {
break
}
out = append(out, map[string]any{"id": x.R.ID, "title": x.R.Title, "servings": x.R.Servings, "prepMinutes": x.R.PrepMinutes, "score": x.S})
}
return okTool("recipes_search", req, out)
}
func (r *toolRegistry) recipesGet(req string, args map[string]any) toolResult {
idv := getString(args, "id")
for _, x := range r.app.db.Snapshot().Recipes {
if x.ID == idv {
return okTool("recipes_get", req, x)
}
}
return failTool("recipes_get", req, "NOT_FOUND", "Rezept nicht gefunden")
}
func (r *toolRegistry) recipesCreate(req string, args map[string]any) toolResult {
var p struct {
Title string `json:"title"`
Description string `json:"description"`
Servings int `json:"servings"`
Prep int `json:"prep_minutes"`
Ingredients []store.Ingredient `json:"ingredients"`
Steps []string `json:"steps"`
}
if err := decodeArgs(args, &p); err != nil || strings.TrimSpace(p.Title) == "" {
return failTool("recipes_create", req, "INVALID_ARGUMENTS", "title fehlt")
}
if p.Servings <= 0 {
p.Servings = 2
}
v := store.Recipe{ID: id("recipe_tool"), Title: strings.TrimSpace(p.Title), Description: p.Description, Servings: p.Servings, PrepMinutes: p.Prep, Ingredients: p.Ingredients, Steps: p.Steps, Source: "jarvis-tool", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}
_ = r.app.db.Update(func(s *store.State) error { s.Recipes = append(s.Recipes, v); return nil })
res := okTool("recipes_create", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "recipe", ID: v.ID}
return res
}
func (r *toolRegistry) recipesUpdate(req string, args map[string]any) toolResult {
idv := getString(args, "id")
if idv == "" {
return failTool("recipes_update", req, "INVALID_ARGUMENTS", "id fehlt")
}
st := r.app.db.Snapshot()
var cur *store.Recipe
for i := range st.Recipes {
if st.Recipes[i].ID == idv {
x := st.Recipes[i]
cur = &x
break
}
}
if cur == nil {
return failTool("recipes_update", req, "NOT_FOUND", "Rezept nicht gefunden")
}
v := *cur
if _, ok := args["title"]; ok {
if x := strings.TrimSpace(getString(args, "title")); x != "" {
v.Title = x
}
}
if _, ok := args["description"]; ok {
v.Description = getString(args, "description")
}
if _, ok := args["servings"]; ok {
if n := getInt(args, "servings", v.Servings); n > 0 {
v.Servings = n
}
}
if _, ok := args["prep_minutes"]; ok {
v.PrepMinutes = getInt(args, "prep_minutes", v.PrepMinutes)
}
if raw, ok := args["ingredients"]; ok {
b, _ := json.Marshal(raw)
var xs []store.Ingredient
if err := json.Unmarshal(b, &xs); err != nil {
return failTool("recipes_update", req, "INVALID_ARGUMENTS", "ingredients ungültig")
}
v.Ingredients = xs
}
if raw, ok := args["steps"]; ok {
b, _ := json.Marshal(raw)
var xs []string
if err := json.Unmarshal(b, &xs); err != nil {
return failTool("recipes_update", req, "INVALID_ARGUMENTS", "steps ungültig")
}
v.Steps = xs
}
if raw, ok := args["tags"]; ok {
b, _ := json.Marshal(raw)
var xs []string
if err := json.Unmarshal(b, &xs); err != nil {
return failTool("recipes_update", req, "INVALID_ARGUMENTS", "tags ungültig")
}
v.Tags = xs
}
v.UpdatedAt = store.Now()
_ = r.app.db.Update(func(s *store.State) error { upsertRecipe(s, v); return nil })
res := okTool("recipes_update", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "recipe", ID: v.ID}
return res
}
func (r *toolRegistry) groceriesSyncMealplan(req string, args map[string]any) toolResult {
days := getInt(args, "days", 14)
if days < 1 {
days = 14
}
if days > 60 {
days = 60
}
changed := r.app.syncMealPlanGroceriesForDays(days)
st := r.app.db.Snapshot()
now := time.Now().In(r.app.timezone)
today := now.Format("2006-01-02")
end := now.AddDate(0, 0, days).Format("2006-01-02")
missing := []map[string]any{}
for _, m := range st.MealPlans {
if m.Date < today || m.Date > end || m.RecipeID != "" {
continue
}
missing = append(missing, map[string]any{"id": m.ID, "date": m.Date, "meal": m.Meal, "title": m.Title})
}
items := []store.GroceryItem{}
for _, g := range st.Groceries {
if g.AIManaged && strings.HasPrefix(g.Source, "mealplan:auto:") {
items = append(items, g)
}
}
res := okTool("groceries_sync_mealplan", req, map[string]any{"changed": changed, "days": days, "items": items, "missing_recipe_meals": missing})
res.Mutated = changed > 0
return res
}
func (r *toolRegistry) mealplanList(req string, args map[string]any) toolResult {
st := r.app.db.Snapshot()
meal := normalizeMeal(getString(args, "meal"))
limit := getInt(args, "limit", 20)
day := ""
if w, ok, err := getTemporal(args, "when"); err != nil {
return failTool("mealplan_list", req, "INVALID_ARGUMENTS", err.Error())
} else if ok {
t, _, e := r.resolveWhen(w)
if e != nil {
return failTool("mealplan_list", req, "INVALID_TIME", e.Error())
}
day = t.Format("2006-01-02")
}
out := []store.MealPlan{}
for _, m := range st.MealPlans {
if day != "" && m.Date != day {
continue
}
if meal != "" && normalizeMeal(m.Meal) != meal {
continue
}
out = append(out, m)
if len(out) >= limit {
break
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Date == out[j].Date {
return out[i].Meal < out[j].Meal
}
return out[i].Date < out[j].Date
})
return okTool("mealplan_list", req, out)
}
func (r *toolRegistry) mealplanCreate(req string, args map[string]any) toolResult {
title := getString(args, "title")
meal := normalizeMeal(getString(args, "meal"))
w, ok, err := getTemporal(args, "when")
if title == "" || meal == "" || err != nil || !ok {
return failTool("mealplan_create", req, "INVALID_ARGUMENTS", "title, when und meal erforderlich")
}
t, _, e := r.resolveWhen(w)
if e != nil {
return failTool("mealplan_create", req, "INVALID_TIME", e.Error())
}
rid := getString(args, "recipe_id")
st := r.app.db.Snapshot()
if rid == "" {
best := ""
score := 0.0
for _, rx := range st.Recipes {
if s := textSimilarity(title, rx.Title); s > score {
score = s
best = rx.ID
}
}
if score >= .34 {
rid = best
}
}
if rid != "" {
for _, rx := range st.Recipes {
if rx.ID == rid && textSimilarity(title, rx.Title) >= .20 {
title = rx.Title
break
}
}
}
serv := getInt(args, "servings", 2)
if serv <= 0 {
serv = 2
}
v := store.MealPlan{ID: id("meal_tool"), Date: t.Format("2006-01-02"), Meal: meal, RecipeID: rid, Title: title, Servings: serv, Notes: getString(args, "notes"), AIManaged: true, Source: "jarvis-tool", CreatedAt: store.Now(), UpdatedAt: store.Now()}
for _, existing := range st.MealPlans {
if existing.Date == v.Date && normalizeMeal(existing.Meal) == v.Meal {
v.ID = existing.ID
v.CreatedAt = existing.CreatedAt
_ = r.app.db.Update(func(s *store.State) error { upsertMealPlan(s, v); return nil })
res := okTool("mealplan_create", req, v)
res.Mutated = true
res.Warnings = []string{"SLOT_UPDATED"}
res.Entity = &toolEntity{Kind: "meal_plan", ID: v.ID}
return res
}
}
_ = r.app.db.Update(func(s *store.State) error { s.MealPlans = append(s.MealPlans, v); return nil })
res := okTool("mealplan_create", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "meal_plan", ID: v.ID}
return res
}
func (r *toolRegistry) mealplanUpdate(req string, args map[string]any) toolResult {
idv := getString(args, "id")
st := r.app.db.Snapshot()
var cur *store.MealPlan
for i := range st.MealPlans {
if st.MealPlans[i].ID == idv {
x := st.MealPlans[i]
cur = &x
break
}
}
if cur == nil {
return failTool("mealplan_update", req, "NOT_FOUND", "Essensplan-Eintrag nicht gefunden")
}
v := *cur
if x := getString(args, "title"); x != "" {
v.Title = x
}
if x := normalizeMeal(getString(args, "meal")); x != "" {
v.Meal = x
}
if x := getString(args, "recipe_id"); x != "" {
v.RecipeID = x
}
if n := getInt(args, "servings", 0); n > 0 {
v.Servings = n
}
if x := getString(args, "notes"); x != "" {
v.Notes = x
}
if w, ok, _ := getTemporal(args, "when"); ok {
t, _, e := r.resolveWhen(w)
if e != nil {
return failTool("mealplan_update", req, "INVALID_TIME", e.Error())
}
v.Date = t.Format("2006-01-02")
}
v.UpdatedAt = store.Now()
_ = r.app.db.Update(func(s *store.State) error { upsertMealPlan(s, v); return nil })
res := okTool("mealplan_update", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "meal_plan", ID: v.ID}
return res
}
func (r *toolRegistry) mealplanDelete(req string, args map[string]any) toolResult {
idv := getString(args, "id")
st := r.app.db.Snapshot()
var found *store.MealPlan
for i := range st.MealPlans {
if st.MealPlans[i].ID == idv {
x := st.MealPlans[i]
found = &x
break
}
}
if found == nil {
return failTool("mealplan_delete", req, "NOT_FOUND", "Essensplan-Eintrag nicht gefunden")
}
_ = r.app.db.Update(func(s *store.State) error {
out := s.MealPlans[:0]
for _, x := range s.MealPlans {
if x.ID != idv {
out = append(out, x)
}
}
s.MealPlans = append([]store.MealPlan(nil), out...)
return nil
})
res := okTool("mealplan_delete", req, map[string]any{"deleted": found})
res.Mutated = true
res.Entity = &toolEntity{Kind: "meal_plan", ID: idv}
return res
}
func toolNormalizeColumn(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "inbox", "next", "doing", "waiting", "done":
return strings.ToLower(strings.TrimSpace(v))
}
return ""
}
func (r *toolRegistry) kanbanList(req string, args map[string]any) toolResult {
col := getString(args, "column")
out := []store.KanbanCard{}
for _, x := range r.app.db.Snapshot().Kanban {
if col != "" && col != "all" && x.Column != col {
continue
}
out = append(out, x)
}
return okTool("kanban_list", req, out)
}
func (r *toolRegistry) kanbanCreate(req string, args map[string]any) toolResult {
title := getString(args, "title")
if title == "" {
return failTool("kanban_create", req, "INVALID_ARGUMENTS", "title fehlt")
}
col := toolNormalizeColumn(getString(args, "column"))
if col == "" {
col = "inbox"
}
v := store.KanbanCard{ID: id("kanban_tool"), Title: title, Notes: getString(args, "notes"), Column: col, Priority: normalizePriority(getString(args, "priority")), Source: "jarvis-tool", AIManaged: false, CreatedAt: store.Now(), UpdatedAt: store.Now()}
_ = r.app.db.Update(func(s *store.State) error { s.Kanban = append(s.Kanban, v); return nil })
res := okTool("kanban_create", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "kanban", ID: v.ID}
return res
}
func (r *toolRegistry) kanbanMove(req string, args map[string]any) toolResult {
idv := getString(args, "id")
col := toolNormalizeColumn(getString(args, "column"))
if col == "" {
return failTool("kanban_move", req, "INVALID_ARGUMENTS", "column ungültig")
}
st := r.app.db.Snapshot()
var cur *store.KanbanCard
for i := range st.Kanban {
if st.Kanban[i].ID == idv {
x := st.Kanban[i]
cur = &x
break
}
}
if cur == nil {
return failTool("kanban_move", req, "NOT_FOUND", "Kanban-Karte nicht gefunden")
}
v := *cur
v.Column = col
v.AIManaged = false
v.Source = "manual-override"
v.UpdatedAt = store.Now()
_ = r.app.db.Update(func(s *store.State) error { upsertKanban(s, v); return nil })
res := okTool("kanban_move", req, v)
res.Mutated = true
res.Entity = &toolEntity{Kind: "kanban", ID: v.ID}
return res
}
func (r *toolRegistry) kanbanDelete(req string, args map[string]any) toolResult {
idv := getString(args, "id")
st := r.app.db.Snapshot()
var found *store.KanbanCard
for i := range st.Kanban {
if st.Kanban[i].ID == idv {
x := st.Kanban[i]
found = &x
break
}
}
if found == nil {
return failTool("kanban_delete", req, "NOT_FOUND", "Kanban-Karte nicht gefunden")
}
_ = r.app.db.Update(func(s *store.State) error {
out := s.Kanban[:0]
for _, x := range s.Kanban {
if x.ID != idv {
out = append(out, x)
}
}
s.Kanban = append([]store.KanbanCard(nil), out...)
return nil
})
res := okTool("kanban_delete", req, map[string]any{"deleted": found})
res.Mutated = true
res.Entity = &toolEntity{Kind: "kanban", ID: idv}
return res
}
func (r *toolRegistry) ragSearch(ctx context.Context, req string, args map[string]any) toolResult {
q := getString(args, "query")
if q == "" {
return failTool("rag_search", req, "INVALID_ARGUMENTS", "query fehlt")
}
limit := getInt(args, "limit", 5)
vecs, err := r.app.ai.Embed(ctx, []string{q})
if err != nil || len(vecs) == 0 {
if err == nil {
err = fmt.Errorf("kein Embedding erzeugt")
}
return failTool("rag_search", req, "RAG_UNAVAILABLE", err.Error())
}
hits := r.app.rag.Search(vecs[0], limit)
return okTool("rag_search", req, hits)
}