diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..75f619c --- /dev/null +++ b/.env.example @@ -0,0 +1,60 @@ +JARVIS_ADDR=:8080 +JARVIS_DATA=./data/store.json +JARVIS_RAG_DATA=./data/rag.json +JARVIS_WAKE_WORD=jarvis +TZ=Europe/Berlin + +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=auto +OLLAMA_EMBED_MODEL=auto + +# Autonome Worker +TASK_SCOUT_INTERVAL=2m +HOME_AUTOMATION_INTERVAL=1m +KANBAN_AI_INTERVAL=5m + +# Lokale Sprachpipeline +VOICE_FFMPEG_BIN=ffmpeg +VOICE_WHISPER_BIN=whisper-cli +VOICE_WHISPER_MODEL=./models/ggml-small.bin +VOICE_LANGUAGE=de +VOICE_PIPER_BIN=piper +VOICE_PIPER_MODEL=./models/de_DE-thorsten-medium.onnx + +# Explizite Zeitzone für KI-Datumsnormalisierung +JARVIS_TIMEZONE=Europe/Berlin + +# Ollama-Kontextfenster / Tokenbudgets +# 16k ist ein guter Startwert für gemma3:4b auf einem lokalen Rechner. +# Bei knapper VRAM/RAM auf 8192 reduzieren; bei genügend Speicher z.B. 32768. +OLLAMA_NUM_CTX=16384 +OLLAMA_NUM_PREDICT=1200 +OLLAMA_TEMPERATURE=0.15 + +# Strukturierte Planner/Scout/Kanban-Aufrufe: kleiner, deterministischer, Temperatur 0. +OLLAMA_PLANNER_NUM_CTX=8192 +OLLAMA_PLANNER_NUM_PREDICT=700 +OLLAMA_PLANNER_TEMPERATURE=0 + + +# Native Tool Agent (Ollama function/tool calling) +OLLAMA_AGENT_NUM_CTX=16384 +OLLAMA_AGENT_NUM_PREDICT=700 +OLLAMA_AGENT_TEMPERATURE=0 +JARVIS_AGENT_MAX_STEPS=6 + +# Logische Verteilung innerhalb des Kontextfensters. +JARVIS_HISTORY_TOKENS=3200 +JARVIS_PLANNER_HISTORY_TOKENS=1800 +JARVIS_STATE_TOKENS=3200 +JARVIS_RAG_TOKENS=4200 + +# Strukturierter Debug-Trace (lokal) +# Zeichnet API-I/O und interne Pipeline-Stufen als JSONL auf. +JARVIS_DEBUG_TRACE=true +JARVIS_DEBUG_DIR=./data/debug +JARVIS_DEBUG_MAX_MB=25 +JARVIS_DEBUG_KEEP_FILES=4 +JARVIS_DEBUG_MAX_STRING=24000 +# GET /api/state ist sehr häufig; standardmäßig nicht mitschreiben. +JARVIS_DEBUG_HTTP_STATE=false diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..cfe785d --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-latest + env: + DOCKER_ORG: sendnrw + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."git.send.nrw"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: git.send.nrw # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3ddf69d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.26-bookworm AS build +WORKDIR /src +COPY go.mod ./ +COPY cmd ./cmd +COPY internal ./internal +RUN CGO_ENABLED=0 go build -o /out/jarvis ./cmd/homehub + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates ffmpeg poppler-utils tesseract-ocr tesseract-ocr-deu && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /out/jarvis /app/jarvis +RUN mkdir -p /app/data /models /tools +EXPOSE 8080 +CMD ["/app/jarvis"] diff --git a/README.md b/README.md index 2f807cc..5a42211 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,498 @@ -# jarvis-home +# JARVIS Home Command v6 – Native Tool Agent + +Lokales Home-Dashboard in Go mit Ollama, Voice, RAG, Kalender, Aufgaben, Erinnerungen, Einkauf, Rezepten, Essensplanung und KI-Kanban. + +**v6 ersetzt den bisherigen Action-Planner im Chatpfad durch eine echte Tool-/Function-Calling-Architektur.** Die KI darf den Home-State nicht direkt verändern. Sie wählt ein registriertes Tool und liefert dessen JSON-Arguments; Go validiert und führt es aus. + +## Architektur + +```text +Voice / Text + │ + ▼ +Conversation Window + │ + ▼ +Ollama Tool Agent + │ native message.tool_calls + ▼ +Tool Registry + JSON Schema + │ + ├── calendar_* + ├── tasks_* + ├── reminders_* + ├── groceries_* + ├── recipes_* + ├── mealplan_* + ├── kanban_* + ├── rag_search + └── system_get_time + │ + ▼ +Go Domain Services / Validator + │ + ▼ +Store + │ + ▼ +Domain Automations + ├── Event -> Reminder + ├── MealPlan + Recipe -> Groceries + └── Task -> Kanban +``` + +### Kernregel + +**Keine Tool-Ausführung = kein Commit.** + +Ein Modell darf also nicht mehr durch eine freie Chat-Antwort behaupten, es habe einen Termin oder Essensplan gespeichert. Mutations-Bestätigungen werden serverseitig aus dem tatsächlich persistierten Tool-Ergebnis formuliert. + +## Native Ollama Tools + +JARVIS sendet JSON-Schemas direkt über das `tools`-Feld an Ollamas `/api/chat`. Die Modellantwort wird über `message.tool_calls[].function.name` und `arguments` ausgewertet. + +Der Agent kann mehrere Schritte durchführen: + +```text +User: Plane morgen Nudeln mit Tomatensauce für vier Personen. + +1. recipes_search + -> recipe_id=r1 + +2. mealplan_create + -> date resolved by Go + -> recipe_id=r1 + -> servings=4 + +3. Home automation + -> Zutaten skaliert in Einkaufsliste + +4. Deterministische Commit-Antwort +``` + +### Compatibility Selector + +Nicht jedes lokale Modell emittiert `tool_calls` immer zuverlässig. Wenn eine eindeutige Home-Anfrage vorliegt, aber das Modell nur Prosa liefert, führt JARVIS **einmalig** einen JSON-Selector-Aufruf aus: + +```json +{ + "tool": "calendar_create", + "arguments": { + "title": "Zahnarzt", + "when": { + "kind": "relative", + "offset_days": 1, + "time": "15:30" + } + }, + "confidence": 0.99 +} +``` + +Auch dieser Pfad darf nur registrierte Tools ausführen. Unbekannte Toolnamen werden abgelehnt. + +## Zeitmodell + +Die KI berechnet Wochentage und relative Daten **nicht mehr selbst**. Sie liefert symbolische Zeitobjekte. + +Heute: + +```json +{"kind":"relative","offset_days":0} +``` + +Morgen 15:30: + +```json +{"kind":"relative","offset_days":1,"time":"15:30"} +``` + +Montag: + +```json +{"kind":"weekday","weekday":"monday"} +``` + +Nächsten Montag: + +```json +{"kind":"weekday","weekday":"monday","force_next":true} +``` + +Explizites Datum: + +```json +{"kind":"absolute","date":"2026-08-31","time":"18:00"} +``` + +Erst das Go-Tool wandelt diese Struktur in einen RFC3339-Zeitstempel in `JARVIS_TIMEZONE` um. + +## Registrierte Tools + +### System / Wissen + +- `system_get_time` +- `rag_search` + +### Kalender + +- `calendar_list` +- `calendar_create` +- `calendar_update` +- `calendar_delete` + +### Aufgaben + +- `tasks_list` +- `tasks_create` +- `tasks_update` +- `tasks_delete` + +### Erinnerungen + +- `reminders_list` +- `reminders_create` +- `reminders_delete` + +### Einkauf + +- `groceries_list` +- `groceries_add` +- `groceries_update` +- `groceries_remove` + +### Rezepte + +- `recipes_search` +- `recipes_get` +- `recipes_create` + +### Essensplan + +- `mealplan_list` +- `mealplan_create` +- `mealplan_update` +- `mealplan_delete` + +### Kanban + +- `kanban_list` +- `kanban_create` +- `kanban_move` +- `kanban_delete` + +Die vollständigen JSON-Schemas werden zur Laufzeit aus der Registry an Ollama gesendet und im Debug-Trace protokolliert. Du kannst sie außerdem direkt über `GET /api/agent/tools` ansehen. + +## Conversation Context + +Der Agent bekommt weiterhin ein echtes `user`-/`assistant`-History-Fenster. Der komplette Home-State wird ihm aber **nicht mehr bei jeder Anfrage in den Prompt geschoben**. + +Wenn JARVIS Informationen braucht, muss er ein Lese-Tool verwenden. Dadurch ist der Store die einzige Wahrheit. + +Die letzte erfolgreiche Mutation wird für kurze Follow-ups als kompakte Session-Referenz gehalten: + +```text +kind=meal_plan id=meal_abc +``` + +Damit kann ein Folgekommando wie + +```text +Nein, doch Freitag. +``` + +den gerade veränderten Eintrag gezielt über `mealplan_update` korrigieren. + +## Autonomous Task Scout + +Der Task Scout analysiert weiterhin Nutzertexte und Dokumente auf Verpflichtungen. Neu ist: Ein akzeptierter Scout-Kandidat schreibt nicht mehr direkt in den Store, sondern wird über dasselbe `tasks_create`-Tool ausgeführt. + +Damit gibt es für KI-erzeugte Aufgaben nur noch einen validierten Schreibpfad. + +Kalendertermine, Erinnerungen, JARVIS-Antworten und explizite Home-Kommandos bleiben als Scout-Quelle ausgeschlossen. + +## Domain Automations + +Nach erfolgreicher Tool-Mutation werden die deterministischen Automationen synchron ausgeführt: + +- Kalendertermin -> automatischer Reminder 30 Minuten vorher +- Rezept im Essensplan -> Zutaten in die Einkaufsliste +- Portionen -> Zutatenmenge skaliert +- Aufgabe -> verknüpfte Kanban-Karte +- erledigte Aufgabe -> KI-Kanban auf DONE +- gelöschte Aufgabe -> verwaiste KI-Karte entfernt + +Manuell verschobene Kanban-Karten werden weiterhin als Override geschützt. + +## RAG + +RAG ist jetzt selbst ein Tool: `rag_search`. + +Das Modell erhält Dokumentauszüge nur dann, wenn es sie tatsächlich benötigt. Dadurch konkurriert ein großer RAG-Block nicht mehr bei jeder Home-Anfrage mit Conversation History und Tool-Schemas um Kontext. + +Unterstützte Dokumente bleiben u. a. TXT, Markdown, CSV, JSON, YAML, XML, ICS, HTML, EML, DOCX sowie PDF über `pdftotext` und Bilder via lokalem Tesseract. + +## Voice + +Die bestehende lokale Pipeline bleibt erhalten: + +```text +Browser Mic + -> Go + -> ffmpeg + -> whisper.cpp + -> Tool Agent + -> Piper + -> Browser Audio +``` + +Der globale Voice-Button und die Mikrofonbuttons je Modul bleiben erhalten. Der Modulname wird als Gesprächskontext mitgesendet; die konkrete Toolwahl bleibt beim Agenten. + +## Debug Trace v2 + +`EXPORT DEBUG` enthält jetzt zusätzlich Tool-Agent-Events: + +- `agent.request` +- `ollama.tools.request` +- `ollama.tools.response` +- `agent.tool_call` +- `agent.tool_result` +- `agent.selector_request` +- `agent.selector_response` +- `agent.completed` +- `state.before_command` +- `state.after_command` + +Beispiel: + +```json +{ + "trace_id": "...", + "component": "agent", + "stage": "tool_call", + "data": { + "tool": "mealplan_create", + "arguments": { + "title": "Pommes Currywurst", + "when": {"kind":"weekday","weekday":"sunday"}, + "meal": "lunch" + } + } +} +``` + +Danach folgt ein separates `tool_result` mit dem real gespeicherten Objekt. Damit lässt sich eindeutig unterscheiden, ob ein Fehler aus Tool-Auswahl, Arguments, Validator, Store oder UI stammt. + +## Konfiguration + +```env +JARVIS_ADDR=:8080 +JARVIS_DATA=./data/store.json +JARVIS_RAG_DATA=./data/rag.json +JARVIS_WAKE_WORD=jarvis +JARVIS_TIMEZONE=Europe/Berlin + +OLLAMA_URL=http://localhost:11434 +OLLAMA_MODEL=auto +OLLAMA_EMBED_MODEL=auto + +# allgemeiner Chat +OLLAMA_NUM_CTX=16384 +OLLAMA_NUM_PREDICT=1200 +OLLAMA_TEMPERATURE=0.15 + +# Native Tool Agent +OLLAMA_AGENT_NUM_CTX=16384 +OLLAMA_AGENT_NUM_PREDICT=700 +OLLAMA_AGENT_TEMPERATURE=0 +JARVIS_AGENT_MAX_STEPS=6 + +# Scout / Kanban strukturierte Calls +OLLAMA_PLANNER_NUM_CTX=8192 +OLLAMA_PLANNER_NUM_PREDICT=700 +OLLAMA_PLANNER_TEMPERATURE=0 + +JARVIS_HISTORY_TOKENS=3200 + +TASK_SCOUT_INTERVAL=2m +HOME_AUTOMATION_INTERVAL=1m +KANBAN_AI_INTERVAL=5m + +JARVIS_DEBUG_TRACE=true +JARVIS_DEBUG_DIR=./data/debug +``` + +Für kleine lokale Modelle ist Temperatur `0` beim Tool-Agent bewusst empfohlen. + +## Start + +```bash +ollama serve +ollama list +``` + +Dann: + +```bash +cp .env.example .env # optional +./scripts/doctor.sh +go run ./cmd/homehub +``` + +Dashboard: + +```text +http://localhost:8080 +``` + +## Empfohlene Tests nach dem Upgrade + +Alten Chat einmal über `CLEAR CHAT` leeren, dann z. B.: + +```text +Trage morgen Zahnarzt um 15:30 Uhr in den Kalender ein. +Welche Termine habe ich morgen? + +Sonntag wären Pommes Currywurst toll, zu Mittag. +Was gibt es Sonntag zu Mittag? + +Plane Montag Nudeln mit Tomatensauce zum Abendessen für vier Personen. +Verschiebe das auf Freitag. + +Füge zwei Liter Milch zur Einkaufsliste hinzu. +Was steht auf der Einkaufsliste? +``` + +Bei einem Fehler: + +1. `CLEAR TRACE` +2. Ablauf einmal reproduzieren +3. `EXPORT DEBUG` +4. JSON hier bereitstellen + +## Tests + +```bash +go test ./... +go vet ./... +go build ./cmd/homehub +node --check internal/ui/static/app.js +``` + +Die Tests enthalten native Tool Calls, einen mehrstufigen `recipes_search -> mealplan_create`-Ablauf, den JSON-Selector-Fallback und die Garantie, dass unbekannte Tools den Store nicht verändern. + +## Zusätzliche UI-Variante: Clean Design + +Die Oberfläche enthält jetzt zusätzlich einen umschaltbaren **Clean-Modus** mit heller, minimaler, Apple-inspirierter Optik. + +- Umschalten direkt im Command-Bereich über `JARVIS | CLEAN` +- Einstellung wird im Browser per `localStorage` gespeichert +- Funktionsumfang bleibt identisch, es ändert sich nur das visuelle Design + +## v7 Clean App – eigene Informationsarchitektur + +Der **CLEAN**-Modus ist jetzt keine reine Farbvariante des HUDs mehr, sondern eine eigene Multi-Screen-App. + +- **Home** – Assistant, nächste Termine, offene Aufgaben, nächstes Essen und Systemstatus +- **Kalender** – 7-Tage-Kalender plus Erinnerungen +- **Aufgaben** – fokussierte Aufgabenansicht mit KPIs und Voice-Trigger +- **Kanban** – eigenständige Vollbild-Workflow-Ansicht +- **Essen & Einkauf** – Essensplanung, Einkaufsliste und Rezepte auf einem Screen +- **Wissen & System** – RAG, Dokumente, Sprachpipeline und Telemetrie + +Desktop verwendet eine permanente linke Sidebar. Unter 850 px wechselt die Navigation in eine mobile Bottom-Bar. Die letzte Clean-Ansicht sowie das ausgewählte Design werden lokal im Browser gespeichert. Der bestehende **JARVIS/HUD**-Modus bleibt vollständig verfügbar. + + +## v7.1 – Kanban AI Reliability Fix + +`AI ORGANIZE` verwendet jetzt ebenfalls Ollamas native Tool-Calling-Schnittstelle statt freien JSON-Text zu parsen. + +Ablauf: + +1. `kanban_recommend` als internes Tool mit festem JSON-Schema +2. Native `message.tool_calls` werden bevorzugt ausgewertet +3. Kompatibilitäts-Retry im JSON-Modus für Modelle ohne zuverlässiges Tool Calling +4. Parser akzeptiert zusätzlich JSON in Markdown-Fences oder eingebettetem Begleittext +5. Bei leerem/abgeschnittenem Modelloutput wird kein HTTP-502/`unexpected end of JSON input` mehr ausgelöst. Die synchronisierten Task-Karten bleiben deterministisch erhalten. + +Der Debug-Trace enthält jetzt `kanban.tool_request`, `kanban.tool_response`, `kanban.json_retry_*` und gegebenenfalls `kanban.deterministic_fallback`. + +## v7.2 – Workflow Planner / Unteraufgaben + +Komplexe Home-Anfragen werden nicht mehr künstlich auf genau einen Tool-Aufruf reduziert. Wenn ein lokales Modell keine nativen Tool-Calls liefert, verwendet JARVIS einen schema-strikten **Workflow Planner**: + +```json +{ + "goal": "Abendessen für sieben Tage planen", + "steps": [ + { + "id": "s1", + "task": "Abendessen für Tag 1 planen", + "tool": "mealplan_create", + "arguments": { + "title": "Gemüsepfanne mit Reis", + "when": {"kind": "relative", "offset_days": 0}, + "meal": "dinner" + } + } + ], + "confidence": 0.96 +} +``` + +- 1–24 Unteraufgaben pro Workflow +- jede Unteraufgabe muss ein registriertes Tool verwenden +- alle Schritte laufen weiterhin durch die Go-Tool-Registry und deren Validatoren +- kreative Planungswünsche (`plane`, `erstelle`, `generiere`) dürfen sinnvolle Vorschläge selbst auswählen +- harte persönliche Fakten (z. B. Allergien) werden nicht erfunden +- `ganze Woche` ohne weitere Präzisierung bedeutet bei Planungswünschen die nächsten sieben Kalendertage +- der API-Response enthält `plan`, das HUD zeigt `PLAN · N SCHRITTE` +- Debug-Trace: `agent.workflow_plan_request`, `agent.workflow_plan_response`, `agent.workflow_plan` + +## v7.3 – Recipe-First Meal Planning & Grocery Pipeline + +Die Essensplanung ist jetzt **recipe-first**: + +- generierte Wochen-/Mehrfachpläne verwenden bevorzugt vorhandene Rezepte aus dem Rezeptbuch +- jeder geplante Recipe-Slot speichert `recipeId`; bei zu wenigen Rezepten dürfen vorhandene Rezepte wiederholt werden statt Zutaten zu erfinden +- bei komplett leerem Rezeptbuch darf der Workflow zunächst vollständige Rezepte mit Zutaten/Schritten erzeugen und sie danach einplanen +- neue Tools: `recipes_list`, `recipes_update`, `groceries_sync_mealplan` +- `groceries_sync_mealplan` aggregiert Mengen über alle verknüpften Rezepte und meldet Mahlzeiten ohne Rezeptzuordnung separat +- automatische Einkaufssynchronisation betrachtet jetzt 14 statt 8 Tage, sodass auch eine kommende Montag–Sonntag-Woche vollständig erfasst wird +- alte freie Mealplan-Titel werden automatisch mit bestehenden Rezepten verknüpft, wenn die Textähnlichkeit eindeutig genug ist +- Rezeptänderungen aktualisieren die abgeleitete Einkaufsliste automatisch +- im UI können Rezepte jetzt über ✎ bearbeitet werden; der Einkauf-Screen hat zusätzlich `↻ AUS PLAN` + +Beispiel: + +```text +Erstelle einen Essensplan für die ganze Woche, nur Abendessen. +→ recipes_list +→ 7 × mealplan_create mit recipe_id +→ automatische Zutatenaggregation + +Was muss ich dafür einkaufen? +→ groceries_sync_mealplan +→ deterministische Einkaufsliste aus den gepflegten Rezeptzutaten +``` + +Zutaten werden dabei niemals frei aus einem Gerichtsnamen erfunden. Ohne Rezeptzuordnung meldet JARVIS die betroffene Mahlzeit als fehlende Rezeptbasis. + + +## v7.4 – Confirmed Actions / Kalender-Löschfix + +Bestätigungsdialoge wie `Soll ich den Termin löschen?` werden jetzt als konkrete serverseitige Pending Tool Action gespeichert. Ein anschließendes `ja` wird nicht erneut vom LLM interpretiert, sondern führt exakt das vorgemerkte Tool mit der zuvor gelesenen Entity-ID aus. + +Zusätzliche Invariante: Wenn ein Schreib-Tool `success:false` liefert, darf freie Modellprosa niemals eine erfolgreiche Änderung behaupten. Die Antwort wird dann deterministisch aus dem Toolfehler erzeugt. + +Der Debug-Export enthält hierfür `runtime.pending_tool_confirmation` sowie Trace-Stufen `agent.pending_tool_set` und `agent.pending_tool_execute`. + + +## Clean UI Polish v7.5 + +Der Clean-Modus besitzt stärkere Textkontraste für Chat-Hervorhebungen, sekundäre Metadaten, Eingaben und Karten. Helle JARVIS-Cyan-Vererbungen werden im Clean-Modus gezielt überschrieben. + +## v7.5.1 – Clean Home Grid Fix + +Die Clean-Home-Seite verwendet jetzt ein bewusstes 12-Spalten-Raster: Begrüßung + nächster Termin = 7/5, Aufgaben + Essen = 6/6 und Local AI = volle Breite. Dadurch entstehen auf großen Displays keine ungenutzten Restspalten mehr. diff --git a/cmd/homehub/agent.go b/cmd/homehub/agent.go new file mode 100644 index 0000000..a10e6b8 --- /dev/null +++ b/cmd/homehub/agent.go @@ -0,0 +1,726 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "homehub/internal/ollama" + "homehub/internal/store" +) + +type agentOutcome struct { + Reply string + Results []toolResult + Automations []automationResult + Mode string + Plan *agentPlan +} + +type selectorOutput struct { + Tool string `json:"tool"` + Name string `json:"name,omitempty"` + Arguments map[string]any `json:"arguments"` + Confidence float64 `json:"confidence"` +} + +type agentPlanStep struct { + ID string `json:"id"` + Parent string `json:"parent,omitempty"` + Task string `json:"task"` + Tool string `json:"tool"` + Arguments map[string]any `json:"arguments"` +} + +type agentPlan struct { + Goal string `json:"goal"` + Steps []agentPlanStep `json:"steps"` + Confidence float64 `json:"confidence"` + Clarification string `json:"clarification,omitempty"` +} + +func (a *app) runToolAgent(ctx context.Context, msg string) (agentOutcome, error) { + registry := a.tools + if registry == nil { + return agentOutcome{}, fmt.Errorf("Tool-Registry nicht initialisiert") + } + if pendingOutcome, ok := a.resolvePendingToolAction(ctx, msg); ok { + return pendingOutcome, nil + } + st := a.db.Snapshot() + history := conversationHistory(st.Chat, msg, a.context.HistoryTokens) + messages := make([]ollama.Turn, 0, len(history)+12) + messages = append(messages, ollama.Turn{Role: "system", Content: a.agentSystemPrompt()}) + messages = append(messages, history...) + if session := a.agentSessionHint(); session != "" { + messages = append(messages, ollama.Turn{Role: "system", Content: session}) + } + messages = append(messages, ollama.Turn{Role: "user", Content: msg}) + + a.trace(ctx, "agent", "request", "input", map[string]any{ + "message": msg, "model": a.ai.Status().Model, "history": history, + "tool_count": len(registry.Definitions()), "tools": toolNames(registry.Definitions()), + "options": a.context.agentOptions(), "session": a.agentSessionHint(), + }) + + out := agentOutcome{Mode: "tool-agent", Results: []toolResult{}, Automations: []automationResult{}} + mutated := false + writeFailed := false + usedPlanner := false + for step := 0; step < a.context.AgentMaxSteps; step++ { + started := time.Now() + turn, err := a.ai.ChatWithTools(ctx, messages, registry.Definitions(), a.context.agentOptions()) + if err != nil { + a.traceErr(ctx, "agent", "model_response", map[string]any{"step": step + 1}, err, started) + return out, err + } + a.traceTimed(ctx, "agent", "model_response", "output", map[string]any{"step": step + 1, "content": turn.Content, "tool_calls": turn.ToolCalls}, started) + if len(turn.ToolCalls) > 0 { + turn.ToolCalls = a.applyRecipeFirstCalls(ctx, msg, turn.ToolCalls) + } + + if len(turn.ToolCalls) == 0 { + // Local models sometimes describe what they would do instead of emitting + // native tool_calls. In that case we ask a schema-constrained workflow + // planner for one OR MANY validated tool steps. This is intentionally a + // plan, not a free-form action parser: every step still executes through + // the same registry and domain validators. + if len(out.Results) == 0 && a.requiresHomeTool(msg) && !usedPlanner { + usedPlanner = true + plan, calls, ok, planErr := a.selectToolPlanJSON(ctx, msg, history) + if planErr != nil { + a.traceErr(ctx, "agent", "workflow_plan", nil, planErr, time.Now()) + } + if ok && len(calls) > 0 { + out.Plan = &plan + out.Mode = "tool-workflow" + turn = ollama.Turn{Role: "assistant", Content: "", ToolCalls: calls} + a.trace(ctx, "agent", "workflow_plan", "output", map[string]any{"plan": plan, "tool_calls": calls}) + } else { + reply := "Ich konnte daraus noch keinen sicheren ausführbaren Plan bilden. Bitte nenne die fehlende Einschränkung etwas genauer." + if plan.Clarification != "" { + reply = plan.Clarification + } else if !looksActionable(msg) { + reply = "Ich konnte die Home-Anfrage nicht sicher einem Tool-Workflow zuordnen. Bitte nenne den Bereich etwas genauer, z. B. Kalender, Aufgaben, Essensplan oder Einkauf." + } + out.Reply = reply + out.Mode = "clarification" + return out, nil + } + } else { + content := strings.TrimSpace(turn.Content) + if mutated { + // Mutation confirmations are generated from persisted tool results, never + // from free-form model prose. + out.Reply = a.toolCommitReply(out.Results) + } else if writeFailed { + // A failed write can never be turned into a success claim by model prose. + out.Reply = a.toolFailureReply(out.Results) + out.Mode = "tool-error" + } else if content != "" { + out.Reply = content + if a.capturePendingToolConfirmation(ctx, content, out.Results) { + out.Mode = "clarification" + } + } else if len(out.Results) > 0 { + out.Reply = a.toolReadReply(out.Results) + } else { + out.Reply = "Ich habe dazu keine belastbare Antwort erhalten." + } + return out, nil + } + } + + messages = append(messages, turn) + for _, call := range turn.ToolCalls { + name := strings.TrimSpace(call.Function.Name) + args := call.Function.Arguments + if args == nil { + args = map[string]any{} + } + a.trace(ctx, "agent", "tool_call", "input", map[string]any{"step": step + 1, "tool": name, "arguments": args}) + toolStarted := time.Now() + res := registry.Execute(ctx, name, args) + a.traceTimed(ctx, "agent", "tool_result", "output", res, toolStarted) + out.Results = append(out.Results, res) + if !res.Success && isMutationTool(name) { + writeFailed = true + } + if res.Mutated && res.Success { + mutated = true + out.Automations = append(out.Automations, automationResult{Type: name, ID: entityID(res), Message: toolResultMessage(res)}) + a.rememberToolEntity(res) + // Domain consequences (event -> reminder, meal plan -> groceries, + // task -> kanban) happen synchronously so a following tool can read them. + a.runHomeAutomations(traceBackground(ctx), "tool:"+name) + } + payload, _ := json.Marshal(res) + messages = append(messages, ollama.Turn{Role: "tool", Content: string(payload)}) + } + } + + if mutated { + out.Reply = a.toolCommitReply(out.Results) + return out, nil + } + out.Reply = "Die Anfrage hat zu viele Tool-Schritte benötigt. Ich habe keine weitere Änderung ausgeführt." + out.Mode = "tool-limit" + return out, nil +} + +func (a *app) agentSystemPrompt() string { + return `Du bist JARVIS, der lokale Agent eines Home-Dashboards. Du hast ausschließlich über die bereitgestellten Tools Zugriff auf Kalender, Aufgaben, Erinnerungen, Einkauf, Rezepte, Essensplan, Kanban und Dokumentwissen. + +REGELN: +1. Für JEDE Aussage über den aktuellen Home-State zuerst ein passendes Lese-Tool benutzen. Home-State niemals aus Gesprächserinnerung erfinden. +2. Für JEDE gewünschte Änderung ein Schreib-Tool benutzen. Behaupte niemals eine Änderung ohne success=true aus einem Tool-Resultat. +3. Bei update/delete und unklarer ID zuerst das passende list/search Tool verwenden, danach die ID aus dessen Ergebnis. +4. Relative Zeiten NICHT selbst in Kalenderdaten umrechnen. Übergib sie symbolisch im when-Objekt: + - heute => {"kind":"relative","offset_days":0} + - morgen => {"kind":"relative","offset_days":1} + - übermorgen => {"kind":"relative","offset_days":2} + - Montag => {"kind":"weekday","weekday":"monday"} + - nächsten/kommenden Montag => {"kind":"weekday","weekday":"monday","force_next":true} + - konkretes Datum => {"kind":"absolute","date":"YYYY-MM-DD"} + Eine genannte Uhrzeit kommt zusätzlich als "time":"HH:MM". Wenn keine Uhrzeit genannt wurde, erfinde keine. +5. Termin/Meeting/Arzt/Zahnarzt gehört in calendar_*, nicht tasks_*. +6. "Erinnere mich" gehört in reminders_*. +7. Mahlzeiten: Frühstück=breakfast, Mittag= lunch, Abendessen=dinner, Snack=snack. +8. Für generierte Essenspläne gilt RECIPE-FIRST: Lies zuerst mit recipes_list das Rezeptbuch. Plane bevorzugt und standardmäßig vorhandene Rezepte und verwende deren recipe_id in mealplan_create. Bei zu wenigen Rezepten darfst du vorhandene Rezepte wiederholen. Freie erfundene Gerichtstitel nur, wenn das Rezeptbuch leer ist oder der Nutzer ausdrücklich ein nicht vorhandenes Gericht verlangt. +9. Wenn ein konkretes bekanntes Rezept gemeint ist, nutze recipes_search/recipes_get und verwende dessen recipe ID. Zum Bearbeiten eines Rezepts nutze recipes_update; bei unbekannter ID zuerst recipes_search oder recipes_list. +10. Für "was muss ich für den Essensplan einkaufen?", "Einkaufsliste aus Essensplan" o. ä. nutze groceries_sync_mealplan. Dieses Tool aggregiert ausschließlich gepflegte Rezeptzutaten; erfinde keine Zutaten. Danach kannst du groceries_list verwenden, wenn nötig. +11. Wenn ein Tool einen Fehler oder mehrere Kandidaten liefert, korrigiere die Arguments oder frage den Nutzer knapp nach der fehlenden Information. Keine freie Vermutung. +12. RAG/Dokumentwissen ausschließlich über rag_search. Quellen nur nennen, wenn sie im Tool-Ergebnis vorkommen. +13. Eine Nutzeranfrage kann MEHRERE Aufgaben enthalten. Zerlege komplexe Ziele selbstständig in Unteraufgaben und führe mehrere Tool-Aufrufe aus, wenn das Ziel dies erfordert. Beispiel: "Essensplan für die ganze Woche, nur Abendessen" bedeutet mehrere mealplan_create-Aufrufe, typischerweise einen pro Tag. +14. Wenn der Nutzer ausdrücklich "plane", "erstelle", "generiere", "mach mir" o. ä. sagt und Inhalte nicht einzeln vorgibt, ist die Auswahl sinnvoller Vorschläge an dich delegiert. Frage NICHT nach jedem einzelnen Inhalt. Erfinde jedoch keine harten persönlichen Fakten wie Allergien oder Personenzahl. +15. "ganze Woche" ohne weitere Präzisierung bedeutet für Planungsaufgaben die nächsten 7 Kalendertage ab heute. Verwende dafür symbolische relative Zeiten offset_days 0..6. "nächste Woche" bedeutet Montag bis Sonntag der kommenden Kalenderwoche; dafür darfst du system_get_time verwenden und anschließend Wochentage symbolisch planen. +16. Nutze so wenige Tool-Aufrufe wie nötig, aber so viele wie fachlich erforderlich. Nach erfolgreicher Ausführung antworte kurz auf Deutsch. Die tatsächliche Commit-Bestätigung kann vom Server deterministisch überschrieben werden.` +} + +func (a *app) agentSessionHint() string { + a.recentMu.Lock() + defer a.recentMu.Unlock() + if a.recent == nil || time.Since(a.recent.At) > 15*time.Minute { + return "" + } + return fmt.Sprintf("SESSION-REFERENZ: Das zuletzt erfolgreich geänderte Objekt war kind=%s id=%s. Bei Bezügen wie 'das', 'es', 'den Termin', 'stattdessen' oder einer direkten Korrektur darfst du diese ID verwenden, sofern der Bereich passt.", a.recent.Kind, a.recent.ID) +} + +func (a *app) rememberToolEntity(res toolResult) { + if res.Entity == nil || res.Entity.ID == "" || res.Entity.Kind == "" { + return + } + a.recentMu.Lock() + a.recent = &recentMutation{Kind: res.Entity.Kind, ID: res.Entity.ID, At: time.Now()} + a.recentMu.Unlock() +} + +func entityID(res toolResult) string { + if res.Entity != nil { + return res.Entity.ID + } + return "" +} + +func toolResultMessage(res toolResult) string { + if !res.Success { + if res.Error != nil { + return res.Error.Message + } + return "Tool fehlgeschlagen" + } + if res.Mutated { + return "Home-State über " + res.Tool + " geändert" + } + return "Tool " + res.Tool + " ausgeführt" +} + +func toolNames(tools []ollama.Tool) []string { + out := make([]string, 0, len(tools)) + for _, t := range tools { + out = append(out, t.Function.Name) + } + return out +} + +func (a *app) requiresHomeTool(msg string) bool { + x := foldCommand(stripModuleContext(msg)) + for _, k := range []string{ + "kalender", "termin", "meeting", "zahnarzt", "arzt", "aufgabe", "todo", "to-do", "erinner", "einkauf", "einkaufsliste", + "rezept", "essensplan", "abendessen", "mittagessen", "fruehstueck", "frühstück", "zu mittag", "zum mittag", "zu abend", "zum abend", + "kanban", "dokument", "datei", "was gibt es", "was gibts", "wann gibt es", "verschieb", "loesch", "entfern", "trag", "fuege", "plane", "erstelle", + } { + if strings.Contains(x, foldCommand(k)) { + return true + } + } + // A terse correction immediately after a mutation belongs to the tool session. + a.recentMu.Lock() + recent := a.recent != nil && time.Since(a.recent.At) <= 15*time.Minute + a.recentMu.Unlock() + return recent && (isTerseControlResponse(msg) || strings.Contains(x, "stattdessen") || strings.Contains(x, "doch ") || strings.HasPrefix(x, "nein")) +} + +func (a *app) selectToolPlanJSON(ctx context.Context, msg string, history []ollama.Turn) (agentPlan, []ollama.ToolCall, bool, error) { + defs := a.tools.Definitions() + catalog, _ := json.Marshal(defs) + st := a.db.Snapshot() + planningContext := a.compactPlanningContext(st) + system := `Du bist der JSON-Workflow-Planner von JARVIS. Zerlege die Nutzeranfrage in 1 bis 24 konkrete ausführbare Unteraufgaben. Jede ausführbare Unteraufgabe muss exakt ein registriertes Tool verwenden. + +Antworte ausschließlich als JSON: +{"goal":"kurzes Ziel","steps":[{"id":"s1","parent":"optional","task":"kurze Beschreibung","tool":"exakter_tool_name","arguments":{...}}],"confidence":0.0,"clarification":"optional"} + +REGELN: +- Mehrere unabhängige Aktionen als mehrere steps ausgeben. NICHT künstlich auf ein Tool reduzieren. +- Komplexe Planungswünsche selbst in Unteraufgaben zerlegen. Bei "Essensplan für die ganze Woche, nur Abendessen" typischerweise 7 mealplan_create-Schritte mit meal="dinner" und when.relative offset_days 0..6. +- Wenn der Nutzer mit "plane/erstelle/generiere/mach mir" die kreative Auswahl delegiert, wähle vernünftige abwechslungsreiche Inhalte selbst. Nicht unnötig nach einzelnen Vorschlägen fragen. +- RECIPE-FIRST: Bei generierten Wochen-/Mehrfach-Essensplänen MUSS jedes mealplan_create nach Möglichkeit ein vorhandenes Rezept aus PLANUNGS-KONTEXT über recipe_id verwenden. Wenn weniger Rezepte als Slots existieren, vorhandene Rezepte wiederholen. Erfinde keine freien Gerichte, solange mindestens ein Rezept vorhanden ist. +- Ist das Rezeptbuch komplett leer und der Nutzer delegiert kreativ einen Essensplan, darfst du zuerst recipes_create-Schritte mit vollständigen Zutaten/Schritten planen und danach passende mealplan_create-Schritte. Die späteren mealplan_create-Schritte dürfen denselben Rezepttitel ohne recipe_id verwenden; der Server verknüpft ihn mit dem zuvor erzeugten Rezept. +- Wenn der Nutzer nach einer Einkaufsliste für den Essensplan fragt, plane groceries_sync_mealplan; Zutaten werden niemals frei erfunden. +- Rezeptänderungen laufen über recipes_update; bei unbekannter ID zuerst recipes_search/list. +- Relative Zeiten bleiben symbolisch im when-Schema; keine Datumsarithmetik. +- Update/Delete mit unbekannter ID zuerst als list/search-Schritt planen. Wenn ein Folgeschritt zwingend eine erst zur Laufzeit unbekannte ID benötigt, plane nur den Lese-Schritt; der native Agent kann danach fortsetzen. +- Keine Tools außerhalb des Katalogs. Maximal 24 steps. +- Nur bei wirklich fehlenden harten Angaben, die nicht sinnvoll delegiert wurden, clarification setzen und steps leer lassen.` + prompt := fmt.Sprintf("TOOL-KATALOG:\n%s\n\nPLANUNGS-KONTEXT:\n%s\n\nSESSION:\n%s\n\nNUTZER:\n%s", string(catalog), planningContext, a.agentSessionHint(), msg) + a.trace(ctx, "agent", "workflow_plan_request", "input", map[string]any{"message": msg, "catalog_tools": toolNames(defs), "history": history, "planning_context": planningContext, "prompt_estimated_tokens": estimateTokens(prompt)}) + started := time.Now() + raw, err := a.ai.ChatWithHistory(ctx, system, history, prompt, true) + if err != nil { + return agentPlan{}, nil, false, err + } + a.traceTimed(ctx, "agent", "workflow_plan_response", "output", map[string]any{"raw": raw}, started) + plan, err := parseAgentPlan(raw) + if err != nil { + return agentPlan{}, nil, false, err + } + if plan.Confidence > 0 && plan.Confidence < .60 { + return plan, nil, false, nil + } + a.applyRecipeFirstPlan(ctx, msg, &plan) + if len(plan.Steps) == 0 { + return plan, nil, false, nil + } + if len(plan.Steps) > 24 { + plan.Steps = plan.Steps[:24] + } + calls := make([]ollama.ToolCall, 0, len(plan.Steps)) + validSteps := make([]agentPlanStep, 0, len(plan.Steps)) + for i, step := range plan.Steps { + step.Tool = strings.TrimSpace(step.Tool) + if step.ID == "" { + step.ID = fmt.Sprintf("s%d", i+1) + } + if step.Task == "" { + step.Task = step.Tool + } + if step.Arguments == nil { + step.Arguments = map[string]any{} + } + if step.Tool == "" || !a.tools.Has(step.Tool) { + continue + } + validSteps = append(validSteps, step) + calls = append(calls, ollama.ToolCall{Function: ollama.ToolFunctionCall{Name: step.Tool, Arguments: step.Arguments}}) + } + plan.Steps = validSteps + if len(calls) == 0 { + return plan, nil, false, nil + } + return plan, calls, true, nil +} + +func parseAgentPlan(raw string) (agentPlan, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return agentPlan{}, fmt.Errorf("leerer Workflow-Plan") + } + if i := strings.Index(raw, "```json"); i >= 0 { + raw = raw[i+7:] + if j := strings.Index(raw, "```"); j >= 0 { + raw = raw[:j] + } + } else if i := strings.Index(raw, "```"); i >= 0 { + raw = raw[i+3:] + if j := strings.Index(raw, "```"); j >= 0 { + raw = raw[:j] + } + } + raw = strings.TrimSpace(raw) + var plan agentPlan + if err := json.Unmarshal([]byte(raw), &plan); err == nil && (len(plan.Steps) > 0 || plan.Clarification != "") { + return plan, nil + } + // Backward-compatible single-tool selector response. + var one selectorOutput + if err := json.Unmarshal([]byte(raw), &one); err == nil { + if one.Tool == "" { + one.Tool = one.Name + } + if strings.TrimSpace(one.Tool) != "" { + if one.Arguments == nil { + one.Arguments = map[string]any{} + } + return agentPlan{Goal: one.Tool, Confidence: one.Confidence, Steps: []agentPlanStep{{ID: "s1", Task: one.Tool, Tool: one.Tool, Arguments: one.Arguments}}}, nil + } + } + // Tolerate common model shape {"tasks":[...]} / {"subtasks":[...]}. + var loose map[string]any + if err := json.Unmarshal([]byte(raw), &loose); err != nil { + return agentPlan{}, err + } + for _, key := range []string{"tasks", "subtasks", "actions"} { + arr, ok := loose[key].([]any) + if !ok || len(arr) == 0 { + continue + } + plan.Goal, _ = loose["goal"].(string) + if c, ok := loose["confidence"].(float64); ok { + plan.Confidence = c + } + for i, item := range arr { + m, ok := item.(map[string]any) + if !ok { + continue + } + tool, _ := m["tool"].(string) + if tool == "" { + tool, _ = m["name"].(string) + } + args, _ := m["arguments"].(map[string]any) + task, _ := m["task"].(string) + if task == "" { + task, _ = m["title"].(string) + } + plan.Steps = append(plan.Steps, agentPlanStep{ID: fmt.Sprintf("s%d", i+1), Task: task, Tool: tool, Arguments: args}) + } + if len(plan.Steps) > 0 { + return plan, nil + } + } + return agentPlan{}, fmt.Errorf("kein ausführbarer Workflow-Plan") +} + +func (a *app) compactPlanningContext(st store.State) string { + var b strings.Builder + b.WriteString("AKTUELLE ZEIT: ") + b.WriteString(time.Now().In(a.timezone).Format(time.RFC3339)) + b.WriteString("\nVORHANDENE REZEPTE:\n") + for i, r := range st.Recipes { + if i >= 20 { + break + } + ingNames := make([]string, 0, len(r.Ingredients)) + for j, ing := range r.Ingredients { + if j >= 5 { + break + } + if strings.TrimSpace(ing.Name) != "" { + ingNames = append(ingNames, ing.Name) + } + } + fmt.Fprintf(&b, "- id=%s | %s | %d Portionen | Zutaten: %s\n", r.ID, r.Title, r.Servings, strings.Join(ingNames, ", ")) + } + b.WriteString("ESSENSPLAN (NÄCHSTE EINTRÄGE):\n") + for i, m := range st.MealPlans { + if i >= 20 { + break + } + fmt.Fprintf(&b, "- id=%s | %s | %s | %s\n", m.ID, m.Date, m.Meal, m.Title) + } + b.WriteString("OFFENE AUFGABEN:\n") + count := 0 + for _, t := range st.Tasks { + if t.Status == "done" { + continue + } + fmt.Fprintf(&b, "- id=%s | %s | %s\n", t.ID, t.Title, t.Priority) + count++ + if count >= 15 { + break + } + } + return b.String() +} + +func isDelegatedWeeklyMealPlan(msg string) bool { + x := foldCommand(stripModuleContext(msg)) + if !strings.Contains(x, "essensplan") && !strings.Contains(x, "wochenplan") { + return false + } + return strings.Contains(x, "woche") || strings.Contains(x, "7 tage") || strings.Contains(x, "sieben tage") +} + +func (a *app) recipeForPlanTitle(title string, recipes []store.Recipe, fallbackIndex int) (store.Recipe, bool) { + if len(recipes) == 0 { + return store.Recipe{}, false + } + best, bestScore := -1, 0.0 + for i, r := range recipes { + if sc := textSimilarity(title, r.Title); sc > bestScore { + best, bestScore = i, sc + } + } + if best >= 0 && bestScore >= .34 { + return recipes[best], true + } + return recipes[fallbackIndex%len(recipes)], true +} + +func (a *app) applyRecipeFirstPlan(ctx context.Context, msg string, plan *agentPlan) { + if plan == nil || !isDelegatedWeeklyMealPlan(msg) { + return + } + recipes := a.db.Snapshot().Recipes + if len(recipes) == 0 { + return + } + before, _ := json.Marshal(plan.Steps) + filtered := make([]agentPlanStep, 0, len(plan.Steps)) + mealIndex := 0 + for _, step := range plan.Steps { + // When a recipe book exists, a delegated week plan must use it instead of + // inventing parallel recipes that have no relation to the user's library. + if step.Tool == "recipes_create" { + continue + } + if step.Tool == "mealplan_create" { + if step.Arguments == nil { + step.Arguments = map[string]any{} + } + rid := getString(step.Arguments, "recipe_id") + var rec store.Recipe + found := false + if rid != "" { + for _, r := range recipes { + if r.ID == rid { + rec, found = r, true + break + } + } + } + if !found { + rec, found = a.recipeForPlanTitle(getString(step.Arguments, "title"), recipes, mealIndex) + } + if found { + step.Arguments["recipe_id"] = rec.ID + step.Arguments["title"] = rec.Title + } + mealIndex++ + } + filtered = append(filtered, step) + } + plan.Steps = filtered + after, _ := json.Marshal(plan.Steps) + if string(before) != string(after) { + a.trace(ctx, "agent", "recipe_first_plan", "output", map[string]any{"message": msg, "before": json.RawMessage(before), "after": json.RawMessage(after), "recipes": len(recipes)}) + } +} + +func (a *app) applyRecipeFirstCalls(ctx context.Context, msg string, calls []ollama.ToolCall) []ollama.ToolCall { + if !isDelegatedWeeklyMealPlan(msg) { + return calls + } + recipes := a.db.Snapshot().Recipes + if len(recipes) == 0 { + return calls + } + out := make([]ollama.ToolCall, 0, len(calls)) + mealIndex := 0 + changed := false + for _, call := range calls { + if call.Function.Name == "recipes_create" { + changed = true + continue + } + if call.Function.Name == "mealplan_create" { + args := call.Function.Arguments + if args == nil { + args = map[string]any{} + } + rid := getString(args, "recipe_id") + var rec store.Recipe + found := false + if rid != "" { + for _, r := range recipes { + if r.ID == rid { + rec, found = r, true + break + } + } + } + if !found { + rec, found = a.recipeForPlanTitle(getString(args, "title"), recipes, mealIndex) + } + if found { + if getString(args, "recipe_id") != rec.ID || getString(args, "title") != rec.Title { + changed = true + } + args["recipe_id"] = rec.ID + args["title"] = rec.Title + call.Function.Arguments = args + } + mealIndex++ + } + out = append(out, call) + } + if changed { + a.trace(ctx, "agent", "recipe_first_native", "output", map[string]any{"message": msg, "tool_calls": out, "recipes": len(recipes)}) + } + return out +} + +func (a *app) toolCommitReply(results []toolResult) string { + mealPlans := []store.MealPlan{} + for _, res := range results { + if res.Success && res.Mutated && (res.Tool == "mealplan_create" || res.Tool == "mealplan_update") { + if v, ok := decodeToolData[store.MealPlan](res.Data); ok { + mealPlans = append(mealPlans, v) + } + } + } + if len(mealPlans) >= 3 { + sort.Slice(mealPlans, func(i, j int) bool { return mealPlans[i].Date < mealPlans[j].Date }) + mealLabel := "Mahlzeiten" + allDinner := true + for _, m := range mealPlans { + if normalizeMeal(m.Meal) != "dinner" { + allDinner = false + break + } + } + if allDinner { + mealLabel = "Abendessen" + } + lines := []string{fmt.Sprintf("Essensplan: %d %s geplant.", len(mealPlans), mealLabel)} + for _, m := range mealPlans { + dateText := m.Date + if d, err := time.ParseInLocation("2006-01-02", m.Date, a.timezone); err == nil { + dateText = fmt.Sprintf("%s, %02d.%02d.", germanWeekday(d.Weekday()), d.Day(), int(d.Month())) + } + lines = append(lines, fmt.Sprintf("• %s · %s", dateText, m.Title)) + } + return strings.Join(lines, "\n") + } + lines := []string{} + for _, res := range results { + if !res.Success || !res.Mutated { + continue + } + switch res.Tool { + case "calendar_create", "calendar_update": + if v, ok := decodeToolData[store.CalendarEvent](res.Data); ok { + lines = append(lines, fmt.Sprintf("Kalender: „%s“ · %s.", v.Title, a.formatDateTime(v.Start, v.AllDay))) + } + case "calendar_delete": + lines = append(lines, "Kalendertermin gelöscht.") + case "tasks_create", "tasks_update": + if v, ok := decodeToolData[store.Task](res.Data); ok { + lines = append(lines, "Aufgabe gespeichert: „"+v.Title+"“.") + } + case "tasks_delete": + lines = append(lines, "Aufgabe gelöscht.") + case "reminders_create": + if v, ok := decodeToolData[store.Reminder](res.Data); ok { + lines = append(lines, fmt.Sprintf("Erinnerung: „%s“ · %s.", v.Title, a.formatDateTime(v.At, false))) + } + case "reminders_delete": + lines = append(lines, "Erinnerung gelöscht.") + case "groceries_add", "groceries_update": + if v, ok := decodeToolData[store.GroceryItem](res.Data); ok { + lines = append(lines, "Einkauf gespeichert: „"+v.Name+"“.") + } + case "groceries_remove": + lines = append(lines, "Einkaufsartikel entfernt.") + case "recipes_create", "recipes_update": + if v, ok := decodeToolData[store.Recipe](res.Data); ok { + verb := "gespeichert" + if res.Tool == "recipes_update" { + verb = "aktualisiert" + } + lines = append(lines, "Rezept "+verb+": „"+v.Title+"“.") + } + case "groceries_sync_mealplan": + if m, ok := res.Data.(map[string]any); ok { + items, _ := m["items"].([]store.GroceryItem) + if items == nil { + b, _ := json.Marshal(m["items"]) + _ = json.Unmarshal(b, &items) + } + if len(items) == 0 { + lines = append(lines, "Für die aktuell mit Rezepten verknüpften Mahlzeiten sind keine Einkaufspositionen offen.") + } else { + parts := []string{"Einkauf aus Essensplan:"} + for _, g := range items { + parts = append(parts, "• "+strings.TrimSpace(strings.Join([]string{g.Quantity, g.Unit, g.Name}, " "))) + } + lines = append(lines, strings.Join(parts, "\n")) + } + var missing []map[string]any + b, _ := json.Marshal(m["missing_recipe_meals"]) + _ = json.Unmarshal(b, &missing) + if len(missing) > 0 { + names := []string{} + for _, x := range missing { + if t, _ := x["title"].(string); t != "" { + names = append(names, t) + } + } + lines = append(lines, "Noch ohne gepflegtes Rezept und daher ohne Zutaten: "+strings.Join(names, ", ")+".") + } + } + case "mealplan_create", "mealplan_update": + if v, ok := decodeToolData[store.MealPlan](res.Data); ok { + dateText := v.Date + if d, err := time.ParseInLocation("2006-01-02", v.Date, a.timezone); err == nil { + dateText = fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(d.Weekday()), d.Day(), int(d.Month()), d.Year()) + } + meal := map[string]string{"breakfast": "Frühstück", "lunch": "Mittagessen", "dinner": "Abendessen", "snack": "Snack"}[normalizeMeal(v.Meal)] + lines = append(lines, fmt.Sprintf("Essensplan: „%s“ · %s · %s.", v.Title, dateText, meal)) + } + case "mealplan_delete": + lines = append(lines, "Essensplan-Eintrag gelöscht.") + case "kanban_create", "kanban_move": + if v, ok := decodeToolData[store.KanbanCard](res.Data); ok { + lines = append(lines, fmt.Sprintf("Kanban: „%s“ → %s.", v.Title, strings.ToUpper(v.Column))) + } + case "kanban_delete": + lines = append(lines, "Kanban-Karte gelöscht.") + } + } + if len(lines) == 0 { + return "Die angeforderte Änderung wurde über das Tool-System ausgeführt." + } + return strings.Join(lines, "\n") +} + +func (a *app) toolReadReply(results []toolResult) string { + if len(results) == 0 { + return "Keine Tool-Ergebnisse vorhanden." + } + last := results[len(results)-1] + if !last.Success { + if last.Error != nil { + return last.Error.Message + } + return "Tool-Anfrage fehlgeschlagen." + } + b, _ := json.Marshal(last.Data) + return "Tool-Ergebnis: " + string(b) +} + +func decodeToolData[T any](v any) (T, bool) { + var out T + b, err := json.Marshal(v) + if err != nil { + return out, false + } + if err := json.Unmarshal(b, &out); err != nil { + return out, false + } + return out, true +} diff --git a/cmd/homehub/command_control.go b/cmd/homehub/command_control.go new file mode 100644 index 0000000..80410da --- /dev/null +++ b/cmd/homehub/command_control.go @@ -0,0 +1,1256 @@ +package main + +import ( + "context" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "homehub/internal/store" +) + +type pendingItem struct { + ID string + Label string +} + +type pendingCommand struct { + Kind string + Items []pendingItem + ExpiresAt time.Time +} + +var ( + clockColonRE = regexp.MustCompile(`(?i)\b([01]?\d|2[0-3]):([0-5]\d)\b`) + clockUhrRE = regexp.MustCompile(`(?i)\b([01]?\d|2[0-3])(?:[.:]([0-5]\d))?\s*uhr\b`) + germanDateRE = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})(?:\.(\d{2,4}))?\b`) + isoDateRE = regexp.MustCompile(`\b(20\d{2})-(\d{2})-(\d{2})\b`) +) + +func stripModuleContext(v string) string { + s := strings.TrimSpace(v) + lower := strings.ToLower(s) + if strings.HasPrefix(lower, "im kontext des moduls ") { + if i := strings.Index(s, ":"); i >= 0 { + return strings.TrimSpace(s[i+1:]) + } + } + return s +} + +func foldCommand(v string) string { + s := strings.ToLower(strings.TrimSpace(v)) + r := strings.NewReplacer("ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss") + return r.Replace(s) +} + +func isDeleteCommand(v string) bool { + x := foldCommand(stripModuleContext(v)) + return strings.Contains(x, "loesch") || strings.Contains(x, "entfern") || strings.Contains(x, "delete") +} + +func isTerseControlResponse(v string) bool { + x := strings.TrimSpace(foldCommand(stripModuleContext(v))) + if x == "" { + return true + } + if n, err := strconv.Atoi(x); err == nil && n >= 0 && n <= 99 { + return true + } + switch x { + case "ja", "nein", "ok", "okay", "beide", "alle", "abbrechen", "abbruch": + return true + } + return false +} + +func (a *app) tryHandleControlCommand(msg string) (string, []automationResult, string, bool) { + raw := stripModuleContext(msg) + if reply, results, mode, ok := a.resolvePendingCommand(raw); ok { + return reply, results, mode, true + } + if reply, results, mode, ok := a.tryHandleRecentCorrection(raw); ok { + return reply, results, mode, true + } + if !isDeleteCommand(raw) { + if reply, ok := a.tryHandleStateQuery(raw); ok { + return reply, nil, "state", true + } + return "", nil, "", false + } + + x := foldCommand(raw) + switch { + case containsFoldedAny(x, "aufgabe", "aufgaben", "task", "tasks", "todo", "to-do"): + return a.handleTaskDeleteCommand(raw) + case containsFoldedAny(x, "termin", "termine", "kalender", "event", "events"): + return a.handleEventDeleteCommand(raw) + default: + return "Ich kann den Löschbefehl noch nicht eindeutig einem Bereich zuordnen. Nenne bitte z. B. „Termin“ oder „Aufgabe“.", nil, "clarification", true + } +} + +func containsFoldedAny(s string, words ...string) bool { + for _, w := range words { + if strings.Contains(s, foldCommand(w)) { + return true + } + } + return false +} + +func asksForAll(v string) bool { + x := foldCommand(v) + return strings.Contains(x, " alle ") || strings.HasPrefix(x, "alle ") || strings.HasSuffix(x, " alle") || x == "alle" || strings.Contains(x, "saemtlich") || strings.Contains(x, "beide") +} + +func (a *app) tryHandleStateQuery(raw string) (string, bool) { + x := foldCommand(raw) + if containsFoldedAny(x, "anleg", "eintrag", "trage", "trag ", "plane", "planen", "erstelle", "fuege", "füge", "erinnere mich", "verschieb", "aendere", "ändere") { + return "", false + } + question := containsFoldedAny(x, "welche", "welcher", "zeige", "was steht", "was habe", "habe ich", "wann", "naechste", "nächste", "uebersicht", "übersicht", "wie viele") || strings.HasSuffix(strings.TrimSpace(raw), "?") + if !question { + return "", false + } + st := a.db.Snapshot() + if containsFoldedAny(x, "termin", "termine", "kalender", "meeting", "event") { + events := upcomingEvents(st.Events, a.timezone) + now := time.Now().In(a.timezone) + if d, ok := explicitOrRelativeDate(raw, now); ok { + filtered := events[:0] + for _, e := range events { + et, err := parseEventTime(e, a.timezone) + if err == nil && et.Year() == d.Year() && et.YearDay() == d.YearDay() { + filtered = append(filtered, e) + } + } + events = append([]store.CalendarEvent(nil), filtered...) + } + // Bei einer konkreten Titelfrage nur den besten Treffer zeigen. + best, bestScore := -1, 0.0 + for i, e := range events { + if sc := textSimilarity(raw, e.Title); sc > bestScore { + best, bestScore = i, sc + } + } + if best >= 0 && bestScore >= .34 && !containsFoldedAny(x, "welche termine", "termine habe", "kalender uebersicht", "kalenderübersicht", "naechste termine", "nächste termine") { + events = []store.CalendarEvent{events[best]} + } + if len(events) == 0 { + return "Im passenden Kalender-Zeitraum sind keine Termine eingetragen.", true + } + var b strings.Builder + if len(events) == 1 { + b.WriteString("Kalender: ") + } else { + fmt.Fprintf(&b, "Kalender · %d Termine:\n", len(events)) + } + for i, e := range events { + if len(events) > 1 { + fmt.Fprintf(&b, "%d. ", i+1) + } + fmt.Fprintf(&b, "%s · %s", e.Title, a.formatDateTime(e.Start, e.AllDay)) + if e.Location != "" { + fmt.Fprintf(&b, " · %s", e.Location) + } + if i < len(events)-1 { + b.WriteByte('\n') + } + } + return b.String(), true + } + if containsFoldedAny(x, "aufgabe", "aufgaben", "task", "tasks", "todo", "to-do") { + open := make([]store.Task, 0, len(st.Tasks)) + for _, t := range st.Tasks { + if t.Status != "done" { + open = append(open, t) + } + } + if strings.Contains(x, "wie viele") { + return fmt.Sprintf("Es sind %d offene Aufgabe(n) vorhanden.", len(open)), true + } + if len(open) == 0 { + return "Die Aufgabenliste ist leer.", true + } + var b strings.Builder + fmt.Fprintf(&b, "Offene Aufgaben · %d:\n", len(open)) + for i, t := range open { + fmt.Fprintf(&b, "%d. %s", i+1, t.Title) + if t.Due != "" { + fmt.Fprintf(&b, " · fällig %s", a.formatDateTime(t.Due, false)) + } + if i < len(open)-1 { + b.WriteByte('\n') + } + } + return b.String(), true + } + if containsFoldedAny(x, "erinnerung", "erinnerungen", "reminder") { + open := make([]store.Reminder, 0, len(st.Reminders)) + for _, r := range st.Reminders { + if !r.Done { + open = append(open, r) + } + } + if len(open) == 0 { + return "Es sind keine offenen Erinnerungen vorhanden.", true + } + sort.Slice(open, func(i, j int) bool { return open[i].At < open[j].At }) + var b strings.Builder + fmt.Fprintf(&b, "Offene Erinnerungen · %d:\n", len(open)) + for i, r := range open { + fmt.Fprintf(&b, "%d. %s · %s", i+1, r.Title, a.formatDateTime(r.At, false)) + if i < len(open)-1 { + b.WriteByte('\n') + } + } + return b.String(), true + } + if containsFoldedAny(x, "essensplan", "mahlzeit", "abendessen", "mittagessen", "fruehstueck", "frühstück", "zu mittag", "zum mittag", "zu abend", "zum abend") { + plans := append([]store.MealPlan(nil), st.MealPlans...) + now := time.Now().In(a.timezone) + if d, ok := explicitOrRelativeDate(raw, now); ok { + filtered := plans[:0] + for _, m := range plans { + if m.Date == d.Format("2006-01-02") { + filtered = append(filtered, m) + } + } + plans = append([]store.MealPlan(nil), filtered...) + } + if meal, ok := explicitMealFromMessage(raw); ok { + filtered := plans[:0] + for _, m := range plans { + if normalizeMeal(m.Meal) == meal { + filtered = append(filtered, m) + } + } + plans = append([]store.MealPlan(nil), filtered...) + } + if len(plans) == 0 { + return "Im passenden Essensplan-Zeitraum ist nichts eingetragen.", true + } + sort.Slice(plans, func(i, j int) bool { + if plans[i].Date == plans[j].Date { + return normalizeMeal(plans[i].Meal) < normalizeMeal(plans[j].Meal) + } + return plans[i].Date < plans[j].Date + }) + var b strings.Builder + fmt.Fprintf(&b, "Essensplan · %d Eintrag/Einträge:\n", len(plans)) + for i, m := range plans { + dateText := m.Date + if d, err := time.ParseInLocation("2006-01-02", m.Date, a.timezone); err == nil { + dateText = fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(d.Weekday()), d.Day(), int(d.Month()), d.Year()) + } + mealText := map[string]string{"breakfast": "Frühstück", "lunch": "Mittagessen", "dinner": "Abendessen", "snack": "Snack"}[normalizeMeal(m.Meal)] + if mealText == "" { + mealText = "Mahlzeit" + } + fmt.Fprintf(&b, "%d. %s · %s · %s", i+1, m.Title, dateText, mealText) + if i < len(plans)-1 { + b.WriteByte('\n') + } + } + return b.String(), true + } + if containsFoldedAny(x, "einkauf", "einkaufsliste") { + open := make([]store.GroceryItem, 0, len(st.Groceries)) + for _, g := range st.Groceries { + if !g.Checked { + open = append(open, g) + } + } + if len(open) == 0 { + return "Die Einkaufsliste ist leer.", true + } + var b strings.Builder + fmt.Fprintf(&b, "Einkaufsliste · %d Position(en):\n", len(open)) + for i, g := range open { + fmt.Fprintf(&b, "%d. %s", i+1, g.Name) + if strings.TrimSpace(g.Quantity+g.Unit) != "" { + fmt.Fprintf(&b, " · %s %s", g.Quantity, g.Unit) + } + if i < len(open)-1 { + b.WriteByte('\n') + } + } + return b.String(), true + } + if containsFoldedAny(x, "rezept", "rezepte") { + if len(st.Recipes) == 0 { + return "Es sind keine Rezepte gespeichert.", true + } + var b strings.Builder + fmt.Fprintf(&b, "Rezepte · %d:\n", len(st.Recipes)) + for i, r := range st.Recipes { + fmt.Fprintf(&b, "%d. %s", i+1, r.Title) + if r.PrepMinutes > 0 { + fmt.Fprintf(&b, " · %d min", r.PrepMinutes) + } + if i < len(st.Recipes)-1 { + b.WriteByte('\n') + } + } + return b.String(), true + } + return "", false +} + +func (a *app) handleTaskDeleteCommand(raw string) (string, []automationResult, string, bool) { + st := a.db.Snapshot() + if asksForAll(" " + raw + " ") { + ids := make(map[string]bool, len(st.Tasks)) + for _, t := range st.Tasks { + ids[t.ID] = true + } + n := len(ids) + _ = a.db.Update(func(s *store.State) error { + s.Tasks = []store.Task{} + kept := s.Kanban[:0] + for _, c := range s.Kanban { + if c.TaskID != "" && ids[c.TaskID] { + continue + } + kept = append(kept, c) + } + s.Kanban = append([]store.KanbanCard(nil), kept...) + return nil + }) + a.clearPending() + if n == 0 { + return "Die Aufgabenliste ist bereits leer. Kalendertermine und Erinnerungen wurden nicht verändert.", nil, "action", true + } + a.activity("automation", fmt.Sprintf("%d Aufgabe(n) gelöscht; Kalender unverändert", n)) + return fmt.Sprintf("%d Aufgabe(n) gelöscht. Zugehörige Task-Karten wurden entfernt; Kalendertermine und Kalender-Erinnerungen bleiben unverändert.", n), []automationResult{{Type: "delete_tasks", Message: fmt.Sprintf("%d Aufgaben gelöscht", n)}}, "action", true + } + + matches := matchTasks(st.Tasks, raw) + if len(matches) == 0 { + return "Ich finde keine passende Aufgabe. Nenne den Aufgabentitel genauer oder sage „lösche alle Aufgaben“.", nil, "clarification", true + } + if len(matches) > 1 { + items := make([]pendingItem, 0, len(matches)) + var b strings.Builder + b.WriteString("Mehrere Aufgaben passen dazu. Welche soll ich löschen?\n") + for i, t := range matches { + items = append(items, pendingItem{ID: t.ID, Label: t.Title}) + fmt.Fprintf(&b, "%d. %s", i+1, t.Title) + if t.Due != "" { + fmt.Fprintf(&b, " · %s", a.formatDateTime(t.Due, false)) + } + b.WriteByte('\n') + } + a.setPending("delete_task", items) + b.WriteString("Antworte mit der Nummer, dem Namen, „alle“ oder „abbrechen“.") + return strings.TrimSpace(b.String()), nil, "clarification", true + } + res := a.deleteTaskByID(matches[0].ID) + return res.Message + ". Kalendertermine wurden nicht verändert.", []automationResult{res}, "action", true +} + +func (a *app) handleEventDeleteCommand(raw string) (string, []automationResult, string, bool) { + st := a.db.Snapshot() + future := upcomingEvents(st.Events, a.timezone) + if asksForAll(" " + raw + " ") { + targets := future + if strings.Contains(foldCommand(raw), "alle") && len(targets) == 0 { + targets = st.Events + } + if len(targets) == 0 { + return "Es gibt keine passenden Kalendertermine zum Löschen.", nil, "action", true + } + results := make([]automationResult, 0, len(targets)) + for _, e := range targets { + results = append(results, a.deleteEventByID(e.ID)) + } + a.clearPending() + return fmt.Sprintf("%d Kalendertermin(e) gelöscht. Verknüpfte automatische Kalender-Erinnerungen wurden ebenfalls entfernt; Aufgaben bleiben unverändert.", len(results)), results, "action", true + } + + matches := matchEvents(future, raw) + if len(matches) == 0 && genericEventDelete(raw) { + matches = future + } + if len(matches) == 0 { + return "Ich finde keinen passenden zukünftigen Kalendertermin. Nenne den Titel genauer.", nil, "clarification", true + } + if len(matches) > 1 { + items := make([]pendingItem, 0, len(matches)) + var b strings.Builder + b.WriteString("Welchen Kalendertermin soll ich löschen?\n") + for i, e := range matches { + items = append(items, pendingItem{ID: e.ID, Label: e.Title}) + fmt.Fprintf(&b, "%d. %s · %s\n", i+1, e.Title, a.formatDateTime(e.Start, e.AllDay)) + } + a.setPending("delete_event", items) + b.WriteString("Antworte mit der Nummer, dem Namen, „alle“ oder „abbrechen“.") + return strings.TrimSpace(b.String()), nil, "clarification", true + } + res := a.deleteEventByID(matches[0].ID) + return res.Message + ". Die zugehörige automatische Kalender-Erinnerung wurde entfernt; Aufgaben bleiben unverändert.", []automationResult{res}, "action", true +} + +func genericEventDelete(raw string) bool { + x := foldCommand(raw) + for _, filler := range []string{"loesche", "loesch", "entferne", "entfern", "bitte", "den", "die", "das", "einen", "termin", "kalender", "kalendereintrag", "event", "aus", "meinem"} { + x = strings.ReplaceAll(x, filler, " ") + } + return len(strings.Fields(x)) == 0 +} + +func (a *app) setPending(kind string, items []pendingItem) { + a.pendingMu.Lock() + defer a.pendingMu.Unlock() + a.pending = &pendingCommand{Kind: kind, Items: items, ExpiresAt: time.Now().Add(3 * time.Minute)} +} +func (a *app) clearPending() { a.pendingMu.Lock(); a.pending = nil; a.pendingMu.Unlock() } + +func (a *app) resolvePendingCommand(raw string) (string, []automationResult, string, bool) { + a.pendingMu.Lock() + p := a.pending + if p == nil || time.Now().After(p.ExpiresAt) { + a.pending = nil + a.pendingMu.Unlock() + return "", nil, "", false + } + // Snapshot while still locked, then release before mutating the DB. + cp := *p + cp.Items = append([]pendingItem(nil), p.Items...) + a.pendingMu.Unlock() + + x := foldCommand(strings.TrimSpace(raw)) + if x == "abbrechen" || x == "abbruch" || x == "nein" { + a.clearPending() + return "Okay, die Löschaktion wurde abgebrochen.", nil, "action", true + } + + chosen := []pendingItem{} + if asksForAll(" " + x + " ") { + chosen = cp.Items + } else if n, err := strconv.Atoi(strings.TrimSpace(x)); err == nil && n >= 1 && n <= len(cp.Items) { + chosen = []pendingItem{cp.Items[n-1]} + } else { + for _, it := range cp.Items { + if textSimilarity(raw, it.Label) >= .45 || strings.Contains(foldCommand(raw), foldCommand(it.Label)) { + chosen = append(chosen, it) + } + } + } + if len(chosen) == 0 { + return "Die Auswahl ist noch nicht eindeutig. Antworte mit einer Nummer, dem Namen, „alle“ oder „abbrechen“.", nil, "clarification", true + } + + a.clearPending() + results := make([]automationResult, 0, len(chosen)) + for _, it := range chosen { + if cp.Kind == "delete_event" { + results = append(results, a.deleteEventByID(it.ID)) + } else { + results = append(results, a.deleteTaskByID(it.ID)) + } + } + if cp.Kind == "delete_event" { + return fmt.Sprintf("%d Kalendertermin(e) gelöscht. Aufgaben bleiben unverändert.", len(results)), results, "action", true + } + return fmt.Sprintf("%d Aufgabe(n) gelöscht. Kalendertermine bleiben unverändert.", len(results)), results, "action", true +} + +func (a *app) deleteEventByID(idv string) automationResult { + title := idv + _ = a.db.Update(func(s *store.State) error { + for _, e := range s.Events { + if e.ID == idv { + title = e.Title + break + } + } + s.Events = filterEvents(s.Events, idv) + s.Reminders = filterLinkedReminders(s.Reminders, "event", idv) + return nil + }) + a.activity("calendar", "Termin gelöscht: "+title) + return automationResult{Type: "delete_event", ID: idv, Message: "Termin gelöscht: " + title} +} +func (a *app) deleteTaskByID(idv string) automationResult { + title := idv + _ = a.db.Update(func(s *store.State) error { + for _, t := range s.Tasks { + if t.ID == idv { + title = t.Title + break + } + } + s.Tasks = filterTasksByID(s.Tasks, idv) + kept := s.Kanban[:0] + for _, c := range s.Kanban { + if c.TaskID != idv { + kept = append(kept, c) + } + } + s.Kanban = append([]store.KanbanCard(nil), kept...) + return nil + }) + a.activity("task", "Aufgabe gelöscht: "+title) + return automationResult{Type: "delete_task", ID: idv, Message: "Aufgabe gelöscht: " + title} +} + +func filterTasksByID(xs []store.Task, idv string) []store.Task { + out := xs[:0] + for _, x := range xs { + if x.ID != idv { + out = append(out, x) + } + } + return append([]store.Task(nil), out...) +} + +func upcomingEvents(xs []store.CalendarEvent, loc *time.Location) []store.CalendarEvent { + if loc == nil { + loc = time.Local + } + cutoff := time.Now().In(loc).Add(-15 * time.Minute) + out := make([]store.CalendarEvent, 0, len(xs)) + for _, e := range xs { + t, err := parseEventTime(e, loc) + if err == nil && !t.Before(cutoff) { + out = append(out, e) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Start < out[j].Start }) + return out +} + +func matchEvents(xs []store.CalendarEvent, raw string) []store.CalendarEvent { + q := removeCommandWords(raw, []string{"lösche", "loesche", "lösch", "loesch", "entferne", "entfern", "bitte", "den", "die", "das", "einen", "termin", "termine", "kalender", "kalendereintrag", "event", "events"}) + if strings.TrimSpace(q) == "" { + return nil + } + out := []store.CalendarEvent{} + for _, e := range xs { + if textSimilarity(q, e.Title) >= .34 || strings.Contains(foldCommand(e.Title), foldCommand(q)) || strings.Contains(foldCommand(q), foldCommand(e.Title)) { + out = append(out, e) + } + } + return out +} +func matchTasks(xs []store.Task, raw string) []store.Task { + q := removeCommandWords(raw, []string{"lösche", "loesche", "lösch", "loesch", "entferne", "entfern", "bitte", "den", "die", "das", "eine", "einen", "aufgabe", "aufgaben", "task", "tasks", "todo", "to-do"}) + if strings.TrimSpace(q) == "" { + return xs + } + out := []store.Task{} + for _, t := range xs { + if textSimilarity(q, t.Title) >= .34 || strings.Contains(foldCommand(t.Title), foldCommand(q)) || strings.Contains(foldCommand(q), foldCommand(t.Title)) { + out = append(out, t) + } + } + return out +} +func removeCommandWords(raw string, words []string) string { + x := foldCommand(raw) + for _, w := range words { + x = strings.ReplaceAll(x, foldCommand(w), " ") + } + return strings.Join(strings.Fields(x), " ") +} + +func tokenSet(v string) map[string]bool { + f := foldCommand(v) + replacer := strings.NewReplacer("-", " ", "_", " ", "/", " ", ".", " ", ",", " ", ":", " ", ";", " ", "(", " ", ")", " ", "\"", " ", "'", " ") + f = replacer.Replace(f) + out := map[string]bool{} + stop := map[string]bool{"der": true, "die": true, "das": true, "den": true, "dem": true, "ein": true, "eine": true, "einen": true, "und": true, "am": true, "um": true, "im": true, "in": true, "fuer": true, "für": true, "termin": true, "kalendereintrag": true, "wann": true, "ist": true, "mein": true, "meine": true, "welche": true, "welcher": true, "zeige": true, "was": true, "steht": true, "habe": true, "ich": true, "naechste": true, "nächste": true} + for _, w := range strings.Fields(f) { + if stop[w] || len(w) < 3 { + continue + } + w = simpleStem(w) + out[w] = true + } + return out +} +func simpleStem(w string) string { + for _, s := range []string{"ern", "en", "er", "es", "e", "n", "s"} { + if len(w) > 5 && strings.HasSuffix(w, s) { + return strings.TrimSuffix(w, s) + } + } + return w +} +func textSimilarity(a, b string) float64 { + aa, bb := tokenSet(a), tokenSet(b) + if len(aa) == 0 || len(bb) == 0 { + if foldCommand(a) == foldCommand(b) { + return 1 + } + return 0 + } + inter := 0 + for k := range aa { + if bb[k] { + inter++ + } + } + den := len(aa) + len(bb) - inter + if den == 0 { + return 0 + } + return float64(inter) / float64(den) +} + +func (a *app) correctTemporalFromMessage(ac commandAction, msg string) commandAction { + return a.correctTemporalFromMessageAt(ac, msg, time.Now().In(a.timezone)) +} + +func (a *app) correctTemporalFromMessageAt(ac commandAction, msg string, now time.Time) commandAction { + raw := stripModuleContext(msg) + if a.timezone != nil { + now = now.In(a.timezone) + } + targetDate, hasDate := explicitOrRelativeDate(raw, now) + hour, minute, hasClock := explicitClock(raw) + apply := func(v string) string { + var base time.Time + if t, err := time.Parse(time.RFC3339, v); err == nil { + base = t.In(a.timezone) + } else { + base = now + } + if hasDate { + base = time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), base.Hour(), base.Minute(), 0, 0, a.timezone) + } + if hasClock { + base = time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, a.timezone) + } + if hasDate || hasClock { + return base.Format(time.RFC3339) + } + return v + } + switch ac.Type { + case "add_event": + if ac.Start != "" && (hasDate || hasClock) { + ac.Start = apply(ac.Start) + } else if ac.Start == "" && hasDate { + base := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 0, 0, 0, 0, a.timezone) + if hasClock { + base = time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, a.timezone) + } + ac.Start = base.Format(time.RFC3339) + } + if hasClock { + ac.AllDay = false + } else if hasDate && ac.Start != "" { + ac.AllDay = true + } + case "add_reminder": + if ac.At != "" && (hasDate || hasClock) { + ac.At = apply(ac.At) + } else if ac.At == "" && hasDate && hasClock { + ac.At = time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), hour, minute, 0, 0, a.timezone).Format(time.RFC3339) + } + case "add_task": + if ac.Due != "" && (hasDate || hasClock) { + ac.Due = apply(ac.Due) + } + case "add_meal_plan": + // Essensplanung muss dieselbe deterministische Datumslogik wie der + // Kalender verwenden. Ein kleines Modell darf "Montag" nicht als + // Sonntag/Montag falsch ausrechnen und ungeprüft persistieren. + if hasDate { + ac.Date = targetDate.Format("2006-01-02") + } + if meal, ok := explicitMealFromMessage(raw); ok { + ac.Meal = meal + } + } + return ac +} + +func explicitMealFromMessage(raw string) (string, bool) { + x := foldCommand(raw) + switch { + case strings.Contains(x, "fruehstueck") || strings.Contains(x, "fruehstück") || strings.Contains(x, "breakfast"): + return "breakfast", true + case strings.Contains(x, "mittagessen") || strings.Contains(x, "zum mittag") || strings.Contains(x, "zu mittag") || strings.Contains(x, "lunch"): + return "lunch", true + case strings.Contains(x, "abendessen") || strings.Contains(x, "zum abend") || strings.Contains(x, "zu abend") || strings.Contains(x, "dinner"): + return "dinner", true + case strings.Contains(x, "snack"): + return "snack", true + } + return "", false +} + +func explicitClock(raw string) (int, int, bool) { + if m := clockColonRE.FindStringSubmatch(raw); len(m) > 0 { + h, _ := strconv.Atoi(m[1]) + min, _ := strconv.Atoi(m[2]) + return h, min, true + } + if m := clockUhrRE.FindStringSubmatch(raw); len(m) > 0 { + h, _ := strconv.Atoi(m[1]) + min := 0 + if m[2] != "" { + min, _ = strconv.Atoi(m[2]) + } + return h, min, true + } + return 0, 0, false +} +func explicitOrRelativeDate(raw string, now time.Time) (time.Time, bool) { + x := foldCommand(raw) + + // Explizite Kalenderdaten haben höchste Priorität. Damit kann ein Modell oder + // ein längerer Satz mit relativen Begriffen ein klar genanntes Datum nicht + // versehentlich überschreiben. + if m := isoDateRE.FindStringSubmatch(raw); len(m) > 0 { + y, _ := strconv.Atoi(m[1]) + mo, _ := strconv.Atoi(m[2]) + d, _ := strconv.Atoi(m[3]) + return time.Date(y, time.Month(mo), d, 0, 0, 0, 0, now.Location()), true + } + if m := germanDateRE.FindStringSubmatch(raw); len(m) > 0 { + d, _ := strconv.Atoi(m[1]) + mo, _ := strconv.Atoi(m[2]) + y := now.Year() + if m[3] != "" { + y, _ = strconv.Atoi(m[3]) + if y < 100 { + y += 2000 + } + } + if mo >= 1 && mo <= 12 && d >= 1 && d <= 31 { + t := time.Date(y, time.Month(mo), d, 0, 0, 0, 0, now.Location()) + if t.Day() == d && int(t.Month()) == mo { + return t, true + } + } + } + + if strings.Contains(x, "uebermorgen") { + return now.AddDate(0, 0, 2), true + } + if strings.Contains(x, "morgen") { + return now.AddDate(0, 0, 1), true + } + if strings.Contains(x, "heute") { + return now, true + } + + // Wochentage werden absichtlich serverseitig aufgelöst. Das LLM darf den + // Kalendertag nicht mehr ausrechnen. Aus Do. 27.08.2026 + "Montag" wird so + // garantiert Mo. 31.08.2026 und niemals Di. 01.09.2026. + weekdays := []struct { + name string + day time.Weekday + }{ + {"sonntag", time.Sunday}, {"montag", time.Monday}, {"dienstag", time.Tuesday}, + {"mittwoch", time.Wednesday}, {"donnerstag", time.Thursday}, {"freitag", time.Friday}, + {"samstag", time.Saturday}, + } + for _, wd := range weekdays { + if !strings.Contains(x, wd.name) { + continue + } + delta := (int(wd.day) - int(now.Weekday()) + 7) % 7 + forceNext := strings.Contains(x, "naechsten "+wd.name) || strings.Contains(x, "kommenden "+wd.name) + if delta == 0 && forceNext { + delta = 7 + } + return now.AddDate(0, 0, delta), true + } + return time.Time{}, false +} + +func parseEventTime(e store.CalendarEvent, loc *time.Location) (time.Time, error) { + if loc == nil { + loc = time.Local + } + if t, err := time.Parse(time.RFC3339, e.Start); err == nil { + return t.In(loc), nil + } + return time.ParseInLocation("2006-01-02", e.Start, loc) +} + +func (a *app) formatDateTime(v string, allDay bool) string { + if t, err := time.Parse(time.RFC3339, v); err == nil { + t = t.In(a.timezone) + if allDay { + return fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(t.Weekday()), t.Day(), int(t.Month()), t.Year()) + } + return fmt.Sprintf("%s, %02d.%02d.%04d um %02d:%02d Uhr", germanWeekday(t.Weekday()), t.Day(), int(t.Month()), t.Year(), t.Hour(), t.Minute()) + } + if t, err := time.ParseInLocation("2006-01-02", v, a.timezone); err == nil { + return fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(t.Weekday()), t.Day(), int(t.Month()), t.Year()) + } + return v +} +func germanWeekday(w time.Weekday) string { + return []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}[int(w)] +} + +func (a *app) deterministicActionReply(results []automationResult) string { + if len(results) == 0 { + return "" + } + st := a.db.Snapshot() + lines := make([]string, 0, len(results)) + for _, r := range results { + switch r.Type { + case "add_event", "event_exists": + for _, e := range st.Events { + if e.ID == r.ID { + prefix := "Kalendertermin eingetragen" + if r.Type == "event_exists" { + prefix = "Kalendertermin bereits vorhanden" + } + line := fmt.Sprintf("%s: „%s“ · %s", prefix, e.Title, a.formatDateTime(e.Start, e.AllDay)) + for _, rem := range st.Reminders { + if rem.LinkedType == "event" && rem.LinkedID == e.ID && !rem.Done { + line += fmt.Sprintf(". Automatische Erinnerung: %s", a.formatDateTime(rem.At, false)) + break + } + } + lines = append(lines, line) + break + } + } + case "add_task", "task_exists": + for _, t := range st.Tasks { + if t.ID == r.ID { + p := "Aufgabe angelegt" + if r.Type == "task_exists" { + p = "Aufgabe bereits vorhanden" + } + line := p + ": „" + t.Title + "“" + if t.Due != "" { + line += " · fällig " + a.formatDateTime(t.Due, false) + } + lines = append(lines, line) + break + } + } + case "add_reminder", "reminder_exists": + for _, v := range st.Reminders { + if v.ID == r.ID { + p := "Erinnerung angelegt" + if r.Type == "reminder_exists" { + p = "Erinnerung bereits vorhanden" + } + lines = append(lines, fmt.Sprintf("%s: „%s“ · %s", p, v.Title, a.formatDateTime(v.At, false))) + break + } + } + case "add_meal_plan", "meal_updated": + for _, v := range st.MealPlans { + if v.ID == r.ID { + p := "Essensplan eingetragen" + if r.Type == "meal_updated" { + p = "Essensplan aktualisiert" + } + meal := map[string]string{"breakfast": "Frühstück", "lunch": "Mittagessen", "dinner": "Abendessen", "snack": "Snack"}[normalizeMeal(v.Meal)] + if meal == "" { + meal = "Mahlzeit" + } + date := v.Date + if d, err := time.ParseInLocation("2006-01-02", v.Date, a.timezone); err == nil { + date = fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(d.Weekday()), d.Day(), int(d.Month()), d.Year()) + } + lines = append(lines, fmt.Sprintf("%s: „%s“ · %s · %s · %d Portionen", p, v.Title, date, meal, v.Servings)) + break + } + } + default: + lines = append(lines, r.Message) + } + } + if len(lines) == 1 { + return lines[0] + "." + } + return "Ausgeführt:\n- " + strings.Join(lines, "\n- ") +} + +func eventEquivalent(ae, be store.CalendarEvent, loc *time.Location) bool { + if textSimilarity(ae.Title, be.Title) < .75 { + return false + } + at, ea := parseEventTime(ae, loc) + bt, eb := parseEventTime(be, loc) + if ea != nil || eb != nil { + return ae.Start == be.Start + } + d := at.Sub(bt) + if d < 0 { + d = -d + } + return d <= 5*time.Minute +} + +func candidateLooksCalendarDerived(c scoutTask, st store.State) bool { + src := foldCommand(c.Source) + if strings.Contains(src, "kalender") || strings.Contains(src, "calendar") || strings.Contains(src, "reminder") || strings.Contains(src, "erinnerung") { + return true + } + for _, e := range st.Events { + sim := textSimilarity(c.Title, e.Title) + if sim >= .42 { + return true + } + if c.Due != "" { + ct, ce := time.Parse(time.RFC3339, c.Due) + et, ee := time.Parse(time.RFC3339, e.Start) + if ce == nil && ee == nil { + d := ct.Sub(et) + if d < 0 { + d = -d + } + if d < 90*time.Minute && sim >= .25 { + return true + } + } + } + } + for _, r := range st.Reminders { + if r.LinkedType == "event" && textSimilarity(c.Title, r.Title) >= .35 { + return true + } + } + return false +} + +func (a *app) repairLegacyCalendarTasks() int { + removed := 0 + _ = a.db.Update(func(s *store.State) error { + removedIDs := map[string]bool{} + kept := s.Tasks[:0] + for _, t := range s.Tasks { + if t.AIManaged && strings.HasPrefix(t.ID, "ai_task") && candidateLooksCalendarDerived(scoutTask{Title: t.Title, Due: t.Due, Source: t.Source}, *s) { + removedIDs[t.ID] = true + removed++ + continue + } + kept = append(kept, t) + } + s.Tasks = append([]store.Task(nil), kept...) + if len(removedIDs) > 0 { + cards := s.Kanban[:0] + for _, c := range s.Kanban { + if c.TaskID != "" && removedIDs[c.TaskID] { + continue + } + cards = append(cards, c) + } + s.Kanban = append([]store.KanbanCard(nil), cards...) + } + return nil + }) + if removed > 0 { + a.activity("repair", fmt.Sprintf("Legacy-Reparatur: %d kalenderabgeleitete KI-Aufgabe(n) entfernt", removed)) + } + return removed +} + +var ( + eventThemeRE = regexp.MustCompile(`(?i)\b(?:mit\s+dem\s+thema|thema|betreff)\s*[:\-]?\s*(.+)$`) + eventWithRE = regexp.MustCompile(`(?i)\bmit\s+(.+)$`) + eventWordRE = regexp.MustCompile(`(?i)\btermin\b|\bkalendereintrag\b|\bevent\b`) + weekdayTextRE = regexp.MustCompile(`(?i)\b(?:am|fuer|für|diesen|diese[nm]?|naechsten|nächsten|kommenden)?\s*(?:montag|dienstag|mittwoch|donnerstag|freitag|samstag|sonntag)\b`) + relativeTextRE = regexp.MustCompile(`(?i)\b(?:heute|morgen|uebermorgen|übermorgen)\b`) +) + +// deterministicCalendarFallback hält elementare Kalenderbefehle auch dann +// funktionsfähig, wenn Ollama gerade kein Chat-Modell bereitstellt. Die KI wird +// weiterhin bevorzugt; dieser Parser greift nur als Sicherheitsnetz. +func (a *app) deterministicCalendarFallback(msg string) (commandAction, bool) { + if actionIntentHint(msg) != "event" { + return commandAction{}, false + } + raw := stripModuleContext(msg) + now := time.Now().In(a.timezone) + day, hasDate := explicitOrRelativeDate(raw, now) + if !hasDate { + return commandAction{}, false + } + h, m, hasClock := explicitClock(raw) + title := extractFallbackEventTitle(raw) + if title == "" { + return commandAction{}, false + } + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, a.timezone) + if hasClock { + start = time.Date(day.Year(), day.Month(), day.Day(), h, m, 0, 0, a.timezone) + } + return commandAction{Type: "add_event", Title: title, Start: start.Format(time.RFC3339), AllDay: !hasClock, Confidence: 1}, true +} + +func extractFallbackEventTitle(raw string) string { + s := strings.TrimSpace(raw) + if m := eventThemeRE.FindStringSubmatch(s); len(m) > 1 { + if v := cleanFallbackEventTitle(m[1]); v != "" { + return v + } + } + // "Termin für Montag mit Urlaub" / "Termin mit Max am Montag". + if m := eventWithRE.FindStringSubmatch(s); len(m) > 1 { + if v := cleanFallbackEventTitle(m[1]); v != "" { + return v + } + } + loc := eventWordRE.FindStringIndex(s) + if loc != nil { + before := cleanFallbackEventTitle(s[:loc[0]]) + after := cleanFallbackEventTitle(s[loc[1]:]) + // Bei "Zahnarzt Termin" ist der Teil vor Termin meist der eigentliche Titel; + // bei "Termin Urlaub" dagegen der Teil danach. + if after != "" && !strings.EqualFold(after, "an") { + return after + } + if before != "" { + return before + } + } + return "" +} + +func cleanFallbackEventTitle(v string) string { + s := strings.TrimSpace(v) + s = clockColonRE.ReplaceAllString(s, " ") + s = clockUhrRE.ReplaceAllString(s, " ") + s = germanDateRE.ReplaceAllString(s, " ") + s = isoDateRE.ReplaceAllString(s, " ") + s = weekdayTextRE.ReplaceAllString(s, " ") + s = relativeTextRE.ReplaceAllString(s, " ") + // Nur Steuer-/Füllwörter entfernen; Inhaltswörter bleiben unangetastet. + filler := regexp.MustCompile(`(?i)\b(?:ich|habe|erstell(?:e|en)?|leg(?:e)?|anlegen|trage|trag|ein|bitte|einen|eine|den|die|das|fuer|für|am|um|uhr|kalender|kalendereintrag|termin|event|mit|dem|thema|zum|zur|einem)\b`) + s = filler.ReplaceAllString(s, " ") + s = strings.Trim(s, " \t\r\n,.:;!?-–—()[]{}\"") + s = strings.Join(strings.Fields(s), " ") + if len([]rune(s)) < 2 { + return "" + } + r := []rune(s) + if r[0] >= 'a' && r[0] <= 'z' { + r[0] = r[0] - ('a' - 'A') + } + return string(r) +} + +type recentMutation struct { + Kind string + ID string + At time.Time +} + +func (a *app) rememberRecentMutation(results []automationResult) { + for i := len(results) - 1; i >= 0; i-- { + r := results[i] + kind := "" + switch r.Type { + case "add_event": + kind = "event" + case "add_meal_plan", "meal_updated": + kind = "meal_plan" + case "add_task": + kind = "task" + case "add_reminder": + kind = "reminder" + } + if kind == "" || r.ID == "" { + continue + } + a.recentMu.Lock() + a.recent = &recentMutation{Kind: kind, ID: r.ID, At: time.Now()} + a.recentMu.Unlock() + return + } +} + +func (a *app) clearRecentMutation() { + a.recentMu.Lock() + a.recent = nil + a.recentMu.Unlock() +} + +func correctionCue(raw string) bool { + x := foldCommand(strings.TrimSpace(raw)) + for _, cue := range []string{"nein", "doch", "korrig", "statt", "gemeint", "aendere", "ändere", "verschieb"} { + if strings.Contains(x, foldCommand(cue)) { + return true + } + } + return false +} + +// tryHandleRecentCorrection löst kurze Korrekturen gegen das zuletzt wirklich +// persistierte Objekt auf. Der Chatverlauf hilft dem LLM beim Verstehen; die +// tatsächliche Mutation bleibt aber deterministisch in Go. +func (a *app) tryHandleRecentCorrection(raw string) (string, []automationResult, string, bool) { + if !correctionCue(raw) { + return "", nil, "", false + } + now := time.Now().In(a.timezone) + date, hasDate := explicitOrRelativeDate(raw, now) + hour, minute, hasClock := explicitClock(raw) + meal, hasMeal := explicitMealFromMessage(raw) + if !hasDate && !hasClock && !hasMeal { + return "", nil, "", false + } + + a.recentMu.Lock() + var rm recentMutation + if a.recent != nil { + rm = *a.recent + } + a.recentMu.Unlock() + if rm.ID == "" || time.Since(rm.At) > 10*time.Minute { + return "", nil, "", false + } + + switch rm.Kind { + case "event": + var updated store.CalendarEvent + found := false + err := a.db.Update(func(s *store.State) error { + for i := range s.Events { + if s.Events[i].ID != rm.ID { + continue + } + v := s.Events[i] + base, err := parseEventTime(v, a.timezone) + if err != nil { + return nil + } + if hasDate { + base = time.Date(date.Year(), date.Month(), date.Day(), base.Hour(), base.Minute(), 0, 0, a.timezone) + } + if hasClock { + base = time.Date(base.Year(), base.Month(), base.Day(), hour, minute, 0, 0, a.timezone) + v.AllDay = false + } + v.Start = base.Format(time.RFC3339) + v.UpdatedAt = store.Now() + s.Events[i] = v + updated, found = v, true + break + } + return nil + }) + if err != nil || !found { + return "", nil, "", false + } + a.runHomeAutomations(context.Background(), "conversation-correction") + res := automationResult{Type: "event_updated", ID: updated.ID, Message: "Termin korrigiert: " + updated.Title} + return fmt.Sprintf("Kalendertermin korrigiert: „%s“ · %s.", updated.Title, a.formatDateTime(updated.Start, updated.AllDay)), []automationResult{res}, "action", true + case "meal_plan": + var updated store.MealPlan + found := false + err := a.db.Update(func(s *store.State) error { + for i := range s.MealPlans { + if s.MealPlans[i].ID != rm.ID { + continue + } + v := s.MealPlans[i] + if hasDate { + v.Date = date.Format("2006-01-02") + } + if hasMeal { + v.Meal = meal + } + v.UpdatedAt = store.Now() + s.MealPlans[i] = v + updated, found = v, true + break + } + return nil + }) + if err != nil || !found { + return "", nil, "", false + } + a.runHomeAutomations(context.Background(), "conversation-correction") + dateText := updated.Date + if d, err := time.ParseInLocation("2006-01-02", updated.Date, a.timezone); err == nil { + dateText = fmt.Sprintf("%s, %02d.%02d.%04d", germanWeekday(d.Weekday()), d.Day(), int(d.Month()), d.Year()) + } + res := automationResult{Type: "meal_updated", ID: updated.ID, Message: "Essensplan korrigiert: " + updated.Title} + return fmt.Sprintf("Essensplan korrigiert: „%s“ · %s.", updated.Title, dateText), []automationResult{res}, "action", true + } + return "", nil, "", false +} + +func (a *app) deterministicMealPlanFallback(msg string) (commandAction, bool) { + if actionIntentHint(msg) != "meal_plan" { + return commandAction{}, false + } + raw := stripModuleContext(msg) + day, hasDate := explicitOrRelativeDate(raw, time.Now().In(a.timezone)) + if !hasDate { + return commandAction{}, false + } + meal, hasMeal := explicitMealFromMessage(raw) + if !hasMeal { + meal = "dinner" + } + title := extractFallbackMealTitle(raw) + if title == "" { + return commandAction{}, false + } + // Preserve recipe linkage even when the LLM is unavailable or returns an + // unusable action. This keeps meal-plan -> grocery automation working in the + // deterministic fallback path as well. + recipeID := "" + bestTitle := title + bestScore := 0.0 + for _, r := range a.db.Snapshot().Recipes { + score := textSimilarity(title, r.Title) + if score > bestScore { + bestScore = score + recipeID = r.ID + bestTitle = r.Title + } + } + if bestScore < .50 { + recipeID = "" + bestTitle = title + } + return commandAction{Type: "add_meal_plan", Date: day.Format("2006-01-02"), Meal: meal, RecipeID: recipeID, RecipeTitle: bestTitle, Title: bestTitle, Servings: 2, Confidence: 1}, true +} + +func extractFallbackMealTitle(raw string) string { + s := stripModuleContext(raw) + s = isoDateRE.ReplaceAllString(s, " ") + s = germanDateRE.ReplaceAllString(s, " ") + s = clockColonRE.ReplaceAllString(s, " ") + s = clockUhrRE.ReplaceAllString(s, " ") + s = weekdayTextRE.ReplaceAllString(s, " ") + s = relativeTextRE.ReplaceAllString(s, " ") + lowerReplacements := []string{ + "zum abendessen", "zum mittagessen", "zum frühstück", "zum fruehstueck", + "zu abend", "zu mittag", "zum abend", "zum mittag", + "abendessen", "mittagessen", "frühstück", "fruehstueck", + "in den essensplan", "auf den essensplan", "essensplan", + "bitte", "eintragen", "eintragen.", "trage", "trag", "plane", "planen", "erstelle", "füge", "fuege", + "kannst du", "koenntest du", "könntest du", "ich moechte", "ich möchte", + "waeren", "wären", "waere", "wäre", "toll", "gerne", "gern", "also", + "für", "fuer", "am", "an", "zum", "zu", "essen", + } + for _, r := range lowerReplacements { + re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(r) + `\b`) + s = re.ReplaceAllString(s, " ") + } + s = strings.Trim(strings.Join(strings.Fields(s), " "), " ,;:-.!?") + if s == "" { + return "" + } + // Kurze natürliche Gerichtsnamen behalten; keine künstliche Titel-Logik. + r := []rune(s) + if len(r) > 0 { + r[0] = []rune(strings.ToUpper(string(r[0])))[0] + } + return string(r) +} diff --git a/cmd/homehub/command_control_test.go b/cmd/homehub/command_control_test.go new file mode 100644 index 0000000..fe47371 --- /dev/null +++ b/cmd/homehub/command_control_test.go @@ -0,0 +1,372 @@ +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "homehub/internal/store" +) + +func testApp(t *testing.T) *app { + t.Helper() + db, err := store.New(filepath.Join(t.TempDir(), "store.json")) + if err != nil { + t.Fatal(err) + } + return &app{db: db, timezone: berlin(t)} +} + +func TestTemporalCorrectionUsesRelativeDayAndExactClock(t *testing.T) { + a := testApp(t) + now := time.Now().In(a.timezone) + ac := commandAction{Type: "add_event", Title: "Zahnarzt", Start: now.AddDate(0, 0, 1).Format("2006-01-02T15:00:00-07:00")} + got := a.correctTemporalFromMessage(ac, "Ich habe morgen einen Zahnarzt Termin um 15:30 Uhr") + tm, err := time.Parse(time.RFC3339, got.Start) + if err != nil { + t.Fatal(err) + } + tm = tm.In(a.timezone) + wantDay := now.AddDate(0, 0, 1) + if tm.Year() != wantDay.Year() || tm.YearDay() != wantDay.YearDay() || tm.Hour() != 15 || tm.Minute() != 30 { + t.Fatalf("corrected start=%s, want tomorrow at 15:30", got.Start) + } +} + +func TestDuplicateEventIsIdempotent(t *testing.T) { + a := testApp(t) + start := time.Now().In(a.timezone).Add(24 * time.Hour).Truncate(time.Minute).Format(time.RFC3339) + ac := commandAction{Type: "add_event", Title: "Zahnarzttermin", Start: start, Confidence: .95} + first, ok := a.applyCommandAction(ac) + if !ok || first.Type != "add_event" { + t.Fatalf("first=%+v ok=%v", first, ok) + } + second, ok := a.applyCommandAction(ac) + if !ok || second.Type != "event_exists" { + t.Fatalf("second=%+v ok=%v", second, ok) + } + if got := len(a.db.Snapshot().Events); got != 1 { + t.Fatalf("events=%d want 1", got) + } +} + +func TestDeleteEventConversationBoth(t *testing.T) { + a := testApp(t) + now := time.Now().In(a.timezone) + e1 := store.CalendarEvent{ID: "e1", Title: "Zahnarzt", Start: now.Add(24 * time.Hour).Format(time.RFC3339), CreatedAt: store.Now()} + e2 := store.CalendarEvent{ID: "e2", Title: "Fische füttern", Start: now.Add(48 * time.Hour).Format(time.RFC3339), CreatedAt: store.Now()} + _ = a.db.Update(func(s *store.State) error { + s.Events = []store.CalendarEvent{e1, e2} + s.Tasks = []store.Task{{ID: "t1", Title: "Steuer", Status: "open", Priority: "normal", CreatedAt: store.Now()}} + return nil + }) + + reply, actions, mode, handled := a.tryHandleControlCommand("lösche den termin") + if !handled || mode != "clarification" || len(actions) != 0 || !strings.Contains(reply, "1.") || !strings.Contains(reply, "2.") { + t.Fatalf("unexpected first response: %q %+v %s %v", reply, actions, mode, handled) + } + if len(a.db.Snapshot().Events) != 2 { + t.Fatal("events were deleted before selection") + } + + reply, actions, mode, handled = a.tryHandleControlCommand("beide") + if !handled || mode != "action" || len(actions) != 2 || !strings.Contains(reply, "2 Kalendertermin") { + t.Fatalf("unexpected selection response: %q %+v %s", reply, actions, mode) + } + st := a.db.Snapshot() + if len(st.Events) != 0 { + t.Fatalf("events=%d want 0", len(st.Events)) + } + if len(st.Tasks) != 1 { + t.Fatalf("tasks=%d want 1", len(st.Tasks)) + } +} + +func TestDeleteAllTasksLeavesCalendarAndReminders(t *testing.T) { + a := testApp(t) + now := time.Now().In(a.timezone) + _ = a.db.Update(func(s *store.State) error { + s.Tasks = []store.Task{{ID: "t1", Title: "A", Status: "open", Priority: "normal", CreatedAt: store.Now()}, {ID: "t2", Title: "B", Status: "open", Priority: "normal", CreatedAt: store.Now()}} + s.Events = []store.CalendarEvent{{ID: "e1", Title: "Termin", Start: now.Add(24 * time.Hour).Format(time.RFC3339), CreatedAt: store.Now()}} + s.Reminders = []store.Reminder{{ID: "r1", Title: "Termin: Termin", At: now.Add(23*time.Hour + 30*time.Minute).Format(time.RFC3339), LinkedType: "event", LinkedID: "e1", AIManaged: true, CreatedAt: store.Now()}} + s.Kanban = []store.KanbanCard{{ID: "c1", Title: "A", TaskID: "t1", AIManaged: false, Column: "next", CreatedAt: store.Now()}, {ID: "c2", Title: "Standalone", AIManaged: false, Column: "inbox", CreatedAt: store.Now()}} + return nil + }) + reply, _, mode, handled := a.tryHandleControlCommand("Lösche alle Aufgaben") + if !handled || mode != "action" || !strings.Contains(reply, "Kalendertermine") { + t.Fatalf("reply=%q", reply) + } + st := a.db.Snapshot() + if len(st.Tasks) != 0 { + t.Fatalf("tasks=%d", len(st.Tasks)) + } + if len(st.Events) != 1 || len(st.Reminders) != 1 { + t.Fatalf("event/reminder changed: %+v", st) + } + if len(st.Kanban) != 1 || st.Kanban[0].ID != "c2" { + t.Fatalf("kanban=%+v", st.Kanban) + } +} + +func TestScoutContextIgnoresAssistantAndCalendarCommands(t *testing.T) { + st := store.State{ + Tasks: []store.Task{}, Events: []store.CalendarEvent{}, Reminders: []store.Reminder{}, Groceries: []store.GroceryItem{}, Recipes: []store.Recipe{}, MealPlans: []store.MealPlan{}, Kanban: []store.KanbanCard{}, Documents: []store.Document{}, + Chat: []store.ChatTurn{ + {Role: "assistant", Content: "Du solltest den Zahnarzttermin vorbereiten"}, + {Role: "user", Content: "Ich habe morgen einen Zahnarzt Termin um 15:30 Uhr"}, + {Role: "user", Content: "Ich muss die Steuerunterlagen bis Freitag abgeben"}, + }, + } + ctx := buildScoutContext(st) + if strings.Contains(ctx, "vorbereiten") || strings.Contains(ctx, "Zahnarzt Termin") { + t.Fatalf("calendar/assistant leaked into scout context: %s", ctx) + } + if !strings.Contains(ctx, "Steuerunterlagen") { + t.Fatalf("implicit task missing: %s", ctx) + } +} + +func TestCalendarDerivedScoutCandidateRejected(t *testing.T) { + st := store.State{Events: []store.CalendarEvent{{ID: "e", Title: "Zahnarzttermin", Start: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}}} + c := scoutTask{Title: "Zahnarzttermin vorbereiten", Source: "chat", Confidence: .95} + if !candidateLooksCalendarDerived(c, st) { + t.Fatal("calendar-derived task should be rejected") + } +} + +func TestEventReminderIsCreatedAndUpdated(t *testing.T) { + a := testApp(t) + start := time.Now().In(a.timezone).Add(24 * time.Hour).Truncate(time.Minute) + _ = a.db.Update(func(s *store.State) error { + s.Events = []store.CalendarEvent{{ID: "e1", Title: "Zahnarzt", Start: start.Format(time.RFC3339), CreatedAt: store.Now()}} + return nil + }) + if n := a.syncEventReminders(); n != 1 { + t.Fatalf("created=%d", n) + } + st := a.db.Snapshot() + if len(st.Reminders) != 1 { + t.Fatalf("reminders=%d", len(st.Reminders)) + } + want := start.Add(-30 * time.Minute).Format(time.RFC3339) + if st.Reminders[0].At != want { + t.Fatalf("at=%s want=%s", st.Reminders[0].At, want) + } + + start2 := start.Add(time.Hour) + _ = a.db.Update(func(s *store.State) error { s.Events[0].Start = start2.Format(time.RFC3339); return nil }) + if n := a.syncEventReminders(); n != 1 { + t.Fatalf("updated=%d", n) + } + st = a.db.Snapshot() + if len(st.Reminders) != 1 { + t.Fatalf("reminders=%d", len(st.Reminders)) + } + want = start2.Add(-30 * time.Minute).Format(time.RFC3339) + if st.Reminders[0].At != want { + t.Fatalf("updated at=%s want=%s", st.Reminders[0].At, want) + } +} + +func TestSyncTaskCardsPrunesOrphanAIOnly(t *testing.T) { + a := testApp(t) + _ = a.db.Update(func(s *store.State) error { + s.Kanban = []store.KanbanCard{{ID: "ai", Title: "Ghost", TaskID: "gone", AIManaged: true, Column: "next", CreatedAt: store.Now()}, {ID: "manual", Title: "Keep", TaskID: "gone2", AIManaged: false, Column: "next", CreatedAt: store.Now()}} + return nil + }) + if n := a.syncTaskCards(); n != 1 { + t.Fatalf("changed=%d want 1", n) + } + st := a.db.Snapshot() + if len(st.Kanban) != 1 || st.Kanban[0].ID != "manual" { + t.Fatalf("kanban=%+v", st.Kanban) + } +} + +func TestCalendarStateQueryIsDeterministic(t *testing.T) { + a := testApp(t) + now := time.Now().In(a.timezone) + _ = a.db.Update(func(s *store.State) error { + s.Events = []store.CalendarEvent{{ID: "e1", Title: "Zahnarzttermin", Start: now.Add(24 * time.Hour).Format(time.RFC3339), CreatedAt: store.Now()}} + return nil + }) + reply, actions, mode, handled := a.tryHandleControlCommand("Wann ist mein Zahnarzttermin?") + if !handled || mode != "state" || len(actions) != 0 || !strings.Contains(reply, "Zahnarzttermin") { + t.Fatalf("reply=%q actions=%+v mode=%s handled=%v", reply, actions, mode, handled) + } +} + +func TestMutatingQuestionIsNotInterceptedAsStateQuery(t *testing.T) { + a := testApp(t) + _, _, _, handled := a.tryHandleControlCommand("Kannst du morgen um 15 Uhr einen Termin anlegen?") + if handled { + t.Fatal("mutating question must continue to the action planner") + } +} + +func TestScoutContextEmptyWithoutEligibleMaterial(t *testing.T) { + st := store.State{Chat: []store.ChatTurn{{Role: "assistant", Content: "Vorschlag"}, {Role: "user", Content: "Trage morgen einen Termin ein"}}} + if got := buildScoutContext(st); got != "" { + t.Fatalf("expected empty scout context, got %q", got) + } +} + +func TestWeekdayResolutionFromThursday(t *testing.T) { + loc := berlin(t) + now := time.Date(2026, 8, 27, 21, 30, 0, 0, loc) // Donnerstag + got, ok := explicitOrRelativeDate("erstelle einen termin für montag mit Urlaub", now) + if !ok { + t.Fatal("weekday not recognized") + } + want := time.Date(2026, 8, 31, 0, 0, 0, 0, loc) + if got.Year() != want.Year() || got.YearDay() != want.YearDay() { + t.Fatalf("got %s want %s", got, want) + } +} + +func TestNextSameWeekdayMeansNextWeekWhenExplicitlyNext(t *testing.T) { + loc := berlin(t) + now := time.Date(2026, 8, 31, 10, 0, 0, 0, loc) // Montag + got, ok := explicitOrRelativeDate("nächsten Montag", now) + if !ok { + t.Fatal("weekday not recognized") + } + want := time.Date(2026, 9, 7, 0, 0, 0, 0, loc) + if got.Year() != want.Year() || got.YearDay() != want.YearDay() { + t.Fatalf("got %s want %s", got, want) + } +} + +func TestFallbackEventTitleAndDate(t *testing.T) { + a := testApp(t) + title := extractFallbackEventTitle("erstelle einen termin für montag mit Urlaub") + if title != "Urlaub" { + t.Fatalf("title=%q", title) + } + title = extractFallbackEventTitle("Ich habe morgen einen Zahnarzt termin um 15:30 Uhr") + if title != "Zahnarzt" { + t.Fatalf("title=%q", title) + } + _ = a +} + +func TestMealPlanWeekdayAndMealAreCorrectedDeterministically(t *testing.T) { + a := testApp(t) + now := time.Date(2026, 8, 27, 21, 55, 0, 0, a.timezone) // Donnerstag + badModelOutput := commandAction{Type: "add_meal_plan", Title: "Nudeln", RecipeTitle: "Nudeln", Date: "2026-08-30", Meal: "lunch", Confidence: .95} + got := a.correctTemporalFromMessageAt(badModelOutput, "montag bitte nudeln zum abendessen eintragen", now) + if got.Date != "2026-08-31" { + t.Fatalf("date=%s want 2026-08-31", got.Date) + } + if got.Meal != "dinner" { + t.Fatalf("meal=%s want dinner", got.Meal) + } +} + +func TestRecentMealCorrectionTargetsLastCommittedObject(t *testing.T) { + a := testApp(t) + m := store.MealPlan{ID: "m1", Date: "2026-08-30", Meal: "dinner", Title: "Nudeln", Servings: 2, Source: "jarvis-command", CreatedAt: store.Now(), UpdatedAt: store.Now()} + _ = a.db.Update(func(s *store.State) error { s.MealPlans = []store.MealPlan{m}; return nil }) + a.recent = &recentMutation{Kind: "meal_plan", ID: "m1", At: time.Now()} + reply, _, mode, handled := a.tryHandleRecentCorrection("nein, Montag den 31.08.") + if !handled || mode != "action" || !strings.Contains(reply, "31.08.2026") { + t.Fatalf("reply=%q mode=%s handled=%v", reply, mode, handled) + } + if got := a.db.Snapshot().MealPlans[0].Date; got != "2026-08-31" { + t.Fatalf("date=%s", got) + } +} + +func TestMealFallbackUnderstandsMondayDinner(t *testing.T) { + a := testApp(t) + // Use an explicit date here for the action parser itself; weekday resolution is + // covered separately with a fixed Thursday above. + ac, ok := a.deterministicMealPlanFallback("31.08.2026 bitte Nudeln zum Abendessen eintragen") + if !ok { + t.Fatal("meal fallback not recognized") + } + if ac.Date != "2026-08-31" || ac.Meal != "dinner" || ac.RecipeTitle != "Nudeln" { + t.Fatalf("action=%+v", ac) + } +} + +func TestMealSlotIsUpdatedInsteadOfDuplicated(t *testing.T) { + a := testApp(t) + first, ok := a.applyCommandAction(commandAction{Type: "add_meal_plan", Date: "2026-08-31", Meal: "dinner", RecipeTitle: "Pizza", Servings: 2, Confidence: 1}) + if !ok || first.Type != "add_meal_plan" { + t.Fatalf("first=%+v", first) + } + second, ok := a.applyCommandAction(commandAction{Type: "add_meal_plan", Date: "2026-08-31", Meal: "dinner", RecipeTitle: "Nudeln", Servings: 2, Confidence: 1}) + if !ok || second.Type != "meal_updated" { + t.Fatalf("second=%+v", second) + } + st := a.db.Snapshot() + if len(st.MealPlans) != 1 || st.MealPlans[0].Title != "Nudeln" { + t.Fatalf("meals=%+v", st.MealPlans) + } +} + +func TestMealFallbackLinksExistingRecipeFromNaturalText(t *testing.T) { + a := testApp(t) + _ = a.db.Update(func(s *store.State) error { + s.Recipes = []store.Recipe{{ID: "r1", Title: "Nudeln mit Thomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "150", Unit: "Gramm"}}, CreatedAt: store.Now()}} + return nil + }) + ac, ok := a.deterministicMealPlanFallback("31.08.2026 zum Abendessen Nudeln thomatensauce einplanen") + if !ok { + t.Fatal("fallback not recognized") + } + if ac.RecipeID != "r1" || ac.RecipeTitle != "Nudeln mit Thomatensauce" { + t.Fatalf("action=%+v", ac) + } +} + +func TestMealPlanDeterministicFastPathPersistsAndBuildsGroceries(t *testing.T) { + a := testApp(t) + _ = a.db.Update(func(s *store.State) error { + s.Recipes = []store.Recipe{{ + ID: "r1", Title: "Nudeln mit Thomatensauce", Servings: 2, + Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "150", Unit: "Gramm"}, {Name: "Thomatensauce", Quantity: "50", Unit: "ml"}}, + CreatedAt: store.Now(), + }} + return nil + }) + results, err := a.planAndApplyExplicitActions(context.Background(), "31.08.2026 bitte Nudeln thomatensauce zum Abendessen einplanen") + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Type != "add_meal_plan" { + t.Fatalf("results=%+v", results) + } + st := a.db.Snapshot() + if len(st.MealPlans) != 1 { + t.Fatalf("meal plans=%+v", st.MealPlans) + } + if st.MealPlans[0].RecipeID != "r1" || st.MealPlans[0].Meal != "dinner" { + t.Fatalf("meal=%+v", st.MealPlans[0]) + } + if len(st.Groceries) != 2 { + t.Fatalf("groceries=%+v", st.Groceries) + } +} + +func TestNaturalMealWishFastPathPersistsWithoutLLM(t *testing.T) { + a := testApp(t) + results, err := a.planAndApplyExplicitActions(context.Background(), "30.08.2026 wären Pommes Currywurst toll. Also zu Mittag") + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Type != "add_meal_plan" { + t.Fatalf("results=%+v", results) + } + st := a.db.Snapshot() + if len(st.MealPlans) != 1 { + t.Fatalf("meal plans=%+v", st.MealPlans) + } + m := st.MealPlans[0] + if m.Date != "2026-08-30" || m.Meal != "lunch" || m.Title != "Pommes Currywurst" { + t.Fatalf("meal=%+v", m) + } +} diff --git a/cmd/homehub/context.go b/cmd/homehub/context.go new file mode 100644 index 0000000..151ef3e --- /dev/null +++ b/cmd/homehub/context.go @@ -0,0 +1,162 @@ +package main + +import ( + "fmt" + "math" + "strings" + + "homehub/internal/ollama" + "homehub/internal/rag" + "homehub/internal/store" +) + +// contextConfig trennt das physische Ollama-Kontextfenster vom logischen +// Gesprächs-, State- und RAG-Budget. So kann ein kleines lokales Modell nicht +// durch einen riesigen HOME-STATE die letzten Nutzeraussagen aus dem Kontext +// drücken. +type contextConfig struct { + NumCtx int `json:"numCtx"` + NumPredict int `json:"numPredict"` + Temperature float64 `json:"temperature"` + PlannerNumCtx int `json:"plannerNumCtx"` + PlannerNumPredict int `json:"plannerNumPredict"` + PlannerTemperature float64 `json:"plannerTemperature"` + AgentNumCtx int `json:"agentNumCtx"` + AgentNumPredict int `json:"agentNumPredict"` + AgentTemperature float64 `json:"agentTemperature"` + AgentMaxSteps int `json:"agentMaxSteps"` + HistoryTokens int `json:"historyTokens"` + PlannerHistoryTokens int `json:"plannerHistoryTokens"` + StateTokens int `json:"stateTokens"` + RAGTokens int `json:"ragTokens"` +} + +func defaultContextConfig() contextConfig { + return contextConfig{ + NumCtx: 16384, NumPredict: 1200, Temperature: 0.15, + PlannerNumCtx: 8192, PlannerNumPredict: 700, PlannerTemperature: 0.0, + AgentNumCtx: 16384, AgentNumPredict: 700, AgentTemperature: 0.0, AgentMaxSteps: 6, + HistoryTokens: 3200, PlannerHistoryTokens: 1800, + StateTokens: 3200, RAGTokens: 4200, + } +} + +func (c contextConfig) chatOptions() ollama.GenerationOptions { + return ollama.GenerationOptions{NumCtx: c.NumCtx, NumPredict: c.NumPredict, Temperature: c.Temperature} +} +func (c contextConfig) plannerOptions() ollama.GenerationOptions { + return ollama.GenerationOptions{NumCtx: c.PlannerNumCtx, NumPredict: c.PlannerNumPredict, Temperature: c.PlannerTemperature} +} +func (c contextConfig) agentOptions() ollama.GenerationOptions { + return ollama.GenerationOptions{NumCtx: c.AgentNumCtx, NumPredict: c.AgentNumPredict, Temperature: c.AgentTemperature} +} + +// estimateTokens ist absichtlich konservativ. Für deutsche Prosa sind ca. 3-4 +// Zeichen pro Token eine brauchbare lokale Näherung, ohne einen Modell-spezifischen +// Tokenizer in den Home-Core einzubauen. +func estimateTokens(s string) int { + r := len([]rune(strings.TrimSpace(s))) + if r == 0 { + return 0 + } + return int(math.Ceil(float64(r)/3.5)) + 4 +} + +func trimToTokenBudget(s string, budget int) string { + if budget <= 0 || estimateTokens(s) <= budget { + return s + } + runes := []rune(s) + maxRunes := int(float64(budget-8) * 3.5) + if maxRunes < 64 { + maxRunes = 64 + } + if maxRunes >= len(runes) { + return s + } + // Bei State/RAG ist der Anfang wichtiger als ein zufälliger Mittelteil. + return strings.TrimSpace(string(runes[:maxRunes])) + "\n[… Kontext gekürzt …]" +} + +// conversationHistory liefert echte user/assistant Messages an /api/chat statt +// den Verlauf als Text in einen einzelnen Prompt zu kleben. Neueste Turns haben +// Priorität. Die aktuell bereits persistierte Nutzerzeile wird nicht dupliziert. +func conversationHistory(chat []store.ChatTurn, current string, budget int) []ollama.Turn { + if budget <= 0 || len(chat) == 0 { + return nil + } + current = strings.TrimSpace(current) + selected := make([]ollama.Turn, 0, 16) + used := 0 + skippedCurrent := false + for i := len(chat) - 1; i >= 0; i-- { + t := chat[i] + role := strings.ToLower(strings.TrimSpace(t.Role)) + if role != "user" && role != "assistant" { + continue + } + content := strings.TrimSpace(t.Content) + if content == "" { + continue + } + if !skippedCurrent && role == "user" && content == current { + skippedCurrent = true + continue + } + cost := estimateTokens(content) + 6 + if used+cost > budget { + if len(selected) > 0 { + break + } + content = trimToTokenBudget(content, budget-6) + cost = estimateTokens(content) + 6 + } + selected = append(selected, ollama.Turn{Role: role, Content: content}) + used += cost + } + // Rückwärts gesammelt -> chronologische Reihenfolge herstellen. + for i, j := 0, len(selected)-1; i < j; i, j = i+1, j-1 { + selected[i], selected[j] = selected[j], selected[i] + } + return selected +} + +func estimateHistoryTokens(history []ollama.Turn) int { + total := 0 + for _, h := range history { + total += estimateTokens(h.Content) + 6 + } + return total +} + +func ragContext(hits []rag.Hit, budget int) string { + if len(hits) == 0 { + return "(keine passenden Dokumentauszüge)" + } + var b strings.Builder + remaining := budget + for i, h := range hits { + header := fmt.Sprintf("[%d] Quelle: %s | Score %.3f\n", i+1, h.Name, h.Score) + hc := estimateTokens(header) + if remaining-hc < 40 { + break + } + remaining -= hc + text := trimToTokenBudget(h.Text, remaining) + tc := estimateTokens(text) + if tc > remaining { + break + } + b.WriteString(header) + b.WriteString(text) + b.WriteString("\n\n") + remaining -= tc + if remaining < 60 { + break + } + } + if b.Len() == 0 { + return "(RAG-Treffer außerhalb des Tokenbudgets)" + } + return strings.TrimSpace(b.String()) +} diff --git a/cmd/homehub/debug.go b/cmd/homehub/debug.go new file mode 100644 index 0000000..459d938 --- /dev/null +++ b/cmd/homehub/debug.go @@ -0,0 +1,231 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "strconv" + "strings" + "time" + + "homehub/internal/debugtrace" + "homehub/internal/store" +) + +type captureWriter struct { + http.ResponseWriter + status int + buf bytes.Buffer + max int +} + +func (w *captureWriter) WriteHeader(code int) { + if w.status == 0 { + w.status = code + } + w.ResponseWriter.WriteHeader(code) +} +func (w *captureWriter) Write(p []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + if w.buf.Len() < w.max { + remain := w.max - w.buf.Len() + if remain > len(p) { + remain = len(p) + } + _, _ = w.buf.Write(p[:remain]) + } + return w.ResponseWriter.Write(p) +} + +func (a *app) traceHTTP(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if a.debug == nil || !a.debug.Enabled() || !strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/api/debug") { + next.ServeHTTP(w, r) + return + } + if (r.URL.Path == "/api/state" || r.URL.Path == "/api/health") && !a.debug.Config().LogHTTPState { + next.ServeHTTP(w, r) + return + } + ctx, traceID := debugtrace.EnsureTrace(r.Context(), "http") + r = r.WithContext(ctx) + w.Header().Set("X-JARVIS-Trace-ID", traceID) + started := time.Now() + var requestData any = map[string]any{"method": r.Method, "path": r.URL.Path, "query": r.URL.Query()} + ct, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + if r.Body != nil && (ct == "application/json" || ct == "text/plain" || ct == "application/x-www-form-urlencoded") { + raw, _ := io.ReadAll(io.LimitReader(r.Body, 512<<10)) + r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(raw)) + if len(raw) > 0 { + var v any + if json.Unmarshal(raw, &v) == nil { + requestData = map[string]any{"method": r.Method, "path": r.URL.Path, "query": r.URL.Query(), "body": v} + } else { + requestData = map[string]any{"method": r.Method, "path": r.URL.Path, "query": r.URL.Query(), "body_text": string(raw)} + } + } + } else if strings.HasPrefix(ct, "multipart/") { + requestData = map[string]any{"method": r.Method, "path": r.URL.Path, "content_type": ct, "content_length": r.ContentLength, "note": "multipart raw bytes intentionally not logged"} + } + a.debug.Record(ctx, "http", "request", "input", "info", requestData, nil, 0) + cw := &captureWriter{ResponseWriter: w, max: 512 << 10} + next.ServeHTTP(cw, r) + respData := map[string]any{"status": cw.status, "content_type": cw.Header().Get("Content-Type")} + body := bytes.TrimSpace(cw.buf.Bytes()) + if len(body) > 0 { + var v any + if json.Unmarshal(body, &v) == nil { + respData["body"] = v + } else if strings.HasPrefix(cw.Header().Get("Content-Type"), "text/") { + respData["body_text"] = string(body) + } else { + respData["body_bytes_captured"] = len(body) + } + } + a.debug.Record(ctx, "http", "response", "output", "info", respData, nil, time.Since(started)) + }) +} + +func traceBackground(ctx context.Context) context.Context { + id := debugtrace.TraceID(ctx) + if id == "" { + return context.Background() + } + return debugtrace.WithTrace(context.Background(), id) +} + +func (a *app) trace(ctx context.Context, component, stage, direction string, data any) { + if a.debug != nil { + a.debug.Record(ctx, component, stage, direction, "info", data, nil, 0) + } +} +func (a *app) traceErr(ctx context.Context, component, stage string, data any, err error, started time.Time) { + if a.debug != nil { + a.debug.Record(ctx, component, stage, "output", "error", data, err, time.Since(started)) + } +} +func (a *app) traceTimed(ctx context.Context, component, stage, direction string, data any, started time.Time) { + if a.debug != nil { + a.debug.Record(ctx, component, stage, direction, "info", data, nil, time.Since(started)) + } +} + +func (a *app) debugRuntimeState() map[string]any { + a.pendingMu.Lock() + var pending any + if a.pending != nil { + cp := *a.pending + cp.Items = append([]pendingItem(nil), a.pending.Items...) + pending = cp + } + a.pendingMu.Unlock() + a.recentMu.Lock() + var recent any + if a.recent != nil { + recent = *a.recent + } + a.recentMu.Unlock() + a.confirmMu.Lock() + var confirmation any + if a.confirm != nil { + cp := *a.confirm + cp.Arguments = map[string]any{} + for k, v := range a.confirm.Arguments { + cp.Arguments[k] = v + } + confirmation = cp + } + a.confirmMu.Unlock() + return map[string]any{"pending_command": pending, "pending_tool_confirmation": confirmation, "recent_mutation": recent} +} + +func debugStateSnapshot(st store.State) map[string]any { + docs := make([]map[string]any, 0, len(st.Documents)) + for _, d := range st.Documents { + docs = append(docs, map[string]any{ + "id": d.ID, "name": d.Name, "mime": d.Mime, "tags": d.Tags, + "chunkCount": d.ChunkCount, "indexedAt": d.IndexedAt, "createdAt": d.CreatedAt, + "textChars": len([]rune(d.Text)), + }) + } + return map[string]any{ + "tasks": st.Tasks, "events": st.Events, "reminders": st.Reminders, + "groceries": st.Groceries, "recipes": st.Recipes, "mealPlans": st.MealPlans, + "kanban": st.Kanban, "documents": docs, "chat": st.Chat, "activity": st.Activity, + } +} + +func (a *app) handleDebugExport(w http.ResponseWriter, r *http.Request) { + if a.debug == nil || !a.debug.Enabled() { + http.Error(w, "Debug tracing ist deaktiviert", 404) + return + } + limit := 5000 + if v, _ := strconv.Atoi(r.URL.Query().Get("limit")); v > 0 && v <= 50000 { + limit = v + } + events, err := a.debug.Events(limit) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + st := a.db.Snapshot() + ms := a.ai.Status() + runtimeState := a.debugRuntimeState() + manifest := map[string]any{ + "schema": "jarvis.debug.export.v2", "generated_at": time.Now().In(a.timezone).Format(time.RFC3339), "timezone": a.timezone.String(), + "events": len(events), "ollama": ms, "context": a.context, "debug": a.debug.Config(), "rag_chunks": a.rag.Count(), + "state_summary": map[string]any{"events": len(st.Events), "tasks": len(st.Tasks), "reminders": len(st.Reminders), "groceries": len(st.Groceries), "recipes": len(st.Recipes), "meal_plans": len(st.MealPlans), "kanban": len(st.Kanban), "documents": len(st.Documents), "chat_turns": len(st.Chat)}, + "runtime": runtimeState, + "agent": map[string]any{"mode": "native-tools", "max_steps": a.context.AgentMaxSteps, "tools": toolNames(a.tools.Definitions())}, + "home_state": debugStateSnapshot(st), + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=jarvis-debug-%s.json", time.Now().Format("20060102-150405"))) + w.WriteHeader(200) + _, _ = w.Write([]byte("{\n \"manifest\": ")) + mb, _ := json.MarshalIndent(manifest, " ", " ") + _, _ = w.Write(mb) + _, _ = w.Write([]byte(",\n \"events\": [\n")) + for i, e := range events { + if i > 0 { + _, _ = w.Write([]byte(",\n")) + } + _, _ = w.Write([]byte(" ")) + _, _ = w.Write(e) + } + _, _ = w.Write([]byte("\n ]\n}\n")) +} +func (a *app) handleDebugExportJSONL(w http.ResponseWriter, r *http.Request) { + if a.debug == nil || !a.debug.Enabled() { + http.Error(w, "Debug tracing ist deaktiviert", 404) + return + } + limit := 10000 + if v, _ := strconv.Atoi(r.URL.Query().Get("limit")); v > 0 && v <= 100000 { + limit = v + } + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=jarvis-debug-%s.jsonl", time.Now().Format("20060102-150405"))) + if err := a.debug.WriteJSONL(w, limit); err != nil { + http.Error(w, err.Error(), 500) + } +} +func (a *app) handleDebugClear(w http.ResponseWriter, r *http.Request) { + if a.debug == nil || !a.debug.Enabled() { + http.Error(w, "Debug tracing ist deaktiviert", 404) + return + } + if err := a.debug.Clear(); err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, map[string]any{"ok": true}) +} diff --git a/cmd/homehub/home.go b/cmd/homehub/home.go new file mode 100644 index 0000000..1f9998f --- /dev/null +++ b/cmd/homehub/home.go @@ -0,0 +1,1717 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "homehub/internal/debugtrace" + "homehub/internal/ollama" + "homehub/internal/store" +) + +type automationResult struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Message string `json:"message"` +} + +type commandPlan struct { + Actions []commandAction `json:"actions"` +} + +type commandAction struct { + Type string `json:"type"` + Title string `json:"title,omitempty"` + Notes string `json:"notes,omitempty"` + Due string `json:"due,omitempty"` + Priority string `json:"priority,omitempty"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + AllDay bool `json:"allDay,omitempty"` + Location string `json:"location,omitempty"` + At string `json:"at,omitempty"` + Name string `json:"name,omitempty"` + Quantity string `json:"quantity,omitempty"` + Unit string `json:"unit,omitempty"` + Category string `json:"category,omitempty"` + Date string `json:"date,omitempty"` + Meal string `json:"meal,omitempty"` + RecipeID string `json:"recipeId,omitempty"` + RecipeTitle string `json:"recipeTitle,omitempty"` + Servings int `json:"servings,omitempty"` + Description string `json:"description,omitempty"` + PrepMinutes int `json:"prepMinutes,omitempty"` + Ingredients []store.Ingredient `json:"ingredients,omitempty"` + Steps []string `json:"steps,omitempty"` + Tags []string `json:"tags,omitempty"` + Confidence float64 `json:"confidence"` +} + +// UnmarshalJSON accepts the small schema variations that local models commonly +// produce even in JSON mode. The canonical internal representation stays strict. +// This prevents a harmless key choice such as "action" instead of "type" from +// turning a valid command into an empty/rejected action. +func (a *commandAction) UnmarshalJSON(data []byte) error { + type plain commandAction + var base plain + if err := json.Unmarshal(data, &base); err != nil { + return err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + readString := func(keys ...string) string { + for _, key := range keys { + v, ok := raw[key] + if !ok { + continue + } + var out string + if json.Unmarshal(v, &out) == nil && strings.TrimSpace(out) != "" { + return strings.TrimSpace(out) + } + } + return "" + } + + if base.Type == "" { + base.Type = readString("action", "kind", "actionType", "action_type") + } + base.Type = canonicalActionType(base.Type) + + if base.Date == "" { + base.Date = readString("meal_plan_date", "mealPlanDate", "plan_date", "planDate") + } + if base.Meal == "" { + base.Meal = readString("meal_type", "mealType", "slot") + } + if base.RecipeID == "" { + base.RecipeID = readString("recipe_id", "recipe") + } + if base.RecipeTitle == "" { + base.RecipeTitle = readString("recipe_title", "dish", "dish_title") + } + if base.Start == "" { + base.Start = readString("start_at", "startAt", "datetime", "date_time") + } + if base.At == "" { + base.At = readString("remind_at", "remindAt", "reminder_at") + } + if base.Title == "" { + base.Title = readString("subject", "label") + } + *a = commandAction(base) + return nil +} + +func canonicalActionType(v string) string { + x := strings.ToLower(strings.TrimSpace(v)) + x = strings.ReplaceAll(x, "-", "_") + x = strings.ReplaceAll(x, " ", "_") + switch x { + case "add_task", "create_task", "new_task", "task": + return "add_task" + case "add_event", "create_event", "new_event", "event", "calendar_event": + return "add_event" + case "add_reminder", "create_reminder", "new_reminder", "reminder": + return "add_reminder" + case "add_grocery", "create_grocery", "grocery", "grocery_item", "add_grocery_item": + return "add_grocery" + case "add_meal_plan", "create_meal_plan", "meal_plan", "plan_meal", "add_meal": + return "add_meal_plan" + case "add_recipe", "create_recipe", "recipe": + return "add_recipe" + default: + return x + } +} + +type kanbanPlan struct { + Cards []kanbanSuggestion `json:"cards"` +} + +type kanbanSuggestion struct { + TaskID string `json:"taskId"` + Title string `json:"title"` + Notes string `json:"notes"` + Column string `json:"column"` + Priority string `json:"priority"` + Due string `json:"due"` + Confidence float64 `json:"confidence"` +} + +func (a *app) handleEventUpsert(w http.ResponseWriter, r *http.Request) { + var v store.CalendarEvent + if !decodeJSON(w, r, &v) { + return + } + v.Title = strings.TrimSpace(v.Title) + if v.Title == "" || strings.TrimSpace(v.Start) == "" { + http.Error(w, "Titel und Start fehlen", 400) + return + } + if !validTimeOrDate(v.Start) { + http.Error(w, "Start muss RFC3339 oder YYYY-MM-DD sein", 400) + return + } + if v.End != "" && !validTimeOrDate(v.End) { + http.Error(w, "Ende muss RFC3339 oder YYYY-MM-DD sein", 400) + return + } + now := store.Now() + if v.ID == "" { + v.ID = id("event") + v.CreatedAt = now + } + v.UpdatedAt = now + if err := a.db.Update(func(s *store.State) error { upsertEvent(s, v); return nil }); err != nil { + http.Error(w, err.Error(), 500) + return + } + a.activity("calendar", "Termin aktualisiert: "+v.Title) + go a.runHomeAutomations(context.Background(), "event-change") + writeJSON(w, 200, v) +} +func (a *app) handleEventDelete(w http.ResponseWriter, r *http.Request) { + idv := r.PathValue("id") + err := a.db.Update(func(s *store.State) error { + s.Events = filterEvents(s.Events, idv) + s.Reminders = filterLinkedReminders(s.Reminders, "event", idv) + return nil + }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) handleReminderUpsert(w http.ResponseWriter, r *http.Request) { + var v store.Reminder + if !decodeJSON(w, r, &v) { + return + } + v.Title = strings.TrimSpace(v.Title) + if v.Title == "" || !validRFC3339(v.At) { + http.Error(w, "Titel und Zeitpunkt (RFC3339) erforderlich", 400) + return + } + now := store.Now() + if v.ID == "" { + v.ID = id("rem") + v.CreatedAt = now + } + v.UpdatedAt = now + if err := a.db.Update(func(s *store.State) error { upsertReminder(s, v); return nil }); err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, v) +} +func (a *app) handleReminderDelete(w http.ResponseWriter, r *http.Request) { + idv := r.PathValue("id") + err := a.db.Update(func(s *store.State) error { s.Reminders = filterReminders(s.Reminders, idv); return nil }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) handleGroceryUpsert(w http.ResponseWriter, r *http.Request) { + var v store.GroceryItem + if !decodeJSON(w, r, &v) { + return + } + v.Name = strings.TrimSpace(v.Name) + if v.Name == "" { + http.Error(w, "Name fehlt", 400) + return + } + now := store.Now() + if v.ID == "" { + v.ID = id("grocery") + v.CreatedAt = now + } + v.UpdatedAt = now + if err := a.db.Update(func(s *store.State) error { upsertGrocery(s, v); return nil }); err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, v) +} +func (a *app) handleGroceryDelete(w http.ResponseWriter, r *http.Request) { + idv := r.PathValue("id") + err := a.db.Update(func(s *store.State) error { s.Groceries = filterGroceries(s.Groceries, idv); return nil }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) handleRecipeUpsert(w http.ResponseWriter, r *http.Request) { + var v store.Recipe + if !decodeJSON(w, r, &v) { + return + } + v.Title = strings.TrimSpace(v.Title) + if v.Title == "" { + http.Error(w, "Titel fehlt", 400) + return + } + if v.Ingredients == nil { + v.Ingredients = []store.Ingredient{} + } + if v.Steps == nil { + v.Steps = []string{} + } + now := store.Now() + if v.ID == "" { + v.ID = id("recipe") + v.CreatedAt = now + } + v.UpdatedAt = now + if err := a.db.Update(func(s *store.State) error { upsertRecipe(s, v); return nil }); err != nil { + http.Error(w, err.Error(), 500) + return + } + a.activity("recipe", "Rezept gespeichert: "+v.Title) + go a.runHomeAutomations(context.Background(), "recipe-change") + writeJSON(w, 200, v) +} +func (a *app) handleRecipeDelete(w http.ResponseWriter, r *http.Request) { + idv := r.PathValue("id") + err := a.db.Update(func(s *store.State) error { + s.Recipes = filterRecipes(s.Recipes, idv) + for i := range s.MealPlans { + if s.MealPlans[i].RecipeID == idv { + s.MealPlans[i].RecipeID = "" + } + } + return nil + }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + go a.runHomeAutomations(context.Background(), "recipe-delete") + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) handleMealPlanUpsert(w http.ResponseWriter, r *http.Request) { + var v store.MealPlan + if !decodeJSON(w, r, &v) { + return + } + if !validDate(v.Date) { + http.Error(w, "Datum muss YYYY-MM-DD sein", 400) + return + } + v.Meal = normalizeMeal(v.Meal) + if v.Title == "" && v.RecipeID != "" { + for _, rec := range a.db.Snapshot().Recipes { + if rec.ID == v.RecipeID { + v.Title = rec.Title + break + } + } + } + v.Title = strings.TrimSpace(v.Title) + if v.Title == "" { + http.Error(w, "Gericht oder Rezept fehlt", 400) + return + } + if v.Servings <= 0 { + v.Servings = 2 + } + now := store.Now() + if v.ID == "" { + v.ID = id("meal") + v.CreatedAt = now + } + v.UpdatedAt = now + if err := a.db.Update(func(s *store.State) error { upsertMealPlan(s, v); return nil }); err != nil { + http.Error(w, err.Error(), 500) + return + } + a.activity("meal", "Essensplan aktualisiert: "+v.Title) + go a.runHomeAutomations(context.Background(), "meal-change") + writeJSON(w, 200, v) +} +func (a *app) handleMealPlanDelete(w http.ResponseWriter, r *http.Request) { + idv := r.PathValue("id") + err := a.db.Update(func(s *store.State) error { s.MealPlans = filterMealPlans(s.MealPlans, idv); return nil }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + go a.runHomeAutomations(context.Background(), "meal-delete") + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) handleKanbanUpsert(w http.ResponseWriter, r *http.Request) { + var v store.KanbanCard + if !decodeJSON(w, r, &v) { + return + } + v.Title = strings.TrimSpace(v.Title) + if v.Title == "" { + http.Error(w, "Titel fehlt", 400) + return + } + v.Column = normalizeColumn(v.Column) + v.Priority = normalizePriority(v.Priority) + now := store.Now() + if v.ID == "" { + v.ID = id("card") + v.CreatedAt = now + } + v.UpdatedAt = now + if err := a.db.Update(func(s *store.State) error { upsertKanban(s, v); return nil }); err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, v) +} +func (a *app) handleKanbanDelete(w http.ResponseWriter, r *http.Request) { + idv := r.PathValue("id") + err := a.db.Update(func(s *store.State) error { s.Kanban = filterKanban(s.Kanban, idv); return nil }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) handleAutomationRun(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) + defer cancel() + summary := a.runHomeAutomations(ctx, "manual") + writeJSON(w, 200, summary) +} +func (a *app) handleKanbanAI(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) + defer cancel() + n, err := a.runKanbanAI(ctx, "manual") + if err != nil { + http.Error(w, err.Error(), 502) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "updated": n}) +} + +func (a *app) homeAutomationLoop(d time.Duration) { + ticker := time.NewTicker(d) + defer ticker.Stop() + for range ticker.C { + a.runHomeAutomations(context.Background(), "interval") + } +} + +func (a *app) runHomeAutomations(parent context.Context, reason string) map[string]any { + ctx, _ := debugtrace.EnsureTrace(parent, "automation") + started := time.Now() + before := a.db.Snapshot() + a.trace(ctx, "automation", "home_sync", "input", map[string]any{"reason": reason, "events": before.Events, "tasks": before.Tasks, "mealPlans": before.MealPlans, "recipes": before.Recipes, "groceries": before.Groceries, "reminders": before.Reminders, "kanban": before.Kanban}) + g := a.syncMealPlanGroceries() + r := a.syncEventReminders() + k := a.syncTaskCards() + if g+r+k > 0 { + a.activity("automation", fmt.Sprintf("Home-Automation (%s): Einkauf %d · Reminder %d · Kanban %d", reason, g, r, k)) + } + out := map[string]any{"ok": true, "groceries": g, "reminders": r, "kanban": k} + after := a.db.Snapshot() + a.traceTimed(ctx, "automation", "home_sync", "output", map[string]any{"result": out, "groceries": after.Groceries, "reminders": after.Reminders, "kanban": after.Kanban}, started) + return out +} + +func (a *app) syncEventReminders() int { + now := time.Now().In(a.timezone) + changed := 0 + _ = a.db.Update(func(s *store.State) error { + events := map[string]store.CalendarEvent{} + for _, e := range s.Events { + events[e.ID] = e + } + + // Entferne nur automatisch verwaltete Reminder, deren Termin nicht mehr existiert. + kept := s.Reminders[:0] + for _, r := range s.Reminders { + if r.AIManaged && r.LinkedType == "event" { + if _, ok := events[r.LinkedID]; !ok { + changed++ + continue + } + } + kept = append(kept, r) + } + s.Reminders = append([]store.Reminder(nil), kept...) + + byEvent := map[string]int{} + for i, r := range s.Reminders { + if r.AIManaged && r.LinkedType == "event" { + byEvent[r.LinkedID] = i + } + } + for _, e := range s.Events { + start, err := parseEventTime(e, a.timezone) + if err != nil || start.Before(now) || start.After(now.Add(45*24*time.Hour)) { + continue + } + at := start.Add(-30 * time.Minute) + if e.AllDay { + at = time.Date(start.Year(), start.Month(), start.Day(), 9, 0, 0, 0, a.timezone) + } + wantTitle := "Termin: " + e.Title + wantAt := at.Format(time.RFC3339) + if i, ok := byEvent[e.ID]; ok { + r := s.Reminders[i] + if r.Title != wantTitle || r.At != wantAt || r.Notes != e.Location || r.Done { + r.Title = wantTitle + r.At = wantAt + r.Notes = e.Location + r.Done = false + r.UpdatedAt = store.Now() + s.Reminders[i] = r + changed++ + } + continue + } + rem := store.Reminder{ID: id("rem_ai"), Title: wantTitle, At: wantAt, Notes: e.Location, LinkedType: "event", LinkedID: e.ID, Source: "calendar:auto", AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()} + s.Reminders = append(s.Reminders, rem) + byEvent[e.ID] = len(s.Reminders) - 1 + changed++ + } + return nil + }) + return changed +} + +func (a *app) syncMealPlanGroceries() int { + return a.syncMealPlanGroceriesForDays(14) +} + +func (a *app) syncMealPlanGroceriesForDays(days int) int { + if days < 1 { + days = 14 + } + if days > 60 { + days = 60 + } + now := time.Now().In(a.timezone) + today := now.Format("2006-01-02") + end := now.AddDate(0, 0, days).Format("2006-01-02") + changed := 0 + _ = a.db.Update(func(s *store.State) error { + recipes := map[string]store.Recipe{} + for _, r := range s.Recipes { + recipes[r.ID] = r + } + // Repair old/free meal-plan entries when their title clearly matches a recipe. + // This keeps ingredient automation deterministic without asking the LLM to infer quantities. + for i := range s.MealPlans { + m := &s.MealPlans[i] + if m.Date < today || m.Date > end || m.RecipeID != "" { + continue + } + bestID, bestTitle, best := "", "", 0.0 + for _, rec := range s.Recipes { + score := textSimilarity(m.Title, rec.Title) + if score > best { + best, bestID, bestTitle = score, rec.ID, rec.Title + } + } + if best >= .45 { + m.RecipeID = bestID + if best >= .72 { + m.Title = bestTitle + } + m.UpdatedAt = store.Now() + changed++ + } + } + type agg struct{ Name, Qty, Unit string } + wanted := map[string]agg{} + for _, m := range s.MealPlans { + if m.Date < today || m.Date > end || m.RecipeID == "" { + continue + } + rec, ok := recipes[m.RecipeID] + if !ok { + continue + } + factor := 1.0 + if rec.Servings > 0 && m.Servings > 0 { + factor = float64(m.Servings) / float64(rec.Servings) + } + for _, ing := range rec.Ingredients { + name := strings.TrimSpace(ing.Name) + if name == "" { + continue + } + key := normalizeTask(name) + "|" + strings.ToLower(strings.TrimSpace(ing.Unit)) + qty := scaleQuantity(ing.Quantity, factor) + if old, ok := wanted[key]; ok { + old.Qty = mergeQuantity(old.Qty, qty) + wanted[key] = old + } else { + wanted[key] = agg{Name: name, Qty: qty, Unit: ing.Unit} + } + } + } + existing := map[string]store.GroceryItem{} + manual := make([]store.GroceryItem, 0, len(s.Groceries)) + for _, g := range s.Groceries { + if g.AIManaged && strings.HasPrefix(g.Source, "mealplan:auto:") { + existing[strings.TrimPrefix(g.Source, "mealplan:auto:")] = g + } else { + manual = append(manual, g) + } + } + auto := make([]store.GroceryItem, 0, len(wanted)) + for key, w := range wanted { + if g, ok := existing[key]; ok { + if g.Quantity != w.Qty || g.Unit != w.Unit || g.Name != w.Name { + changed++ + } + g.Name = w.Name + g.Quantity = w.Qty + g.Unit = w.Unit + g.UpdatedAt = store.Now() + auto = append(auto, g) + } else { + auto = append(auto, store.GroceryItem{ID: id("grocery_ai"), Name: w.Name, Quantity: w.Qty, Unit: w.Unit, Category: "Essensplan", Source: "mealplan:auto:" + key, AIManaged: true, CreatedAt: store.Now(), UpdatedAt: store.Now()}) + changed++ + } + } + for key := range existing { + if _, ok := wanted[key]; !ok { + changed++ + } + } + s.Groceries = append(manual, auto...) + return nil + }) + return changed +} + +func (a *app) syncTaskCards() int { + changed := 0 + _ = a.db.Update(func(s *store.State) error { + taskIDs := map[string]bool{} + for _, t := range s.Tasks { + taskIDs[t.ID] = true + } + kept := s.Kanban[:0] + for _, c := range s.Kanban { + if c.AIManaged && c.TaskID != "" && !taskIDs[c.TaskID] { + changed++ + continue + } + kept = append(kept, c) + } + s.Kanban = append([]store.KanbanCard(nil), kept...) + + byTask := map[string]int{} + for i, c := range s.Kanban { + if c.TaskID != "" { + byTask[c.TaskID] = i + } + } + for _, t := range s.Tasks { + idx, ok := byTask[t.ID] + if ok { + c := s.Kanban[idx] + if !c.AIManaged { + continue + } + target := c.Column + if t.Status == "done" { + target = "done" + } else if c.Column == "done" { + target = "next" + } + if c.Title != t.Title || c.Priority != t.Priority || c.Due != t.Due || c.Column != target { + c.Title = t.Title + c.Priority = normalizePriority(t.Priority) + c.Due = t.Due + c.Column = target + c.UpdatedAt = store.Now() + s.Kanban[idx] = c + changed++ + } + continue + } + if t.Status == "done" { + continue + } + s.Kanban = append(s.Kanban, store.KanbanCard{ID: id("card_ai"), Title: t.Title, Notes: t.Notes, Column: "next", Priority: normalizePriority(t.Priority), Due: t.Due, TaskID: t.ID, Source: "task-sync", AIManaged: true, Confidence: maxFloat(t.Confidence, .9), CreatedAt: store.Now(), UpdatedAt: store.Now()}) + changed++ + } + return nil + }) + return changed +} + +func (a *app) kanbanLoop(d time.Duration) { + ticker := time.NewTicker(d) + defer ticker.Stop() + for range ticker.C { + _, _ = a.runKanbanAI(context.Background(), "interval") + } +} +func (a *app) runKanbanAI(parent context.Context, reason string) (int, error) { + ctx0, _ := debugtrace.EnsureTrace(parent, "kanban") + if !a.kanbanMu.TryLock() { + a.trace(ctx0, "kanban", "skipped", "output", map[string]any{"reason": "already_running"}) + return 0, nil + } + defer a.kanbanMu.Unlock() + a.syncTaskCards() + ctx, cancel := context.WithTimeout(ctx0, 110*time.Second) + defer cancel() + st := a.db.Snapshot() + if len(st.Tasks) == 0 { + return 0, nil + } + + kanbanContext := a.buildKanbanContext(st) + plan, planMode, err := a.requestKanbanPlan(ctx, reason, kanbanContext) + if err != nil { + a.traceErr(ctx, "kanban", "plan_failed", map[string]any{"reason": reason}, err, time.Now()) + return 0, err + } + a.trace(ctx, "kanban", "parsed", "output", map[string]any{"mode": planMode, "plan": plan}) + + updated := 0 + err = a.db.Update(func(s *store.State) error { + tasks := map[string]store.Task{} + for _, t := range s.Tasks { + tasks[t.ID] = t + } + byTask := map[string]int{} + protected := map[string]bool{} + for i, c := range s.Kanban { + if c.TaskID == "" { + continue + } + if c.AIManaged { + byTask[c.TaskID] = i + } else { + protected[c.TaskID] = true + } + } + for _, p := range plan.Cards { + if protected[p.TaskID] || p.Confidence < .70 { + continue + } + t, ok := tasks[p.TaskID] + if !ok { + continue + } + col := normalizeColumn(p.Column) + if t.Status == "done" { + col = "done" + } + prio := normalizePriority(p.Priority) + if prio == "normal" && t.Priority != "" { + prio = normalizePriority(t.Priority) + } + if i, ok := byTask[p.TaskID]; ok { + c := s.Kanban[i] + if !c.AIManaged { + continue + } + changed := c.Column != col || c.Priority != prio || c.Notes != strings.TrimSpace(p.Notes) || c.Confidence != p.Confidence + c.Column = col + c.Priority = prio + c.Confidence = p.Confidence + c.Notes = strings.TrimSpace(p.Notes) + if p.Due != "" && validRFC3339(p.Due) { + if c.Due != p.Due { + changed = true + } + c.Due = p.Due + } + if changed { + c.UpdatedAt = store.Now() + s.Kanban[i] = c + updated++ + } + } else { + s.Kanban = append(s.Kanban, store.KanbanCard{ID: id("card_ai"), Title: t.Title, Notes: p.Notes, Column: col, Priority: prio, Due: t.Due, TaskID: t.ID, Source: "jarvis-kanban", AIManaged: true, Confidence: p.Confidence, CreatedAt: store.Now(), UpdatedAt: store.Now()}) + updated++ + } + } + return nil + }) + if err == nil && updated > 0 { + a.activity("kanban", fmt.Sprintf("KI-Kanban (%s/%s): %d Karten gepflegt", reason, planMode, updated)) + } + a.trace(ctx, "kanban", "persisted", "output", map[string]any{"reason": reason, "mode": planMode, "updated": updated, "error": errString(err), "cards": a.db.Snapshot().Kanban}) + return updated, err +} + +func (a *app) requestKanbanPlan(ctx context.Context, reason, kanbanContext string) (kanbanPlan, string, error) { + system := `Du bist der Kanban-Organizer von JARVIS. Analysiere ausschließlich die übergebenen bestehenden Aufgaben. Verändere keine manuellen Karten. Für jede sinnvolle offene Aufgabe darfst du eine Empfehlung für inbox|next|doing|waiting|done ausgeben. Nutze Priorität, Fälligkeit, Kalender und Erinnerungen. Rufe zwingend das Tool kanban_recommend genau einmal auf. Erfinde keine taskId.` + tool := ollama.Tool{Type: "function", Function: ollama.ToolFunction{ + Name: "kanban_recommend", + Description: "Liefert strukturierte Kanban-Empfehlungen für bestehende Aufgaben. Dieses Tool verändert noch keinen Zustand.", + Parameters: map[string]any{ + "type": "object", + "additionalProperties": false, + "required": []string{"cards"}, + "properties": map[string]any{ + "cards": map[string]any{ + "type": "array", "maxItems": 30, + "items": map[string]any{ + "type": "object", "additionalProperties": false, + "required": []string{"taskId", "column", "confidence"}, + "properties": map[string]any{ + "taskId": map[string]any{"type": "string"}, + "title": map[string]any{"type": "string"}, + "notes": map[string]any{"type": "string"}, + "column": map[string]any{"type": "string", "enum": []string{"inbox", "next", "doing", "waiting", "done"}}, + "priority": map[string]any{"type": "string", "enum": []string{"low", "normal", "high"}}, + "due": map[string]any{"type": "string"}, + "confidence": map[string]any{"type": "number", "minimum": 0, "maximum": 1}, + }, + }, + }, + }, + }, + }} + messages := []ollama.Turn{{Role: "system", Content: system}, {Role: "user", Content: kanbanContext}} + options := a.ai.GenerationOptions(true) + a.trace(ctx, "kanban", "tool_request", "input", map[string]any{"reason": reason, "model": a.ai.Status().Model, "messages": messages, "tool": tool, "options": options}) + started := time.Now() + turn, err := a.ai.ChatWithTools(ctx, messages, []ollama.Tool{tool}, options) + if err == nil { + a.traceTimed(ctx, "kanban", "tool_response", "output", map[string]any{"content": turn.Content, "tool_calls": turn.ToolCalls}, started) + for _, call := range turn.ToolCalls { + if strings.EqualFold(strings.TrimSpace(call.Function.Name), "kanban_recommend") { + b, _ := json.Marshal(call.Function.Arguments) + var plan kanbanPlan + if e := json.Unmarshal(b, &plan); e == nil { + return normalizeKanbanPlan(plan), "native-tool", nil + } else { + a.traceErr(ctx, "kanban", "tool_arguments_parse", map[string]any{"arguments": call.Function.Arguments}, e, time.Now()) + } + } + } + if plan, e := parseKanbanPlanText(turn.Content); e == nil { + return normalizeKanbanPlan(plan), "tool-content", nil + } + } else { + a.traceErr(ctx, "kanban", "tool_response", nil, err, started) + } + + // Compatibility retry for local models/templates that expose no native tool call. + retrySystem := `Du bist der Kanban-Organizer von JARVIS. Antworte ausschließlich mit einem vollständigen JSON-Objekt. Kein Markdown, keine Erklärung. Schema: {"cards":[{"taskId":"ID einer existierenden Aufgabe","title":"Titel","notes":"kurzer Grund","column":"inbox|next|doing|waiting|done","priority":"low|normal|high","due":"RFC3339 oder leer","confidence":0.0}]}. Maximal 30 Karten, nur bestehende taskId, confidence >= 0.70.` + a.trace(ctx, "kanban", "json_retry_request", "input", map[string]any{"reason": reason, "model": a.ai.Status().Model, "context": kanbanContext}) + started = time.Now() + raw, retryErr := a.ai.Chat(ctx, retrySystem, kanbanContext, true) + if retryErr == nil { + a.traceTimed(ctx, "kanban", "json_retry_response", "output", map[string]any{"raw": raw}, started) + if plan, e := parseKanbanPlanText(raw); e == nil { + return normalizeKanbanPlan(plan), "json-retry", nil + } else { + a.traceErr(ctx, "kanban", "json_retry_parse", map[string]any{"raw": raw}, e, time.Now()) + } + } else { + a.traceErr(ctx, "kanban", "json_retry_response", nil, retryErr, started) + } + + // Safe fallback: do not fail the UI because a model emitted empty/truncated JSON. + // Task cards have already been synchronized; preserve their current columns. + plan := deterministicKanbanPlan(a.db.Snapshot()) + a.trace(ctx, "kanban", "deterministic_fallback", "output", map[string]any{"cards": plan.Cards, "reason": "model returned no usable structured plan"}) + return plan, "deterministic-fallback", nil +} + +func normalizeKanbanPlan(plan kanbanPlan) kanbanPlan { + if len(plan.Cards) > 30 { + plan.Cards = plan.Cards[:30] + } + out := make([]kanbanSuggestion, 0, len(plan.Cards)) + seen := map[string]bool{} + for _, c := range plan.Cards { + c.TaskID = strings.TrimSpace(c.TaskID) + if c.TaskID == "" || seen[c.TaskID] || c.Confidence < .70 { + continue + } + seen[c.TaskID] = true + c.Column = normalizeColumn(c.Column) + c.Priority = normalizePriority(c.Priority) + c.Title = strings.TrimSpace(c.Title) + c.Notes = strings.TrimSpace(c.Notes) + out = append(out, c) + } + plan.Cards = out + return plan +} + +func parseKanbanPlanText(raw string) (kanbanPlan, error) { + var plan kanbanPlan + s := strings.TrimSpace(raw) + if s == "" { + return plan, fmt.Errorf("leere Modellantwort") + } + if strings.HasPrefix(s, "```") { + lines := strings.Split(s, "\n") + if len(lines) > 1 { + lines = lines[1:] + } + if len(lines) > 0 && strings.HasPrefix(strings.TrimSpace(lines[len(lines)-1]), "```") { + lines = lines[:len(lines)-1] + } + s = strings.TrimSpace(strings.Join(lines, "\n")) + } + if err := json.Unmarshal([]byte(s), &plan); err == nil { + return plan, nil + } + candidate := firstJSONObject(s) + if candidate == "" { + return plan, fmt.Errorf("kein vollständiges JSON-Objekt in Modellantwort") + } + if err := json.Unmarshal([]byte(candidate), &plan); err != nil { + return plan, fmt.Errorf("JSON konnte nicht geparst werden: %w", err) + } + return plan, nil +} + +func firstJSONObject(s string) string { + start := strings.IndexByte(s, '{') + if start < 0 { + return "" + } + depth := 0 + inString := false + escaped := false + for i := start; i < len(s); i++ { + ch := s[i] + if inString { + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + if ch == '"' { + inString = false + } + continue + } + if ch == '"' { + inString = true + continue + } + switch ch { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[start : i+1] + } + } + } + return "" +} + +func deterministicKanbanPlan(st store.State) kanbanPlan { + byTask := map[string]store.KanbanCard{} + for _, c := range st.Kanban { + if c.TaskID != "" && c.AIManaged { + byTask[c.TaskID] = c + } + } + cards := make([]kanbanSuggestion, 0, len(st.Tasks)) + for _, t := range st.Tasks { + col := "next" + if t.Status == "done" { + col = "done" + } else if c, ok := byTask[t.ID]; ok && c.Column != "" { + col = normalizeColumn(c.Column) + } + cards = append(cards, kanbanSuggestion{TaskID: t.ID, Title: t.Title, Notes: t.Notes, Column: col, Priority: normalizePriority(t.Priority), Due: t.Due, Confidence: 1}) + if len(cards) >= 30 { + break + } + } + return kanbanPlan{Cards: cards} +} + +func (a *app) buildKanbanContext(st store.State) string { + var b strings.Builder + now := time.Now().In(a.timezone) + fmt.Fprintf(&b, "ZEIT: %s\nAUFGABEN:\n", now.Format(time.RFC3339)) + for i, t := range st.Tasks { + if i >= 80 { + break + } + fmt.Fprintf(&b, "- id=%s | %s | status=%s | prio=%s | due=%s | notes=%s\n", t.ID, t.Title, t.Status, t.Priority, t.Due, t.Notes) + } + b.WriteString("\nNÄCHSTE TERMINE:\n") + count := 0 + for _, e := range sortedEvents(st.Events) { + d := parseDateForContext(e.Start, a.timezone) + if !d.IsZero() && d.Before(now.Add(-12*time.Hour)) { + continue + } + fmt.Fprintf(&b, "- %s | %s | %s\n", e.Start, e.Title, e.Location) + count++ + if count >= 16 { + break + } + } + b.WriteString("\nREMINDER:\n") + count = 0 + for _, r := range st.Reminders { + if !r.Done { + fmt.Fprintf(&b, "- %s | %s\n", r.At, r.Title) + count++ + } + if count >= 20 { + break + } + } + return b.String() +} + +func parseDateForContext(v string, loc *time.Location) time.Time { + if loc == nil { + loc = time.Local + } + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t.In(loc) + } + if t, err := time.ParseInLocation("2006-01-02", v, loc); err == nil { + return t + } + return time.Time{} +} + +func looksActionable(s string) bool { + x := foldCommand(stripModuleContext(s)) + for _, k := range []string{"fuege", "trag", "trage", "erinnere", "plane", "planen", "erstelle", "mach eine aufgabe", "auf die liste", "einkauf", "termin", "kalender", "rezept speichern", "speichere", "essen wir", "essensplan", "kanban", "loesch", "entfern", "moechte", "ich will"} { + if strings.Contains(x, k) { + return true + } + } + return looksImplicitMealPlanAction(s) +} + +// looksImplicitMealPlanAction recognizes natural household phrasing that carries +// a clear date + meal slot + dish but no imperative verb, e.g. +// "Sonntag wären Pommes Currywurst toll. Also zu Mittag". +// Information questions remain outside the mutation path. +func looksImplicitMealPlanAction(s string) bool { + raw := stripModuleContext(s) + x := foldCommand(raw) + if strings.TrimSpace(x) == "" { + return false + } + for _, q := range []string{"was gibt es", "was gibts", "welches essen", "welche mahlzeit", "was steht", "zeige mir", "wann gibt es", "habe ich", "ist geplant", "sind geplant"} { + if strings.Contains(x, q) { + return false + } + } + if strings.Contains(raw, "?") && !strings.Contains(x, "kannst du") && !strings.Contains(x, "koenntest du") { + return false + } + if _, ok := explicitMealFromMessage(raw); !ok { + return false + } + hasDate := weekdayTextRE.MatchString(raw) || relativeTextRE.MatchString(raw) || germanDateRE.MatchString(raw) || isoDateRE.MatchString(raw) + if !hasDate { + return false + } + return strings.TrimSpace(extractFallbackMealTitle(raw)) != "" +} + +func actionIntentHint(msg string) string { + x := strings.ToLower(msg) + containsAny := func(words ...string) bool { + for _, w := range words { + if strings.Contains(x, w) { + return true + } + } + return false + } + // Module-Kontext hat Vorrang, damit ein kurzer Voice-Befehl wie + // "Zahnarzt morgen 15 Uhr" am Kalender-Mikrofon eindeutig bleibt. + if containsAny("kontext des moduls kalender", "[kalender]") { + return "event" + } + if containsAny("kontext des moduls erinnerungen", "[erinnerungen]") { + return "reminder" + } + if containsAny("kontext des moduls aufgaben", "[aufgaben]") { + return "task" + } + if containsAny("kontext des moduls essensplanung", "kontext des moduls essensplan", "[essensplanung]") { + return "meal_plan" + } + if containsAny("kontext des moduls einkauf", "[einkauf]") { + return "grocery" + } + if containsAny("kontext des moduls rezepte", "kontext des moduls rezept", "[rezepte]") { + return "recipe" + } + if containsAny("erinnere mich", "erinnerung", "reminder") { + return "reminder" + } + if containsAny("termin", "kalender", "meeting", "treffen", "verabredung", "arzttermin", "zahnarzt", "arzt ") { + return "event" + } + if containsAny("einkauf", "einkaufsliste", "auf die liste") { + return "grocery" + } + if containsAny("essensplan", "essen wir", "mahlzeit", "abendessen", "mittagessen", "frühstück", "fruehstueck", "zum abend", "zum mittag", "zu abend", "zu mittag") { + return "meal_plan" + } + if containsAny("rezept") { + return "recipe" + } + if containsAny("aufgabe", "to-do", "todo", "erledigen") { + return "task" + } + return "auto" +} + +func (a *app) normalizePlannedAction(ac commandAction, intent string) commandAction { + // Kleine lokale Modelle verwechseln Termin und Task gelegentlich. Ein starker + // Kalender-Hinweis darf deshalb ein add_task deterministisch zu add_event + // korrigieren. Dabei wird ein eventuell als due geliefertes Datum übernommen. + if intent == "event" && ac.Type == "add_task" { + ac.Type = "add_event" + if strings.TrimSpace(ac.Start) == "" { + ac.Start = ac.Due + } + ac.Due = "" + } + if intent == "reminder" && ac.Type == "add_task" { + ac.Type = "add_reminder" + if strings.TrimSpace(ac.At) == "" { + ac.At = ac.Due + } + ac.Due = "" + } + + switch ac.Type { + case "add_event": + if strings.TrimSpace(ac.Start) == "" && strings.TrimSpace(ac.Due) != "" { + ac.Start = ac.Due + } + if start, allDay, ok := normalizeLocalDateTime(ac.Start, a.timezone); ok { + ac.Start = start + ac.AllDay = ac.AllDay || allDay + } + if end, _, ok := normalizeLocalDateTime(ac.End, a.timezone); ok { + ac.End = end + } else { + ac.End = "" + } + case "add_reminder": + if at, _, ok := normalizeLocalDateTime(ac.At, a.timezone); ok { + ac.At = at + } else if at, _, ok := normalizeLocalDateTime(ac.Due, a.timezone); ok { + ac.At = at + } + case "add_task": + if due, _, ok := normalizeLocalDateTime(ac.Due, a.timezone); ok { + ac.Due = due + } + } + return ac +} + +func normalizeLocalDateTime(raw string, loc *time.Location) (string, bool, bool) { + v := strings.TrimSpace(raw) + if v == "" { + return "", false, false + } + if loc == nil { + loc = time.Local + } + if t, err := time.Parse(time.RFC3339, v); err == nil { + return t.In(loc).Format(time.RFC3339), false, true + } + for _, layout := range []string{ + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + } { + if t, err := time.ParseInLocation(layout, v, loc); err == nil { + return t.Format(time.RFC3339), false, true + } + } + if t, err := time.ParseInLocation("2006-01-02", v, loc); err == nil { + return t.Format(time.RFC3339), true, true + } + return "", false, false +} + +// Deprecated: the production chat path no longer calls this v5 planner. It is +// retained temporarily for regression coverage and data-migration compatibility; +// all v6 chat mutations go through runToolAgent -> toolRegistry.Execute. +func (a *app) planAndApplyExplicitActions(ctx context.Context, msg string) ([]automationResult, error) { + if !looksActionable(msg) { + a.trace(ctx, "planner", "actionability", "output", map[string]any{"message": msg, "actionable": false}) + return nil, nil + } + st := a.db.Snapshot() + intent := actionIntentHint(msg) + now := time.Now().In(a.timezone) + + // High-confidence household commands should not depend on model compliance. + // For a meal command with an explicit date AND meal slot, Go can resolve the + // temporal semantics safely, link an existing recipe and persist immediately. + // The LLM remains the fallback for incomplete/complex formulations. + if intent == "meal_plan" { + raw := stripModuleContext(msg) + _, hasDate := explicitOrRelativeDate(raw, now) + _, hasMeal := explicitMealFromMessage(raw) + if hasDate && hasMeal { + if ac, ok := a.deterministicMealPlanFallback(msg); ok { + a.trace(ctx, "planner", "deterministic_fast_path", "output", map[string]any{"kind": "meal_plan", "action": ac, "reason": "explicit_date_and_meal"}) + if res, applied := a.applyCommandAction(ac); applied { + results := []automationResult{res} + a.activity("automation", "Essensplan deterministisch ausgeführt") + a.runHomeAutomations(traceBackground(ctx), "command-fast-path") + a.trace(ctx, "planner", "completed", "output", map[string]any{"results": results, "path": "deterministic_fast_path"}) + return results, nil + } + } + } + } + + system := `Du bist der Action-Planner von JARVIS. Übersetze Änderungsabsichten des Nutzers in sichere Home-Dashboard-Aktionen. Fragen und reine Informationswünsche erzeugen KEINE Aktion. Erfinde nichts. Erlaubte Typen: add_task, add_event, add_reminder, add_grocery, add_meal_plan, add_recipe. + +SEHR WICHTIGE TRENNUNG: +- Ein persönlicher Termin mit konkretem Datum/relativem Tag und/oder Uhrzeit (auch formuliert als "Ich habe morgen ...") => add_event, NIEMALS add_task. +- Termin, Kalender, Meeting, Treffen, Verabredung, Arzt/Zahnarzt => add_event. +- Aufgabe, To-do, erledigen, machen => add_task. +- "Erinnere mich ..." => add_reminder. +- Wenn der Modulkontext Kalender ist, ist das Ergebnis add_event. +- Existiert derselbe Eintrag bereits mit demselben Zeitpunkt, gib KEINE neue Aktion zurück. +- Verweist der Nutzer nur auf "den Termin" oder einen bereits vorhandenen Termin, ohne einen neuen Zeitpunkt zu nennen, erzeuge KEIN Duplikat. + +GESPRÄCHSKONTEXT: +- Die letzten relevanten user/assistant-Turns werden als echtes Chat-History-Fenster mitgeliefert. Nutze sie für Pronomen und Korrekturen, aber der HOME-STATE ist autoritativ. +- Wochentage/relative Tage im AKTUELLEN NUTZERBEFEHL sind wichtiger als ältere Datumsangaben im Verlauf. + +EXAKTES JSON-SCHEMA: +- Der Aktionstyp steht IMMER im Feld "type" (niemals "action"). +- add_meal_plan verwendet "date" (YYYY-MM-DD) und "meal" ("breakfast"|"lunch"|"dinner"|"snack"), niemals "meal_plan_date". +- Vorhandene Rezepte über "recipeId" und optional "recipeTitle" referenzieren. +- add_event: "title" und "start" sind Pflicht; start/end RFC3339 mit lokaler Zeitzone. Nur Datum ohne Uhrzeit => "allDay": true. +- add_task: "title", optional "due". +- add_reminder: "title" und "at". +- add_grocery: "name", optional quantity/unit/category. +- add_recipe: "title" und möglichst ingredients/steps. +Gib ausschließlich JSON {"actions":[...]}. Jede Aktion braucht confidence 0..1; nur >=0.80.` + history := conversationHistory(st.Chat, msg, a.context.PlannerHistoryTokens) + stateIndex := trimToTokenBudget(plannerStateIndex(st), a.context.StateTokens) + recipes := trimToTokenBudget(recipeIndex(st.Recipes), maxInt(400, a.context.StateTokens/3)) + prompt := fmt.Sprintf("AKTUELLE LOKALE ZEIT: %s\nZEITZONE: %s\nINTENT-HINWEIS DES ROUTERS: %s\nVORHANDENER HOME-STATE (AUTORITATIV):\n%s\nVORHANDENE REZEPTE:\n%s\nAKTUELLER NUTZERBEFEHL:\n%s", now.Format(time.RFC3339), a.timezone.String(), intent, stateIndex, recipes, msg) + a.trace(ctx, "planner", "request", "input", map[string]any{"message": msg, "intent_hint": intent, "model": a.ai.Status().Model, "options": a.ai.GenerationOptions(true), "system": system, "history": history, "home_state": stateIndex, "recipes": recipes, "prompt": prompt, "history_estimated_tokens": estimateHistoryTokens(history), "prompt_estimated_tokens": estimateTokens(prompt)}) + plannerStart := time.Now() + raw, err := a.ai.ChatWithHistory(ctx, system, history, prompt, true) + if err != nil { + a.traceErr(ctx, "planner", "raw_output", nil, err, plannerStart) + if ac, ok := a.deterministicCalendarFallback(msg); ok { + a.trace(ctx, "planner", "fallback", "output", map[string]any{"kind": "calendar", "action": ac}) + if res, applied := a.applyCommandAction(ac); applied { + results := []automationResult{res} + a.activity("automation", "Kalender-Fallback ohne LLM ausgeführt") + a.runHomeAutomations(traceBackground(ctx), "command-fallback") + a.trace(ctx, "planner", "completed", "output", map[string]any{"results": results}) + return results, nil + } + } + if ac, ok := a.deterministicMealPlanFallback(msg); ok { + a.trace(ctx, "planner", "fallback", "output", map[string]any{"kind": "meal_plan", "action": ac}) + if res, applied := a.applyCommandAction(ac); applied { + results := []automationResult{res} + a.activity("automation", "Essensplan-Fallback ohne LLM ausgeführt") + a.runHomeAutomations(traceBackground(ctx), "command-fallback") + a.trace(ctx, "planner", "completed", "output", map[string]any{"results": results}) + return results, nil + } + } + return nil, err + } + a.traceTimed(ctx, "planner", "raw_output", "output", map[string]any{"raw": raw, "model": a.ai.Status().Model}, plannerStart) + var plan commandPlan + if err := json.Unmarshal([]byte(raw), &plan); err != nil { + a.traceErr(ctx, "planner", "parse", map[string]any{"raw": raw}, err, time.Now()) + if ac, ok := a.deterministicCalendarFallback(msg); ok { + a.trace(ctx, "planner", "fallback", "output", map[string]any{"kind": "calendar_after_empty_plan", "action": ac}) + if res, applied := a.applyCommandAction(ac); applied { + results := []automationResult{res} + a.runHomeAutomations(traceBackground(ctx), "command-fallback") + a.trace(ctx, "planner", "completed", "output", map[string]any{"results": results}) + return results, nil + } + } + return nil, fmt.Errorf("Action JSON ungültig: %w", err) + } + a.trace(ctx, "planner", "parsed", "output", plan) + results := []automationResult{} + for _, ac := range plan.Actions { + original := ac + if ac.Confidence < .80 { + a.trace(ctx, "planner", "action_rejected", "output", map[string]any{"reason": "confidence_below_threshold", "action": ac}) + continue + } + normalized := a.normalizePlannedAction(ac, intent) + corrected := a.correctTemporalFromMessage(normalized, msg) + a.trace(ctx, "planner", "action_normalized", "output", map[string]any{"original": original, "normalized": normalized, "corrected": corrected}) + if res, ok := a.applyCommandAction(corrected); ok { + results = append(results, res) + a.trace(ctx, "persistence", "action_applied", "output", map[string]any{"action": corrected, "result": res}) + } else { + a.trace(ctx, "persistence", "action_rejected", "output", map[string]any{"action": corrected}) + } + } + if len(results) == 0 { + if ac, ok := a.deterministicCalendarFallback(msg); ok { + if res, applied := a.applyCommandAction(ac); applied { + results = append(results, res) + } + } else if ac, ok := a.deterministicMealPlanFallback(msg); ok { + a.trace(ctx, "planner", "fallback", "output", map[string]any{"kind": "meal_plan_after_empty_plan", "action": ac}) + if res, applied := a.applyCommandAction(ac); applied { + results = append(results, res) + } + } + } + if len(results) > 0 { + a.activity("automation", fmt.Sprintf("Sprach/Text-Automation: %d Aktion(en)", len(results))) + a.runHomeAutomations(traceBackground(ctx), "command") + go a.runKanbanAI(traceBackground(ctx), "command") + } + a.trace(ctx, "planner", "completed", "output", map[string]any{"results": results}) + return results, nil +} + +func (a *app) applyCommandAction(ac commandAction) (automationResult, bool) { + now := store.Now() + switch ac.Type { + case "add_task": + if strings.TrimSpace(ac.Title) == "" { + return automationResult{}, false + } + for _, existing := range a.db.Snapshot().Tasks { + if existing.Status != "done" && normalizeTask(existing.Title) == normalizeTask(ac.Title) { + return automationResult{Type: "task_exists", ID: existing.ID, Message: "Aufgabe bereits vorhanden: " + existing.Title}, true + } + } + v := store.Task{ID: id("task_ai"), Title: strings.TrimSpace(ac.Title), Notes: ac.Notes, Due: validOrEmptyTime(ac.Due), Status: "open", Priority: normalizePriority(ac.Priority), Source: "jarvis-command", Confidence: ac.Confidence, AIManaged: true, CreatedAt: now, UpdatedAt: now} + _ = a.db.Update(func(s *store.State) error { s.Tasks = append([]store.Task{v}, s.Tasks...); return nil }) + return automationResult{Type: ac.Type, ID: v.ID, Message: "Aufgabe angelegt: " + v.Title}, true + case "add_event": + if strings.TrimSpace(ac.Title) == "" || !validRFC3339(ac.Start) { + return automationResult{}, false + } + v := store.CalendarEvent{ID: id("event_ai"), Title: strings.TrimSpace(ac.Title), Start: ac.Start, End: validOrEmptyTime(ac.End), AllDay: ac.AllDay, Location: ac.Location, Notes: ac.Notes, Source: "jarvis-command", AIManaged: true, CreatedAt: now, UpdatedAt: now} + for _, existing := range a.db.Snapshot().Events { + if eventEquivalent(existing, v, a.timezone) { + return automationResult{Type: "event_exists", ID: existing.ID, Message: "Termin bereits vorhanden: " + existing.Title}, true + } + } + _ = a.db.Update(func(s *store.State) error { s.Events = append(s.Events, v); return nil }) + return automationResult{Type: ac.Type, ID: v.ID, Message: "Termin angelegt: " + v.Title}, true + case "add_reminder": + if strings.TrimSpace(ac.Title) == "" || !validRFC3339(ac.At) { + return automationResult{}, false + } + for _, existing := range a.db.Snapshot().Reminders { + if !existing.Done && normalizeTask(existing.Title) == normalizeTask(ac.Title) && existing.At == ac.At { + return automationResult{Type: "reminder_exists", ID: existing.ID, Message: "Erinnerung bereits vorhanden: " + existing.Title}, true + } + } + v := store.Reminder{ID: id("rem_ai"), Title: strings.TrimSpace(ac.Title), At: ac.At, Notes: ac.Notes, Source: "jarvis-command", AIManaged: true, CreatedAt: now, UpdatedAt: now} + _ = a.db.Update(func(s *store.State) error { s.Reminders = append(s.Reminders, v); return nil }) + return automationResult{Type: ac.Type, ID: v.ID, Message: "Erinnerung angelegt: " + v.Title}, true + case "add_grocery": + if strings.TrimSpace(ac.Name) == "" { + return automationResult{}, false + } + v := store.GroceryItem{ID: id("grocery_ai"), Name: strings.TrimSpace(ac.Name), Quantity: ac.Quantity, Unit: ac.Unit, Category: ac.Category, Source: "jarvis-command", AIManaged: true, CreatedAt: now, UpdatedAt: now} + _ = a.db.Update(func(s *store.State) error { s.Groceries = append(s.Groceries, v); return nil }) + return automationResult{Type: ac.Type, ID: v.ID, Message: "Einkauf ergänzt: " + v.Name}, true + case "add_meal_plan": + if !validDate(ac.Date) { + return automationResult{}, false + } + rid := ac.RecipeID + title := strings.TrimSpace(ac.RecipeTitle) + st := a.db.Snapshot() + if rid == "" && title != "" { + for _, r := range st.Recipes { + if strings.EqualFold(r.Title, title) { + rid = r.ID + title = r.Title + break + } + } + } + if title == "" && rid != "" { + for _, r := range st.Recipes { + if r.ID == rid { + title = r.Title + break + } + } + } + if title == "" { + title = strings.TrimSpace(ac.Title) + } + if title == "" { + return automationResult{}, false + } + serv := ac.Servings + if serv <= 0 { + serv = 2 + } + v := store.MealPlan{ID: id("meal_ai"), Date: ac.Date, Meal: normalizeMeal(ac.Meal), RecipeID: rid, Title: title, Servings: serv, Notes: ac.Notes, AIManaged: true, Source: "jarvis-command", CreatedAt: now, UpdatedAt: now} + // Ein Datum + Mahlzeit ist ein Slot. Ein neuer Sprachbefehl für denselben + // Slot aktualisiert ihn statt still ein zweites Essen daneben zu legen. + for _, existing := range st.MealPlans { + if existing.Date == v.Date && normalizeMeal(existing.Meal) == v.Meal { + v.ID = existing.ID + v.CreatedAt = existing.CreatedAt + _ = a.db.Update(func(s *store.State) error { upsertMealPlan(s, v); return nil }) + return automationResult{Type: "meal_updated", ID: v.ID, Message: "Essensplan aktualisiert: " + v.Title}, true + } + } + _ = a.db.Update(func(s *store.State) error { s.MealPlans = append(s.MealPlans, v); return nil }) + return automationResult{Type: ac.Type, ID: v.ID, Message: "Essensplan ergänzt: " + v.Title}, true + case "add_recipe": + if strings.TrimSpace(ac.Title) == "" { + return automationResult{}, false + } + v := store.Recipe{ID: id("recipe_ai"), Title: strings.TrimSpace(ac.Title), Description: ac.Description, Servings: ac.Servings, PrepMinutes: ac.PrepMinutes, Ingredients: ac.Ingredients, Steps: ac.Steps, Tags: ac.Tags, Source: "jarvis-command", AIManaged: true, CreatedAt: now, UpdatedAt: now} + if v.Servings <= 0 { + v.Servings = 2 + } + _ = a.db.Update(func(s *store.State) error { s.Recipes = append(s.Recipes, v); return nil }) + return automationResult{Type: ac.Type, ID: v.ID, Message: "Rezept gespeichert: " + v.Title}, true + } + return automationResult{}, false +} + +func homeStateContext(st store.State) string { + var b strings.Builder + b.WriteString("\nKALENDER:\n") + for _, e := range sortedEvents(st.Events) { + fmt.Fprintf(&b, "- %s | %s | %s\n", e.Start, e.Title, e.Location) + } + b.WriteString("\nERINNERUNGEN:\n") + for _, r := range st.Reminders { + if !r.Done { + fmt.Fprintf(&b, "- %s | %s\n", r.At, r.Title) + } + } + b.WriteString("\nEINKAUF:\n") + for _, g := range st.Groceries { + if !g.Checked { + fmt.Fprintf(&b, "- %s %s %s\n", g.Quantity, g.Unit, g.Name) + } + } + b.WriteString("\nESSENSPLAN:\n") + for _, m := range st.MealPlans { + fmt.Fprintf(&b, "- %s %s | %s | %d Portionen\n", m.Date, m.Meal, m.Title, m.Servings) + } + b.WriteString("\nREZEPTE:\n") + for _, r := range st.Recipes { + fmt.Fprintf(&b, "- %s | %d min | %d Portionen\n", r.Title, r.PrepMinutes, r.Servings) + } + b.WriteString("\nKANBAN:\n") + for _, c := range st.Kanban { + if c.Column != "done" { + fmt.Fprintf(&b, "- %s | %s | %s\n", c.Column, c.Title, c.Priority) + } + } + return b.String() +} + +func recipeIndex(recipes []store.Recipe) string { + var b strings.Builder + for _, r := range recipes { + fmt.Fprintf(&b, "- id=%s | %s | servings=%d\n", r.ID, r.Title, r.Servings) + } + return b.String() +} + +func plannerStateIndex(st store.State) string { + var b strings.Builder + b.WriteString("KALENDER:\n") + for _, e := range sortedEvents(st.Events) { + fmt.Fprintf(&b, "- id=%s | start=%s | title=%s | location=%s\n", e.ID, e.Start, e.Title, e.Location) + } + b.WriteString("AUFGABEN:\n") + for _, t := range st.Tasks { + if t.Status != "done" { + fmt.Fprintf(&b, "- id=%s | due=%s | title=%s\n", t.ID, t.Due, t.Title) + } + } + b.WriteString("ERINNERUNGEN:\n") + for _, r := range st.Reminders { + if !r.Done { + fmt.Fprintf(&b, "- id=%s | at=%s | title=%s | linked=%s:%s\n", r.ID, r.At, r.Title, r.LinkedType, r.LinkedID) + } + } + return b.String() +} +func upsertEvent(s *store.State, v store.CalendarEvent) { + for i := range s.Events { + if s.Events[i].ID == v.ID { + if v.CreatedAt == "" { + v.CreatedAt = s.Events[i].CreatedAt + } + s.Events[i] = v + return + } + } + s.Events = append(s.Events, v) +} +func upsertReminder(s *store.State, v store.Reminder) { + for i := range s.Reminders { + if s.Reminders[i].ID == v.ID { + if v.CreatedAt == "" { + v.CreatedAt = s.Reminders[i].CreatedAt + } + s.Reminders[i] = v + return + } + } + s.Reminders = append(s.Reminders, v) +} +func upsertGrocery(s *store.State, v store.GroceryItem) { + for i := range s.Groceries { + if s.Groceries[i].ID == v.ID { + if v.CreatedAt == "" { + v.CreatedAt = s.Groceries[i].CreatedAt + } + s.Groceries[i] = v + return + } + } + s.Groceries = append(s.Groceries, v) +} +func upsertRecipe(s *store.State, v store.Recipe) { + for i := range s.Recipes { + if s.Recipes[i].ID == v.ID { + if v.CreatedAt == "" { + v.CreatedAt = s.Recipes[i].CreatedAt + } + s.Recipes[i] = v + return + } + } + s.Recipes = append(s.Recipes, v) +} +func upsertMealPlan(s *store.State, v store.MealPlan) { + for i := range s.MealPlans { + if s.MealPlans[i].ID == v.ID { + if v.CreatedAt == "" { + v.CreatedAt = s.MealPlans[i].CreatedAt + } + s.MealPlans[i] = v + return + } + } + s.MealPlans = append(s.MealPlans, v) +} +func upsertKanban(s *store.State, v store.KanbanCard) { + for i := range s.Kanban { + if s.Kanban[i].ID == v.ID { + if v.CreatedAt == "" { + v.CreatedAt = s.Kanban[i].CreatedAt + } + s.Kanban[i] = v + return + } + } + s.Kanban = append(s.Kanban, v) +} +func filterEvents(xs []store.CalendarEvent, id string) []store.CalendarEvent { + o := xs[:0] + for _, x := range xs { + if x.ID != id { + o = append(o, x) + } + } + return append([]store.CalendarEvent(nil), o...) +} +func filterReminders(xs []store.Reminder, id string) []store.Reminder { + o := xs[:0] + for _, x := range xs { + if x.ID != id { + o = append(o, x) + } + } + return append([]store.Reminder(nil), o...) +} +func filterLinkedReminders(xs []store.Reminder, typ, id string) []store.Reminder { + o := xs[:0] + for _, x := range xs { + if !(x.LinkedType == typ && x.LinkedID == id && x.AIManaged) { + o = append(o, x) + } + } + return append([]store.Reminder(nil), o...) +} +func filterGroceries(xs []store.GroceryItem, id string) []store.GroceryItem { + o := xs[:0] + for _, x := range xs { + if x.ID != id { + o = append(o, x) + } + } + return append([]store.GroceryItem(nil), o...) +} +func filterRecipes(xs []store.Recipe, id string) []store.Recipe { + o := xs[:0] + for _, x := range xs { + if x.ID != id { + o = append(o, x) + } + } + return append([]store.Recipe(nil), o...) +} +func filterMealPlans(xs []store.MealPlan, id string) []store.MealPlan { + o := xs[:0] + for _, x := range xs { + if x.ID != id { + o = append(o, x) + } + } + return append([]store.MealPlan(nil), o...) +} +func filterKanban(xs []store.KanbanCard, id string) []store.KanbanCard { + o := xs[:0] + for _, x := range xs { + if x.ID != id { + o = append(o, x) + } + } + return append([]store.KanbanCard(nil), o...) +} +func validRFC3339(v string) bool { + _, e := time.Parse(time.RFC3339, strings.TrimSpace(v)) + return e == nil +} +func validDate(v string) bool { + _, e := time.Parse("2006-01-02", strings.TrimSpace(v)) + return e == nil +} +func validTimeOrDate(v string) bool { return validRFC3339(v) || validDate(v) } +func validOrEmptyTime(v string) string { + if validRFC3339(v) { + return v + } + return "" +} +func parseTimeOrDate(v string, allDay bool) (time.Time, error) { + if allDay || validDate(v) { + d, e := time.ParseInLocation("2006-01-02", v, time.Local) + return d, e + } + return time.Parse(time.RFC3339, v) +} +func normalizePriority(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "low": + return "low" + case "high": + return "high" + default: + return "normal" + } +} +func normalizeColumn(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "inbox", "next", "doing", "waiting", "done": + return strings.ToLower(strings.TrimSpace(v)) + default: + return "inbox" + } +} +func normalizeMeal(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "breakfast", "frühstück", "fruehstueck": + return "breakfast" + case "lunch", "mittag", "mittagessen": + return "lunch" + case "snack": + return "snack" + default: + return "dinner" + } +} +func sortedEvents(xs []store.CalendarEvent) []store.CalendarEvent { + o := append([]store.CalendarEvent(nil), xs...) + sort.Slice(o, func(i, j int) bool { return o[i].Start < o[j].Start }) + return o +} +func scaleQuantity(q string, f float64) string { + q = strings.TrimSpace(q) + if q == "" || f == 1 { + return q + } + n, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64) + if err != nil { + return q + } + return strconv.FormatFloat(n*f, 'f', -1, 64) +} +func mergeQuantity(a, b string) string { + af, ae := strconv.ParseFloat(strings.ReplaceAll(a, ",", "."), 64) + bf, be := strconv.ParseFloat(strings.ReplaceAll(b, ",", "."), 64) + if ae == nil && be == nil { + return strconv.FormatFloat(af+bf, 'f', -1, 64) + } + if a == "" { + return b + } + if b == "" { + return a + } + return a + " + " + b +} +func maxFloat(a, b float64) float64 { + if a > b { + return a + } + return b +} +func minInt(a, b int) int { + if a < b { + return a + } + return b +} +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/cmd/homehub/home_test.go b/cmd/homehub/home_test.go new file mode 100644 index 0000000..9f3845f --- /dev/null +++ b/cmd/homehub/home_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "encoding/json" + "path/filepath" + "testing" + "time" + + "homehub/internal/store" +) + +func berlin(t *testing.T) *time.Location { + t.Helper() + loc, err := time.LoadLocation("Europe/Berlin") + if err != nil { + t.Fatal(err) + } + return loc +} + +func TestActionIntentHintCalendar(t *testing.T) { + cases := []string{ + "Trage morgen um 15 Uhr einen Termin beim Zahnarzt ein", + "Im Kontext des Moduls Kalender: Zahnarzt morgen 15 Uhr", + "Plane ein Meeting am Freitag um 10 Uhr", + } + for _, in := range cases { + if got := actionIntentHint(in); got != "event" { + t.Fatalf("%q: got %q, want event", in, got) + } + } +} + +func TestNormalizeLocalDateTimeWithoutOffset(t *testing.T) { + got, allDay, ok := normalizeLocalDateTime("2026-08-28T15:00:00", berlin(t)) + if !ok || allDay { + t.Fatalf("unexpected flags ok=%v allDay=%v", ok, allDay) + } + if got != "2026-08-28T15:00:00+02:00" { + t.Fatalf("got %q", got) + } +} + +func TestNormalizeCalendarTaskMisclassification(t *testing.T) { + a := &app{timezone: berlin(t)} + in := commandAction{Type: "add_task", Title: "Zahnarzt", Due: "2026-08-28T15:00", Confidence: .95} + got := a.normalizePlannedAction(in, "event") + if got.Type != "add_event" { + t.Fatalf("type=%q", got.Type) + } + if got.Start != "2026-08-28T15:00:00+02:00" { + t.Fatalf("start=%q", got.Start) + } + if got.Due != "" { + t.Fatalf("due should be cleared, got %q", got.Due) + } +} + +func TestCalendarIntentCreatesEventNotTask(t *testing.T) { + db, err := store.New(filepath.Join(t.TempDir(), "store.json")) + if err != nil { + t.Fatal(err) + } + a := &app{db: db, timezone: berlin(t)} + ac := commandAction{Type: "add_task", Title: "Zahnarzt", Due: "2026-08-28T15:00", Confidence: .95} + ac = a.normalizePlannedAction(ac, actionIntentHint("Im Kontext des Moduls Kalender: Zahnarzt morgen 15 Uhr")) + if _, ok := a.applyCommandAction(ac); !ok { + t.Fatal("calendar action was rejected") + } + st := db.Snapshot() + if len(st.Events) != 1 { + t.Fatalf("events=%d, want 1", len(st.Events)) + } + if len(st.Tasks) != 0 { + t.Fatalf("tasks=%d, want 0", len(st.Tasks)) + } + if st.Events[0].Start != "2026-08-28T15:00:00+02:00" { + t.Fatalf("start=%q", st.Events[0].Start) + } +} + +func TestPlannerAcceptsLocalModelSchemaAliases(t *testing.T) { + raw := `{"actions":[{"action":"add_meal_plan","confidence":1.0,"meal_plan_date":"2026-08-30","recipeId":"recipe_1","recipeTitle":"Nudeln mit Tomatensauce"}]}` + var plan commandPlan + if err := json.Unmarshal([]byte(raw), &plan); err != nil { + t.Fatal(err) + } + if len(plan.Actions) != 1 { + t.Fatalf("actions=%d", len(plan.Actions)) + } + ac := plan.Actions[0] + if ac.Type != "add_meal_plan" || ac.Date != "2026-08-30" || ac.RecipeID != "recipe_1" { + t.Fatalf("parsed action=%+v", ac) + } +} + +func TestImplicitMealWishIsActionable(t *testing.T) { + msg := "Sonntag wären Pommes Currywurst toll. Also zu Mittag" + if !looksActionable(msg) { + t.Fatal("implicit meal wish must be routed to the action planner") + } + if got := actionIntentHint(msg); got != "meal_plan" { + t.Fatalf("intent=%q want meal_plan", got) + } + a := testApp(t) + ac, ok := a.deterministicMealPlanFallback(msg) + if !ok { + t.Fatal("deterministic meal fallback should understand the sentence") + } + if ac.Meal != "lunch" { + t.Fatalf("meal=%q want lunch", ac.Meal) + } + if ac.RecipeTitle != "Pommes Currywurst" { + t.Fatalf("title=%q want Pommes Currywurst", ac.RecipeTitle) + } +} + +func TestParseKanbanPlanTextAcceptsFencedJSON(t *testing.T) { + raw := "```json\n{\"cards\":[{\"taskId\":\"t1\",\"column\":\"next\",\"priority\":\"high\",\"confidence\":0.9}]}\n```" + plan, err := parseKanbanPlanText(raw) + if err != nil { + t.Fatal(err) + } + if len(plan.Cards) != 1 || plan.Cards[0].TaskID != "t1" || plan.Cards[0].Column != "next" { + t.Fatalf("plan=%+v", plan) + } +} + +func TestParseKanbanPlanTextExtractsJSONFromProse(t *testing.T) { + raw := "Hier ist der Plan: {\"cards\":[{\"taskId\":\"t2\",\"column\":\"doing\",\"confidence\":0.82}]} fertig." + plan, err := parseKanbanPlanText(raw) + if err != nil { + t.Fatal(err) + } + if len(plan.Cards) != 1 || plan.Cards[0].TaskID != "t2" { + t.Fatalf("plan=%+v", plan) + } +} + +func TestParseKanbanPlanTextRejectsEmptyAndTruncated(t *testing.T) { + if _, err := parseKanbanPlanText(""); err == nil { + t.Fatal("expected error for empty output") + } + if _, err := parseKanbanPlanText(`{"cards":[{"taskId":"t1"}`); err == nil { + t.Fatal("expected error for truncated output") + } +} + +func TestDeterministicKanbanPlanPreservesManagedColumn(t *testing.T) { + st := store.State{ + Tasks: []store.Task{{ID: "t1", Title: "Hausflur putzen", Status: "open", Priority: "high"}}, + Kanban: []store.KanbanCard{{ID: "c1", TaskID: "t1", Title: "Hausflur putzen", Column: "doing", AIManaged: true}}, + } + plan := deterministicKanbanPlan(st) + if len(plan.Cards) != 1 || plan.Cards[0].Column != "doing" || plan.Cards[0].TaskID != "t1" { + t.Fatalf("plan=%+v", plan) + } +} diff --git a/cmd/homehub/main.go b/cmd/homehub/main.go new file mode 100644 index 0000000..600238c --- /dev/null +++ b/cmd/homehub/main.go @@ -0,0 +1,952 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "homehub/internal/debugtrace" + "homehub/internal/documents" + "homehub/internal/ollama" + "homehub/internal/rag" + "homehub/internal/store" + "homehub/internal/ui" + "homehub/internal/voice" +) + +type app struct { + db *store.Store + ai *ollama.Client + rag *rag.Index + voice *voice.Pipeline + wakeWord string + timezone *time.Location + scoutMu sync.Mutex + kanbanMu sync.Mutex + commandMu sync.Mutex + pendingMu sync.Mutex + pending *pendingCommand + confirmMu sync.Mutex + confirm *pendingToolAction + context contextConfig + recentMu sync.Mutex + recent *recentMutation + tools *toolRegistry + debug *debugtrace.Logger +} + +type chatRequest struct { + Message string `json:"message"` +} +type chatResponse struct { + TraceID string `json:"traceId,omitempty"` + Reply string `json:"reply"` + Sources []rag.Hit `json:"sources"` + Automations []automationResult `json:"automations,omitempty"` + ToolResults []toolResult `json:"toolResults,omitempty"` + Plan *agentPlan `json:"plan,omitempty"` + Mode string `json:"mode,omitempty"` +} +type ragSearchRequest struct { + Query string `json:"query"` + Limit int `json:"limit"` +} +type speakRequest struct { + Text string `json:"text"` +} +type scoutOutput struct { + Tasks []scoutTask `json:"tasks"` +} +type scoutTask struct { + Title string `json:"title"` + Notes string `json:"notes"` + Due string `json:"due"` + Priority string `json:"priority"` + Source string `json:"source"` + Confidence float64 `json:"confidence"` +} + +type systemState struct { + store.State + System map[string]any `json:"system"` +} + +func main() { + addr := env("JARVIS_ADDR", env("HOMEHUB_ADDR", ":8080")) + dataPath := env("JARVIS_DATA", env("HOMEHUB_DATA", "./data/store.json")) + ragPath := env("JARVIS_RAG_DATA", "./data/rag.json") + ollamaURL := env("OLLAMA_URL", "http://localhost:11434") + ollamaModel := env("OLLAMA_MODEL", "auto") + embedModel := env("OLLAMA_EMBED_MODEL", "auto") + wakeWord := strings.TrimSpace(env("JARVIS_WAKE_WORD", "jarvis")) + timezoneName := strings.TrimSpace(env("JARVIS_TIMEZONE", env("TZ", "Europe/Berlin"))) + timezone, tzErr := time.LoadLocation(timezoneName) + if tzErr != nil { + log.Printf("Ungültige Zeitzone %q, verwende Europe/Berlin: %v", timezoneName, tzErr) + timezone, _ = time.LoadLocation("Europe/Berlin") + } + + ctxCfg := defaultContextConfig() + ctxCfg.NumCtx = envInt("OLLAMA_NUM_CTX", ctxCfg.NumCtx) + ctxCfg.NumPredict = envInt("OLLAMA_NUM_PREDICT", ctxCfg.NumPredict) + ctxCfg.Temperature = envFloat("OLLAMA_TEMPERATURE", ctxCfg.Temperature) + ctxCfg.PlannerNumCtx = envInt("OLLAMA_PLANNER_NUM_CTX", ctxCfg.PlannerNumCtx) + ctxCfg.PlannerNumPredict = envInt("OLLAMA_PLANNER_NUM_PREDICT", ctxCfg.PlannerNumPredict) + ctxCfg.PlannerTemperature = envFloat("OLLAMA_PLANNER_TEMPERATURE", ctxCfg.PlannerTemperature) + ctxCfg.AgentNumCtx = envInt("OLLAMA_AGENT_NUM_CTX", ctxCfg.AgentNumCtx) + ctxCfg.AgentNumPredict = envInt("OLLAMA_AGENT_NUM_PREDICT", ctxCfg.AgentNumPredict) + ctxCfg.AgentTemperature = envFloat("OLLAMA_AGENT_TEMPERATURE", ctxCfg.AgentTemperature) + ctxCfg.AgentMaxSteps = envInt("JARVIS_AGENT_MAX_STEPS", ctxCfg.AgentMaxSteps) + if ctxCfg.AgentMaxSteps < 2 { + ctxCfg.AgentMaxSteps = 2 + } + if ctxCfg.AgentMaxSteps > 12 { + ctxCfg.AgentMaxSteps = 12 + } + ctxCfg.HistoryTokens = envInt("JARVIS_HISTORY_TOKENS", ctxCfg.HistoryTokens) + ctxCfg.PlannerHistoryTokens = envInt("JARVIS_PLANNER_HISTORY_TOKENS", ctxCfg.PlannerHistoryTokens) + ctxCfg.StateTokens = envInt("JARVIS_STATE_TOKENS", ctxCfg.StateTokens) + ctxCfg.RAGTokens = envInt("JARVIS_RAG_TOKENS", ctxCfg.RAGTokens) + + debugLogger, debugErr := debugtrace.New(debugtrace.Config{ + Enabled: envBool("JARVIS_DEBUG_TRACE", true), + Dir: env("JARVIS_DEBUG_DIR", "./data/debug"), + MaxBytes: int64(envInt("JARVIS_DEBUG_MAX_MB", 25)) << 20, + KeepFiles: envInt("JARVIS_DEBUG_KEEP_FILES", 4), + MaxString: envInt("JARVIS_DEBUG_MAX_STRING", 24000), + LogHTTPState: envBool("JARVIS_DEBUG_HTTP_STATE", false), + }) + if debugErr != nil { + log.Printf("Debug-Trace konnte nicht initialisiert werden: %v", debugErr) + debugLogger, _ = debugtrace.New(debugtrace.Config{Enabled: false}) + } + + db, err := store.New(dataPath) + if err != nil { + log.Fatal(err) + } + idx, err := rag.New(ragPath) + if err != nil { + log.Fatal(err) + } + aiClient := ollama.New(ollamaURL, ollamaModel, embedModel) + aiClient.ConfigureGeneration(ctxCfg.chatOptions(), ctxCfg.plannerOptions()) + if debugLogger != nil { + aiClient.SetTraceHook(func(ctx context.Context, stage, direction string, data any, err error, duration time.Duration) { + level := "info" + if err != nil { + level = "error" + } + debugLogger.Record(ctx, "ollama", stage, direction, level, data, err, duration) + }) + } + resolveBase := debugtrace.WithTrace(context.Background(), debugtrace.NewTraceID("startup")) + resolveCtx, resolveCancel := context.WithTimeout(resolveBase, 4*time.Second) + modelStatus := aiClient.ResolveModels(resolveCtx) + resolveCancel() + if !modelStatus.Reachable { + log.Printf("Ollama noch nicht erreichbar; Home-Core startet im degradierten Modus: %s", modelStatus.LastError) + } else { + log.Printf("Ollama erkannt: chat=%q embed=%q fallback=%v installed=%v", modelStatus.Model, modelStatus.EmbeddingModel, modelStatus.Fallback, modelStatus.Installed) + } + a := &app{ + db: db, + ai: aiClient, + rag: idx, + wakeWord: wakeWord, + timezone: timezone, + context: ctxCfg, + debug: debugLogger, + voice: &voice.Pipeline{ + FFmpegBin: env("VOICE_FFMPEG_BIN", "ffmpeg"), + WhisperBin: env("VOICE_WHISPER_BIN", "whisper-cli"), + WhisperModel: env("VOICE_WHISPER_MODEL", "./models/ggml-small.bin"), + WhisperLanguage: env("VOICE_LANGUAGE", "de"), + PiperBin: env("VOICE_PIPER_BIN", "piper"), + PiperModel: env("VOICE_PIPER_MODEL", "./models/de_DE-thorsten-medium.onnx"), + }, + } + a.tools = newToolRegistry(a) + + mux := http.NewServeMux() + mux.HandleFunc("GET /api/state", a.handleState) + mux.HandleFunc("POST /api/tasks", a.handleTaskUpsert) + mux.HandleFunc("DELETE /api/tasks/{id}", a.handleTaskDelete) + mux.HandleFunc("POST /api/events", a.handleEventUpsert) + mux.HandleFunc("DELETE /api/events/{id}", a.handleEventDelete) + mux.HandleFunc("POST /api/reminders", a.handleReminderUpsert) + mux.HandleFunc("DELETE /api/reminders/{id}", a.handleReminderDelete) + mux.HandleFunc("POST /api/groceries", a.handleGroceryUpsert) + mux.HandleFunc("DELETE /api/groceries/{id}", a.handleGroceryDelete) + mux.HandleFunc("POST /api/recipes", a.handleRecipeUpsert) + mux.HandleFunc("DELETE /api/recipes/{id}", a.handleRecipeDelete) + mux.HandleFunc("POST /api/meal-plans", a.handleMealPlanUpsert) + mux.HandleFunc("DELETE /api/meal-plans/{id}", a.handleMealPlanDelete) + mux.HandleFunc("POST /api/kanban", a.handleKanbanUpsert) + mux.HandleFunc("DELETE /api/kanban/{id}", a.handleKanbanDelete) + mux.HandleFunc("POST /api/automation/run", a.handleAutomationRun) + mux.HandleFunc("POST /api/ai/kanban", a.handleKanbanAI) + mux.HandleFunc("POST /api/documents", a.handleDocumentUpload) + mux.HandleFunc("DELETE /api/documents/{id}", a.handleDocumentDelete) + mux.HandleFunc("POST /api/rag/search", a.handleRAGSearch) + mux.HandleFunc("POST /api/rag/reindex", a.handleRAGReindex) + mux.HandleFunc("POST /api/ai/chat", a.handleAIChat) + mux.HandleFunc("DELETE /api/chat", a.handleChatClear) + mux.HandleFunc("POST /api/ai/scout", a.handleScout) + mux.HandleFunc("POST /api/voice/transcribe", a.handleTranscribe) + mux.HandleFunc("POST /api/voice/speak", a.handleSpeak) + mux.HandleFunc("GET /api/health", a.handleHealth) + mux.HandleFunc("GET /api/agent/tools", a.handleAgentTools) + mux.HandleFunc("GET /api/debug/export", a.handleDebugExport) + mux.HandleFunc("GET /api/debug/export.jsonl", a.handleDebugExportJSONL) + mux.HandleFunc("DELETE /api/debug", a.handleDebugClear) + + static, err := fs.Sub(ui.Files, "static") + if err != nil { + log.Fatal(err) + } + mux.Handle("/", http.FileServer(http.FS(static))) + + if d, err := time.ParseDuration(env("TASK_SCOUT_INTERVAL", "2m")); err == nil && d > 0 { + go a.scoutLoop(d) + log.Printf("Task-Scout aktiv: alle %s", d) + } + if d, err := time.ParseDuration(env("HOME_AUTOMATION_INTERVAL", "1m")); err == nil && d > 0 { + go a.homeAutomationLoop(d) + log.Printf("Home-Automation aktiv: alle %s", d) + } + if d, err := time.ParseDuration(env("KANBAN_AI_INTERVAL", "5m")); err == nil && d > 0 { + go a.kanbanLoop(d) + log.Printf("KI-Kanban aktiv: alle %s", d) + } + go a.reindexMissing() + if n := a.repairLegacyCalendarTasks(); n > 0 { + log.Printf("Legacy-Reparatur: %d kalenderabgeleitete KI-Aufgabe(n) entfernt", n) + } + go a.runHomeAutomations(context.Background(), "startup") + + srv := &http.Server{Addr: addr, Handler: securityHeaders(logging(a.traceHTTP(mux))), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 180 * time.Second, IdleTimeout: 90 * time.Second} + log.Printf("JARVIS Home Core auf http://localhost%s", addr) + log.Printf("Ollama=%s requested-model=%s requested-embeddings=%s wake=%q timezone=%s ctx=%d predict=%d planner-ctx=%d", ollamaURL, ollamaModel, embedModel, wakeWord, timezone.String(), ctxCfg.NumCtx, ctxCfg.NumPredict, ctxCfg.PlannerNumCtx) + log.Fatal(srv.ListenAndServe()) +} + +func (a *app) handleState(w http.ResponseWriter, r *http.Request) { + st := a.db.Snapshot() + ms := a.ai.Status() + writeJSON(w, 200, systemState{State: st, System: map[string]any{ + "ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "native-tools", "tools": toolNames(a.tools.Definitions()), "maxSteps": a.context.AgentMaxSteps}, + }}) +} + +func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) { + ms := a.ai.Status() + writeJSON(w, 200, map[string]any{"ok": true, "model": ms.Model, "embeddingModel": ms.EmbeddingModel, "ollama": ms, "ragChunks": a.rag.Count(), "voice": a.voice.Status(a.wakeWord), "context": a.context, "debug": a.debug.Config(), "agent": map[string]any{"mode": "native-tools", "tools": toolNames(a.tools.Definitions()), "maxSteps": a.context.AgentMaxSteps}}) +} + +func (a *app) handleAgentTools(w http.ResponseWriter, r *http.Request) { + writeJSON(w, 200, map[string]any{ + "mode": "native-tools", + "maxSteps": a.context.AgentMaxSteps, + "tools": a.tools.Definitions(), + }) +} + +func (a *app) handleTaskUpsert(w http.ResponseWriter, r *http.Request) { + var v store.Task + if !decodeJSON(w, r, &v) { + return + } + if strings.TrimSpace(v.Title) == "" { + http.Error(w, "Titel fehlt", 400) + return + } + now := store.Now() + if v.ID == "" { + v.ID = id("task") + v.CreatedAt = now + } + v.UpdatedAt = now + if v.Status == "" { + v.Status = "open" + } + if v.Priority == "" { + v.Priority = "normal" + } + err := a.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 + } + } + s.Tasks = append([]store.Task{v}, s.Tasks...) + return nil + }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + go a.runHomeAutomations(context.Background(), "task-change") + writeJSON(w, 200, v) +} + +func (a *app) handleTaskDelete(w http.ResponseWriter, r *http.Request) { + idv := r.PathValue("id") + err := a.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 + }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) handleDocumentUpload(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(40 << 20); err != nil { + http.Error(w, "Upload zu groß oder ungültig", 400) + return + } + f, h, err := r.FormFile("file") + if err != nil { + http.Error(w, "Datei fehlt", 400) + return + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, 40<<20)) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + text, err := documents.Extract(h.Filename, b) + if err != nil { + http.Error(w, err.Error(), 422) + return + } + if len(text) > 1_000_000 { + text = text[:1_000_000] + "\n[gekürzt]" + } + mimeType := h.Header.Get("Content-Type") + if mimeType == "" { + mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(h.Filename))) + } + doc := store.Document{ID: id("doc"), Name: h.Filename, Mime: mimeType, Text: text, CreatedAt: store.Now()} + if err := a.db.Update(func(s *store.State) error { s.Documents = append([]store.Document{doc}, s.Documents...); return nil }); err != nil { + http.Error(w, err.Error(), 500) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 150*time.Second) + defer cancel() + idxErr := a.indexDocument(ctx, doc) + if idxErr != nil { + a.activity("warning", "RAG-Indexierung für "+doc.Name+" fehlgeschlagen: "+idxErr.Error()) + } else { + a.activity("document", "Dokument indexiert: "+doc.Name) + go a.runScout(traceBackground(r.Context()), "document-upload") + } + writeJSON(w, 200, map[string]any{"document": doc, "indexed": idxErr == nil, "indexError": errString(idxErr)}) +} + +func (a *app) handleDocumentDelete(w http.ResponseWriter, r *http.Request) { + idv := r.PathValue("id") + err := a.db.Update(func(s *store.State) error { + out := s.Documents[:0] + for _, x := range s.Documents { + if x.ID != idv { + out = append(out, x) + } + } + s.Documents = append([]store.Document(nil), out...) + return nil + }) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + _ = a.rag.DeleteDocument(idv) + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) indexDocument(ctx context.Context, doc store.Document) error { + ctx, _ = debugtrace.EnsureTrace(ctx, "ragindex") + started := time.Now() + a.trace(ctx, "rag", "index_input", "input", map[string]any{"document_id": doc.ID, "name": doc.Name, "mime": doc.Mime, "text_chars": len([]rune(doc.Text)), "text_preview": trimToTokenBudget(doc.Text, 700)}) + chunks := rag.ChunkText(doc.Text, 1300, 180) + if len(chunks) == 0 { + err := fmt.Errorf("Dokument enthält keinen indexierbaren Text") + a.traceErr(ctx, "rag", "chunking", nil, err, started) + return err + } + a.trace(ctx, "rag", "chunking", "output", map[string]any{"chunks": len(chunks), "chunk_previews": previewStrings(chunks, 8, 900)}) + vectors := make([][]float64, 0, len(chunks)) + for start := 0; start < len(chunks); start += 16 { + end := start + 16 + if end > len(chunks) { + end = len(chunks) + } + batchStart := time.Now() + a.trace(ctx, "rag", "embed_batch", "input", map[string]any{"from": start, "to": end, "count": end - start, "model": a.ai.Status().EmbeddingModel, "texts": previewStrings(chunks[start:end], 16, 1200)}) + v, err := a.ai.Embed(ctx, chunks[start:end]) + if err != nil { + a.traceErr(ctx, "rag", "embed_batch", map[string]any{"from": start, "to": end}, err, batchStart) + return err + } + dim := 0 + if len(v) > 0 { + dim = len(v[0]) + } + a.traceTimed(ctx, "rag", "embed_batch", "output", map[string]any{"from": start, "to": end, "vectors": len(v), "dimension": dim}, batchStart) + vectors = append(vectors, v...) + } + if err := a.rag.ReplaceDocument(doc.ID, doc.Name, chunks, vectors); err != nil { + a.traceErr(ctx, "rag", "persist_index", nil, err, started) + return err + } + now := store.Now() + err := a.db.Update(func(s *store.State) error { + for i := range s.Documents { + if s.Documents[i].ID == doc.ID { + s.Documents[i].ChunkCount = len(chunks) + s.Documents[i].IndexedAt = now + break + } + } + return nil + }) + if err != nil { + a.traceErr(ctx, "rag", "index_complete", nil, err, started) + return err + } + dim := 0 + if len(vectors) > 0 { + dim = len(vectors[0]) + } + a.traceTimed(ctx, "rag", "index_complete", "output", map[string]any{"document_id": doc.ID, "chunks": len(chunks), "vectors": len(vectors), "dimension": dim}, started) + return nil +} + +func (a *app) handleRAGSearch(w http.ResponseWriter, r *http.Request) { + var req ragSearchRequest + if !decodeJSON(w, r, &req) { + return + } + req.Query = strings.TrimSpace(req.Query) + if req.Query == "" { + http.Error(w, "Query fehlt", 400) + return + } + if req.Limit <= 0 || req.Limit > 20 { + req.Limit = 6 + } + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + v, err := a.ai.Embed(ctx, []string{req.Query}) + if err != nil { + http.Error(w, err.Error(), 502) + return + } + writeJSON(w, 200, map[string]any{"hits": a.rag.Search(v[0], req.Limit)}) +} + +func (a *app) handleRAGReindex(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) + defer cancel() + st := a.db.Snapshot() + okCount := 0 + errs := []string{} + for _, d := range st.Documents { + if err := a.indexDocument(ctx, d); err != nil { + errs = append(errs, d.Name+": "+err.Error()) + } else { + okCount++ + } + } + writeJSON(w, 200, map[string]any{"indexed": okCount, "errors": errs, "chunks": a.rag.Count()}) +} + +func (a *app) handleAIChat(w http.ResponseWriter, r *http.Request) { + baseCtx, traceID := debugtrace.EnsureTrace(r.Context(), "command") + r = r.WithContext(baseCtx) + var req chatRequest + if !decodeJSON(w, r, &req) { + return + } + req.Message = strings.TrimSpace(req.Message) + if req.Message == "" { + http.Error(w, "Nachricht fehlt", 400) + return + } + a.trace(baseCtx, "command", "user_input", "input", map[string]any{"message": req.Message, "timezone": a.timezone.String(), "architecture": "native-tool-agent"}) + a.trace(baseCtx, "state", "before_command", "input", debugStateSnapshot(a.db.Snapshot())) + + // One household command is processed atomically at the orchestration level. + // All AI-originated Home mutations happen through the registered tools. + a.commandMu.Lock() + defer a.commandMu.Unlock() + + a.appendChat("user", req.Message) + ctx, cancel := context.WithTimeout(baseCtx, 180*time.Second) + defer cancel() + + outcome, err := a.runToolAgent(ctx, req.Message) + if err != nil { + a.traceErr(ctx, "agent", "failed", nil, err, time.Now()) + a.activity("warning", "Tool-Agent: "+err.Error()) + reply := "Der lokale JARVIS-Agent ist momentan nicht vollständig verfügbar. Es wurde keine unbestätigte Home-Änderung ausgeführt." + if errors.Is(err, ollama.ErrNoChatModel) { + reply = "Es ist aktuell kein lokales Chat-Modell für den Tool-Agent verfügbar. Es wurde keine Home-Änderung ausgeführt." + } + a.appendChat("assistant", reply) + a.trace(ctx, "state", "after_command", "output", debugStateSnapshot(a.db.Snapshot())) + writeJSON(w, 200, chatResponse{TraceID: traceID, Reply: reply, Sources: []rag.Hit{}, Mode: "degraded"}) + return + } + + reply := strings.TrimSpace(outcome.Reply) + if reply == "" { + reply = "Der Tool-Agent hat keine Antwort erzeugt. Es wurde nichts zusätzlich ausgeführt." + } + a.appendChat("assistant", reply) + a.trace(ctx, "agent", "completed", "output", map[string]any{"mode": outcome.Mode, "reply": reply, "tool_results": outcome.Results, "automations": outcome.Automations, "plan": outcome.Plan}) + a.trace(ctx, "state", "after_command", "output", debugStateSnapshot(a.db.Snapshot())) + + mutated := false + for _, tr := range outcome.Results { + if tr.Success && tr.Mutated { + mutated = true + break + } + } + if mutated { + a.activity("assistant", "JARVIS hat Home-Tools erfolgreich ausgeführt") + go a.runKanbanAI(traceBackground(ctx), "tool-agent") + } else { + a.activity("assistant", "JARVIS Tool-Agent hat eine Anfrage beantwortet") + if !a.requiresHomeTool(req.Message) && !isTerseControlResponse(req.Message) { + go a.runScout(traceBackground(ctx), "chat") + } + } + writeJSON(w, 200, chatResponse{TraceID: traceID, Reply: reply, Sources: []rag.Hit{}, Automations: outcome.Automations, ToolResults: outcome.Results, Plan: outcome.Plan, Mode: outcome.Mode}) +} + +func buildRAGPrompt(st store.State, hits []rag.Hit, msg string, now time.Time, cfg contextConfig) string { + var state strings.Builder + state.WriteString("AUFGABEN:\n") + for _, t := range st.Tasks { + if t.Status != "done" { + fmt.Fprintf(&state, "- id=%s | %s | %s | due=%s | source=%s\n", t.ID, t.Title, t.Priority, t.Due, t.Source) + } + } + state.WriteString(homeStateContext(st)) + + var b strings.Builder + fmt.Fprintf(&b, "AKTUELLE LOKALE ZEIT: %s\n", now.Format(time.RFC3339)) + b.WriteString("\nHOME-STATE (AUTORITATIV):\n") + b.WriteString(trimToTokenBudget(state.String(), cfg.StateTokens)) + b.WriteString("\n\nRAG-KONTEXT (NUR DOKUMENTWISSEN):\n") + b.WriteString(ragContext(hits, cfg.RAGTokens)) + b.WriteString("\n\nAKTUELLE NUTZERANFRAGE:\n") + b.WriteString(msg) + return b.String() +} + +func (a *app) handleChatClear(w http.ResponseWriter, r *http.Request) { + a.clearPending() + a.clearRecentMutation() + if err := a.db.Update(func(s *store.State) error { + s.Chat = []store.ChatTurn{} + return nil + }); err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + +func (a *app) appendChat(role, content string) { + _ = a.db.Update(func(s *store.State) error { + s.Chat = append(s.Chat, store.ChatTurn{ID: id("chat"), Role: role, Content: content, CreatedAt: store.Now()}) + if len(s.Chat) > 100 { + s.Chat = append([]store.ChatTurn(nil), s.Chat[len(s.Chat)-100:]...) + } + return nil + }) +} +func (a *app) activity(kind, msg string) { + _ = a.db.Update(func(s *store.State) error { + s.Activity = append([]store.Activity{{ID: id("act"), Kind: kind, Message: msg, CreatedAt: store.Now()}}, s.Activity...) + if len(s.Activity) > 80 { + s.Activity = s.Activity[:80] + } + return nil + }) +} + +func (a *app) scoutLoop(d time.Duration) { + ticker := time.NewTicker(d) + defer ticker.Stop() + for range ticker.C { + a.runScout(context.Background(), "interval") + } +} +func (a *app) handleScout(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second) + defer cancel() + n, err := a.runScout(ctx, "manual") + if err != nil { + http.Error(w, err.Error(), 502) + return + } + writeJSON(w, 200, map[string]any{"ok": true, "created": n}) +} + +func (a *app) runScout(parent context.Context, reason string) (int, error) { + ctx0, _ := debugtrace.EnsureTrace(parent, "scout") + if !a.scoutMu.TryLock() { + a.trace(ctx0, "scout", "skipped", "output", map[string]any{"reason": "already_running", "trigger": reason}) + return 0, nil + } + defer a.scoutMu.Unlock() + ctx, cancel := context.WithTimeout(ctx0, 110*time.Second) + defer cancel() + st := a.db.Snapshot() + contextText := buildScoutContext(st) + if strings.TrimSpace(contextText) == "" { + a.trace(ctx, "scout", "input", "input", map[string]any{"trigger": reason, "context": "", "note": "no material"}) + return 0, nil + } + system := `Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {"tasks":[{"title":"kurz und konkret","notes":"Begründung oder Kontext","due":"RFC3339 oder leer","priority":"low|normal|high","source":"chat|Dokumentname","confidence":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.` + a.trace(ctx, "scout", "llm_request", "input", map[string]any{"trigger": reason, "model": a.ai.Status().Model, "options": a.ai.GenerationOptions(true), "system": system, "context": contextText, "estimated_tokens": estimateTokens(contextText)}) + started := time.Now() + raw, err := a.ai.Chat(ctx, system, contextText, true) + if err != nil { + a.traceErr(ctx, "scout", "llm_response", nil, err, started) + return 0, err + } + a.traceTimed(ctx, "scout", "llm_response", "output", map[string]any{"raw": raw}, started) + var out scoutOutput + if err := json.Unmarshal([]byte(raw), &out); err != nil { + a.traceErr(ctx, "scout", "parse", map[string]any{"raw": raw}, err, time.Now()) + return 0, fmt.Errorf("Task-Scout JSON ungültig: %w", err) + } + a.trace(ctx, "scout", "parsed", "output", out) + created := 0 + decisions := []map[string]any{} + for _, candidate := range out.Tasks { + candidate.Title = strings.TrimSpace(candidate.Title) + if candidate.Title == "" || candidate.Confidence < 0.78 { + decisions = append(decisions, map[string]any{"candidate": candidate, "decision": "rejected", "reason": "empty_or_low_confidence"}) + continue + } + stNow := a.db.Snapshot() + if candidateLooksCalendarDerived(candidate, stNow) { + decisions = append(decisions, map[string]any{"candidate": candidate, "decision": "rejected", "reason": "calendar_derived"}) + continue + } + key := normalizeTask(candidate.Title) + fp := fingerprint(key + "|" + strings.ToLower(strings.TrimSpace(candidate.Source))) + duplicate := false + for _, t := range stNow.Tasks { + if normalizeTask(t.Title) == key || (t.Fingerprint != "" && t.Fingerprint == fp) { + duplicate = true + break + } + } + if duplicate { + decisions = append(decisions, map[string]any{"candidate": candidate, "decision": "rejected", "reason": "duplicate"}) + continue + } + args := map[string]any{ + "title": candidate.Title, "notes": strings.TrimSpace(candidate.Notes), "priority": normalizePriority(candidate.Priority), + "source": "ai-scout:" + strings.TrimSpace(candidate.Source), "confidence": candidate.Confidence, "fingerprint": fp, + } + if due := strings.TrimSpace(candidate.Due); due != "" { + if t, e := time.Parse(time.RFC3339, due); e == nil { + t = t.In(a.timezone) + args["due"] = map[string]any{"kind": "absolute", "date": t.Format("2006-01-02"), "time": t.Format("15:04")} + } + } + a.trace(ctx, "scout", "tool_call", "input", map[string]any{"tool": "tasks_create", "arguments": args}) + res := a.tools.Execute(ctx, "tasks_create", args) + a.trace(ctx, "scout", "tool_result", "output", res) + if res.Success && res.Mutated { + created++ + decisions = append(decisions, map[string]any{"candidate": candidate, "decision": "created", "task_id": entityID(res), "tool": "tasks_create"}) + } else if res.Success { + decisions = append(decisions, map[string]any{"candidate": candidate, "decision": "rejected", "reason": "duplicate", "tool_result": res}) + } else { + decisions = append(decisions, map[string]any{"candidate": candidate, "decision": "rejected", "reason": "tool_error", "tool_result": res}) + } + } + if created > 0 { + a.activity("scout", fmt.Sprintf("Task-Scout (%s): %d neue Aufgabe(n) via Tool-Registry", reason, created)) + a.runHomeAutomations(traceBackground(ctx), "task-scout") + go a.runKanbanAI(traceBackground(ctx), "task-scout") + } + a.trace(ctx, "scout", "completed", "output", map[string]any{"trigger": reason, "created": created, "decisions": decisions}) + return created, nil +} + +func buildScoutContext(st store.State) string { + var source strings.Builder + material := false + source.WriteString("NUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\n") + start := 0 + if len(st.Chat) > 40 { + start = len(st.Chat) - 40 + } + for _, c := range st.Chat[start:] { + // Der Scout darf niemals aus JARVIS' eigenen Vorschlägen neue Tasks erzeugen. + // Ebenso werden explizite Kalender-/Task-/Listenbefehle vom Action-Router + // verarbeitet und hier bewusst ausgeblendet. + if c.Role != "user" || looksActionable(c.Content) || isDeleteCommand(c.Content) || isTerseControlResponse(c.Content) { + continue + } + clean := strings.TrimSpace(stripModuleContext(c.Content)) + if clean == "" { + continue + } + material = true + fmt.Fprintf(&source, "user: %s\n", clean) + } + source.WriteString("\nDOKUMENTE (AUSZÜGE):\n") + total := 0 + for _, d := range st.Documents { + if total > 45000 { + break + } + txt := strings.TrimSpace(d.Text) + if txt == "" { + continue + } + material = true + if len(txt) > 6500 { + txt = txt[:6500] + } + fmt.Fprintf(&source, "\n--- %s ---\n%s\n", d.Name, txt) + total += len(txt) + } + if !material { + return "" + } + + var b strings.Builder + fmt.Fprintf(&b, "AKTUELLE SERVERZEIT: %s\n", time.Now().Format(time.RFC3339)) + b.WriteString("BEREITS VORHANDENE AUFGABEN:\n") + for _, t := range st.Tasks { + if t.Status != "done" { + fmt.Fprintf(&b, "- %s | source=%s | due=%s\n", t.Title, t.Source, t.Due) + } + } + b.WriteByte('\n') + b.WriteString(source.String()) + return b.String() +} + +func (a *app) handleTranscribe(w http.ResponseWriter, r *http.Request) { + ctx0, _ := debugtrace.EnsureTrace(r.Context(), "stt") + r = r.WithContext(ctx0) + if err := r.ParseMultipartForm(12 << 20); err != nil { + http.Error(w, "Audio ungültig/zu groß", 400) + return + } + f, h, err := r.FormFile("audio") + if err != nil { + http.Error(w, "audio fehlt", 400) + return + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, 12<<20)) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + ext := filepath.Ext(h.Filename) + if ext == "" { + ext = ".webm" + } + a.trace(ctx0, "voice", "stt_input", "input", map[string]any{"filename": h.Filename, "extension": ext, "bytes": len(b), "content_type": h.Header.Get("Content-Type"), "note": "raw audio is intentionally not written to debug log"}) + ctx, cancel := context.WithTimeout(ctx0, 75*time.Second) + defer cancel() + sttStart := time.Now() + text, err := a.voice.Transcribe(ctx, b, ext) + if err != nil { + a.traceErr(ctx, "voice", "stt_output", nil, err, sttStart) + http.Error(w, err.Error(), 503) + return + } + a.traceTimed(ctx, "voice", "stt_output", "output", map[string]any{"text": text, "wakeWord": a.wakeWord}, sttStart) + writeJSON(w, 200, map[string]any{"text": text, "wakeWord": a.wakeWord}) +} + +func (a *app) handleSpeak(w http.ResponseWriter, r *http.Request) { + ctx0, _ := debugtrace.EnsureTrace(r.Context(), "tts") + r = r.WithContext(ctx0) + var req speakRequest + if !decodeJSON(w, r, &req) { + return + } + a.trace(ctx0, "voice", "tts_input", "input", map[string]any{"text": req.Text, "chars": len([]rune(req.Text))}) + ctx, cancel := context.WithTimeout(ctx0, 90*time.Second) + defer cancel() + ttsStart := time.Now() + wav, err := a.voice.Speak(ctx, req.Text) + if err != nil { + a.traceErr(ctx, "voice", "tts_output", nil, err, ttsStart) + http.Error(w, err.Error(), 503) + return + } + a.traceTimed(ctx, "voice", "tts_output", "output", map[string]any{"wav_bytes": len(wav), "note": "raw WAV is intentionally not written to debug log"}, ttsStart) + w.Header().Set("Content-Type", "audio/wav") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(200) + _, _ = w.Write(wav) +} + +func (a *app) reindexMissing() { + time.Sleep(2 * time.Second) + st := a.db.Snapshot() + for _, d := range st.Documents { + if a.rag.HasDocument(d.ID) { + continue + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + err := a.indexDocument(ctx, d) + cancel() + if err != nil { + log.Printf("RAG reindex %s: %v", d.Name, err) + continue + } + } +} + +func previewStrings(in []string, maxItems, maxRunes int) []string { + if maxItems <= 0 || maxItems > len(in) { + maxItems = len(in) + } + out := make([]string, 0, maxItems) + for _, s := range in[:maxItems] { + r := []rune(s) + if maxRunes > 0 && len(r) > maxRunes { + s = string(r[:maxRunes]) + "…" + } + out = append(out, s) + } + return out +} +func normalizeTask(s string) string { + return strings.Join(strings.Fields(strings.ToLower(strings.TrimSpace(s))), " ") +} +func fingerprint(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:12]) +} +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + dec := json.NewDecoder(io.LimitReader(r.Body, 2<<20)) + if err := dec.Decode(dst); err != nil { + http.Error(w, "Ungültiges JSON: "+err.Error(), 400) + return false + } + return true +} +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} +func id(prefix string) string { return prefix + "_" + strconv.FormatInt(time.Now().UnixNano(), 36) } +func env(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} +func envInt(k string, d int) int { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return d + } + n, err := strconv.Atoi(v) + if err != nil || n <= 0 { + return d + } + return n +} +func envFloat(k string, d float64) float64 { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return d + } + n, err := strconv.ParseFloat(v, 64) + if err != nil || n < 0 { + return d + } + return n +} +func envBool(k string, d bool) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(k))) + if v == "" { + return d + } + switch v { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + } + return d +} +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "same-origin") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Permissions-Policy", "microphone=(self)") + // Das Dashboard wird lokal häufig direkt ersetzt/aktualisiert. Kein Browser soll + // nach einem Upgrade noch altes app.js oder einen alten API-Zustand verwenden. + w.Header().Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} +func logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} diff --git a/cmd/homehub/pending_tool.go b/cmd/homehub/pending_tool.go new file mode 100644 index 0000000..b7ef37e --- /dev/null +++ b/cmd/homehub/pending_tool.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "homehub/internal/store" +) + +// pendingToolAction is an exact, server-owned action awaiting a short user +// confirmation. It deliberately stores the concrete entity ID so a follow-up +// such as "ja" never has to be resolved by the LLM or by a stale session hint. +type pendingToolAction struct { + Tool string + Arguments map[string]any + Label string + ExpiresAt time.Time +} + +func (a *app) setPendingToolAction(tool string, args map[string]any, label string) { + a.confirmMu.Lock() + defer a.confirmMu.Unlock() + copied := map[string]any{} + for k, v := range args { + copied[k] = v + } + a.confirm = &pendingToolAction{Tool: tool, Arguments: copied, Label: label, ExpiresAt: time.Now().Add(3 * time.Minute)} +} + +func (a *app) clearPendingToolAction() { + a.confirmMu.Lock() + a.confirm = nil + a.confirmMu.Unlock() +} + +func isAffirmativeToolConfirmation(raw string) bool { + x := strings.TrimSpace(foldCommand(stripModuleContext(raw))) + switch x { + case "ja", "ja bitte", "bitte", "ok", "okay", "mach es", "mach das", "tu es", "loesch ihn", "loesche ihn", "entfern ihn", "entferne ihn": + return true + } + return strings.HasPrefix(x, "ja, ") || strings.HasPrefix(x, "ja ") +} + +func isNegativeToolConfirmation(raw string) bool { + x := strings.TrimSpace(foldCommand(stripModuleContext(raw))) + return x == "nein" || x == "abbrechen" || x == "abbruch" || x == "doch nicht" +} + +func (a *app) resolvePendingToolAction(ctx context.Context, raw string) (agentOutcome, bool) { + a.confirmMu.Lock() + p := a.confirm + if p == nil || time.Now().After(p.ExpiresAt) { + a.confirm = nil + a.confirmMu.Unlock() + return agentOutcome{}, false + } + cp := *p + cp.Arguments = map[string]any{} + for k, v := range p.Arguments { + cp.Arguments[k] = v + } + a.confirmMu.Unlock() + + if isNegativeToolConfirmation(raw) { + a.clearPendingToolAction() + return agentOutcome{Reply: "Okay, die vorgemerkte Änderung wurde nicht ausgeführt.", Mode: "action", Results: []toolResult{}, Automations: []automationResult{}}, true + } + if !isAffirmativeToolConfirmation(raw) { + // A substantive new command supersedes the old confirmation request. + if !isTerseControlResponse(raw) { + a.clearPendingToolAction() + } + return agentOutcome{}, false + } + + a.clearPendingToolAction() + res := a.tools.Execute(ctx, cp.Tool, cp.Arguments) + a.trace(ctx, "agent", "pending_tool_execute", "output", map[string]any{"tool": cp.Tool, "arguments": cp.Arguments, "label": cp.Label, "result": res}) + out := agentOutcome{Mode: "tool-confirmed", Results: []toolResult{res}, Automations: []automationResult{}} + if !res.Success { + out.Mode = "tool-error" + out.Reply = a.toolFailureReply(out.Results) + return out, true + } + if res.Mutated { + out.Automations = append(out.Automations, automationResult{Type: cp.Tool, ID: entityID(res), Message: toolResultMessage(res)}) + a.rememberToolEntity(res) + a.runHomeAutomations(traceBackground(ctx), "confirmed:"+cp.Tool) + out.Reply = a.toolCommitReply(out.Results) + return out, true + } + out.Reply = a.toolReadReply(out.Results) + return out, true +} + +func (a *app) capturePendingToolConfirmation(ctx context.Context, content string, results []toolResult) bool { + x := foldCommand(content) + if !(strings.Contains(x, "soll ich") || strings.Contains(x, "moechtest du") || strings.Contains(x, "moechten sie") || strings.Contains(x, "wenn du moechtest")) { + return false + } + if !(strings.Contains(x, "loesch") || strings.Contains(x, "entfern")) { + return false + } + + for i := len(results) - 1; i >= 0; i-- { + res := results[i] + if !res.Success { + continue + } + switch res.Tool { + case "calendar_list": + var items []store.CalendarEvent + if v, ok := decodeToolData[[]store.CalendarEvent](res.Data); ok { + items = v + } + if len(items) == 1 { + a.setPendingToolAction("calendar_delete", map[string]any{"id": items[0].ID}, items[0].Title) + a.trace(ctx, "agent", "pending_tool_set", "output", map[string]any{"tool": "calendar_delete", "id": items[0].ID, "label": items[0].Title}) + return true + } + case "tasks_list": + if items, ok := decodeToolData[[]store.Task](res.Data); ok && len(items) == 1 { + a.setPendingToolAction("tasks_delete", map[string]any{"id": items[0].ID}, items[0].Title) + return true + } + case "reminders_list": + if items, ok := decodeToolData[[]store.Reminder](res.Data); ok && len(items) == 1 { + a.setPendingToolAction("reminders_delete", map[string]any{"id": items[0].ID}, items[0].Title) + return true + } + case "mealplan_list": + if items, ok := decodeToolData[[]store.MealPlan](res.Data); ok && len(items) == 1 { + a.setPendingToolAction("mealplan_delete", map[string]any{"id": items[0].ID}, items[0].Title) + return true + } + case "kanban_list": + if items, ok := decodeToolData[[]store.KanbanCard](res.Data); ok && len(items) == 1 { + a.setPendingToolAction("kanban_delete", map[string]any{"id": items[0].ID}, items[0].Title) + return true + } + } + } + return false +} + +func isMutationTool(name string) bool { + switch name { + case "calendar_create", "calendar_update", "calendar_delete", + "tasks_create", "tasks_update", "tasks_delete", + "reminders_create", "reminders_delete", + "groceries_add", "groceries_update", "groceries_remove", "groceries_sync_mealplan", + "recipes_create", "recipes_update", + "mealplan_create", "mealplan_update", "mealplan_delete", + "kanban_create", "kanban_move", "kanban_delete": + return true + default: + return false + } +} + +func (a *app) toolFailureReply(results []toolResult) string { + for i := len(results) - 1; i >= 0; i-- { + res := results[i] + if res.Success || !isMutationTool(res.Tool) { + continue + } + msg := "Die gewünschte Änderung konnte nicht ausgeführt werden." + if res.Error != nil && strings.TrimSpace(res.Error.Message) != "" { + msg = strings.TrimSpace(res.Error.Message) + } + return fmt.Sprintf("Keine Änderung gespeichert: %s", msg) + } + return "Keine Änderung gespeichert: Das Schreib-Tool konnte nicht erfolgreich ausgeführt werden." +} diff --git a/cmd/homehub/tool_agent_test.go b/cmd/homehub/tool_agent_test.go new file mode 100644 index 0000000..507b4ae --- /dev/null +++ b/cmd/homehub/tool_agent_test.go @@ -0,0 +1,476 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "homehub/internal/ollama" + "homehub/internal/rag" + "homehub/internal/store" +) + +func toolTestApp(t *testing.T, serverURL string) *app { + t.Helper() + db, err := store.New(filepath.Join(t.TempDir(), "store.json")) + if err != nil { + t.Fatal(err) + } + idx, err := rag.New(filepath.Join(t.TempDir(), "rag.json")) + if err != nil { + t.Fatal(err) + } + cfg := defaultContextConfig() + cfg.AgentMaxSteps = 6 + ai := ollama.New(serverURL, "fake-model", "fake-embed") + ai.ConfigureGeneration(cfg.chatOptions(), cfg.plannerOptions()) + a := &app{db: db, ai: ai, rag: idx, timezone: berlin(t), context: cfg} + a.tools = newToolRegistry(a) + return a +} + +func writeOllamaTurn(w http.ResponseWriter, msg map[string]any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"message": msg}) +} + +func TestNativeToolAgentCreatesMealPlanSymbolically(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tools"]; ok { + if calls.Add(1) == 1 { + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "mealplan_create", "arguments": map[string]any{"title": "Pommes Currywurst", "when": map[string]any{"kind": "weekday", "weekday": "sunday"}, "meal": "lunch"}}}}}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Erledigt."}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "{}"}) + })) + defer srv.Close() + a := toolTestApp(t, srv.URL) + out, err := a.runToolAgent(context.Background(), "Sonntag wären Pommes Currywurst toll. Also zu Mittag") + if err != nil { + t.Fatal(err) + } + if len(out.Results) == 0 || !out.Results[0].Success || !out.Results[0].Mutated { + t.Fatalf("results=%+v", out.Results) + } + st := a.db.Snapshot() + if len(st.MealPlans) != 1 { + t.Fatalf("meal plans=%d", len(st.MealPlans)) + } + m := st.MealPlans[0] + if m.Meal != "lunch" || m.Title != "Pommes Currywurst" { + t.Fatalf("meal=%+v", m) + } + d, err := time.ParseInLocation("2006-01-02", m.Date, a.timezone) + if err != nil { + t.Fatal(err) + } + if d.Weekday() != time.Sunday { + t.Fatalf("date=%s weekday=%v", m.Date, d.Weekday()) + } + if len(st.Tasks) != 0 { + t.Fatalf("unexpected tasks=%+v", st.Tasks) + } +} + +func TestToolAgentCanSearchRecipeThenPlanAndSyncGroceries(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tools"]; ok { + switch calls.Add(1) { + case 1: + writeOllamaTurn(w, map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "recipes_search", "arguments": map[string]any{"query": "Nudeln Tomatensauce"}}}}}) + case 2: + writeOllamaTurn(w, map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "mealplan_create", "arguments": map[string]any{"title": "Nudeln mit Tomatensauce", "recipe_id": "r1", "when": map[string]any{"kind": "relative", "offset_days": 1}, "meal": "dinner", "servings": 4}}}}}) + default: + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Geplant."}) + } + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "{}"}) + })) + defer srv.Close() + a := toolTestApp(t, srv.URL) + _ = a.db.Update(func(s *store.State) error { + s.Recipes = []store.Recipe{{ID: "r1", Title: "Nudeln mit Tomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "200", Unit: "g"}, {Name: "Tomatensauce", Quantity: "100", Unit: "ml"}}, CreatedAt: store.Now()}} + return nil + }) + out, err := a.runToolAgent(context.Background(), "Plane morgen Nudeln mit Tomatensauce zum Abendessen für vier Personen") + if err != nil { + t.Fatal(err) + } + if len(out.Results) < 2 { + t.Fatalf("expected search + create, got %+v", out.Results) + } + st := a.db.Snapshot() + if len(st.MealPlans) != 1 || st.MealPlans[0].RecipeID != "r1" || st.MealPlans[0].Servings != 4 { + t.Fatalf("meal=%+v", st.MealPlans) + } + if len(st.Groceries) < 2 { + t.Fatalf("expected recipe groceries, got %+v", st.Groceries) + } +} + +func TestAgentJSONSelectorFallbackStillUsesRegistry(t *testing.T) { + var native atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tools"]; ok { + if native.Add(1) == 1 { + // Model incorrectly answers in prose instead of a native tool call. + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Ich habe den Termin eingetragen."}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "OK"}) + return + } + // JSON selector fallback chooses a registered tool with symbolic time. + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": `{"tool":"calendar_create","arguments":{"title":"Zahnarzt","when":{"kind":"relative","offset_days":1,"time":"15:30"}},"confidence":0.99}`}) + })) + defer srv.Close() + a := toolTestApp(t, srv.URL) + out, err := a.runToolAgent(context.Background(), "Trage morgen Zahnarzt um 15:30 Uhr in den Kalender ein") + if err != nil { + t.Fatal(err) + } + if len(out.Results) != 1 || out.Results[0].Tool != "calendar_create" || !out.Results[0].Mutated { + t.Fatalf("out=%+v", out) + } + st := a.db.Snapshot() + if len(st.Events) != 1 { + t.Fatalf("events=%+v", st.Events) + } + tm, err := time.Parse(time.RFC3339, st.Events[0].Start) + if err != nil { + t.Fatal(err) + } + if tm.In(a.timezone).Hour() != 15 || tm.In(a.timezone).Minute() != 30 { + t.Fatalf("start=%s", st.Events[0].Start) + } + if out.Reply == "Ich habe den Termin eingetragen." { + t.Fatal("uncommitted prose must not be trusted") + } +} + +func TestUnknownToolNeverMutatesStore(t *testing.T) { + a := toolTestApp(t, "http://127.0.0.1:1") + res := a.tools.Execute(context.Background(), "destroy_everything", map[string]any{}) + if res.Success || res.Error == nil || res.Error.Code != "UNKNOWN_TOOL" { + t.Fatalf("res=%+v", res) + } + st := a.db.Snapshot() + if len(st.Tasks)+len(st.Events)+len(st.MealPlans) != 0 { + t.Fatalf("state mutated: %+v", st) + } +} + +func TestWorkflowPlannerDecomposesWeeklyDinnerPlanIntoSubtasks(t *testing.T) { + var nativeCalls atomic.Int32 + var jsonCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tools"]; ok { + if nativeCalls.Add(1) == 1 { + // Native model asks an unnecessary clarification instead of using tools. + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Welche Gerichte soll ich planen?"}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Plan erledigt."}) + return + } + jsonCalls.Add(1) + steps := []map[string]any{} + titles := []string{"Gemüsepfanne mit Reis", "Nudeln mit Tomatensauce", "Ofenkartoffeln mit Kräuterquark", "Linsencurry", "Wraps mit Gemüse", "Pizza Margherita", "Kartoffelsuppe"} + for i, title := range titles { + steps = append(steps, map[string]any{ + "id": fmt.Sprintf("s%d", i+1), + "task": fmt.Sprintf("Abendessen Tag %d planen", i+1), + "tool": "mealplan_create", + "arguments": map[string]any{ + "title": title, + "when": map[string]any{"kind": "relative", "offset_days": i}, + "meal": "dinner", + }, + }) + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": mustJSON(map[string]any{"goal": "7 Abendessen planen", "steps": steps, "confidence": 0.96})}) + })) + defer srv.Close() + + a := toolTestApp(t, srv.URL) + out, err := a.runToolAgent(context.Background(), "erstelle einen essensplan für die ganze woche aber nur abendessen") + if err != nil { + t.Fatal(err) + } + if out.Plan == nil || len(out.Plan.Steps) != 7 { + t.Fatalf("plan=%+v", out.Plan) + } + if len(out.Results) != 7 { + t.Fatalf("expected 7 tool results, got %d: %+v", len(out.Results), out.Results) + } + st := a.db.Snapshot() + if len(st.MealPlans) != 7 { + t.Fatalf("expected 7 meal plans, got %+v", st.MealPlans) + } + seen := map[string]bool{} + for _, m := range st.MealPlans { + if m.Meal != "dinner" { + t.Fatalf("unexpected meal=%+v", m) + } + seen[m.Date] = true + } + if len(seen) != 7 { + t.Fatalf("expected seven distinct dates, got %+v", st.MealPlans) + } + if jsonCalls.Load() != 1 { + t.Fatalf("expected one workflow planner call, got %d", jsonCalls.Load()) + } +} + +func mustJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func TestWeeklyPlanRecipeFirstUsesRecipeBookAndBuildsGroceries(t *testing.T) { + var nativeCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tools"]; ok { + if nativeCalls.Add(1) == 1 { + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Ich würde verschiedene Gerichte planen."}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Erledigt."}) + return + } + steps := []map[string]any{} + for i := 0; i < 7; i++ { + steps = append(steps, map[string]any{ + "id": fmt.Sprintf("s%d", i+1), "task": "Abendessen planen", "tool": "mealplan_create", + "arguments": map[string]any{"title": fmt.Sprintf("Erfundenes Gericht %d", i+1), "when": map[string]any{"kind": "relative", "offset_days": i}, "meal": "dinner"}, + }) + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": mustJSON(map[string]any{"goal": "Wochenplan", "steps": steps, "confidence": 0.95})}) + })) + defer srv.Close() + + a := toolTestApp(t, srv.URL) + _ = a.db.Update(func(s *store.State) error { + s.Recipes = []store.Recipe{{ID: "r1", Title: "Nudeln mit Tomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "150", Unit: "g"}, {Name: "Tomatensauce", Quantity: "50", Unit: "ml"}}, CreatedAt: store.Now()}} + return nil + }) + out, err := a.runToolAgent(context.Background(), "erstelle einen essensplan für die ganze woche aber nur abendessen") + if err != nil { + t.Fatal(err) + } + if out.Plan == nil || len(out.Plan.Steps) != 7 { + t.Fatalf("plan=%+v", out.Plan) + } + st := a.db.Snapshot() + if len(st.MealPlans) != 7 { + t.Fatalf("meal plans=%+v", st.MealPlans) + } + for _, m := range st.MealPlans { + if m.RecipeID != "r1" || m.Title != "Nudeln mit Tomatensauce" { + t.Fatalf("recipe-first failed: %+v", m) + } + } + var noodles, sauce *store.GroceryItem + for i := range st.Groceries { + g := &st.Groceries[i] + if g.Name == "Nudeln" { + noodles = g + } + if g.Name == "Tomatensauce" { + sauce = g + } + } + if noodles == nil || noodles.Quantity != "1050" { + t.Fatalf("noodles=%+v groceries=%+v", noodles, st.Groceries) + } + if sauce == nil || sauce.Quantity != "350" { + t.Fatalf("sauce=%+v groceries=%+v", sauce, st.Groceries) + } +} + +func TestRecipesUpdateRefreshesLinkedGroceries(t *testing.T) { + var nativeCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tools"]; ok { + if nativeCalls.Add(1) == 1 { + writeOllamaTurn(w, map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "recipes_update", "arguments": map[string]any{"id": "r1", "ingredients": []any{map[string]any{"name": "Nudeln", "quantity": "300", "unit": "g"}, map[string]any{"name": "Tomatensauce", "quantity": "120", "unit": "ml"}}}}}}}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Aktualisiert."}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": `{}`}) + })) + defer srv.Close() + + a := toolTestApp(t, srv.URL) + today := time.Now().In(a.timezone).Format("2006-01-02") + _ = a.db.Update(func(s *store.State) error { + s.Recipes = []store.Recipe{{ID: "r1", Title: "Nudeln mit Tomatensauce", Servings: 2, Ingredients: []store.Ingredient{{Name: "Nudeln", Quantity: "150", Unit: "g"}}, CreatedAt: store.Now()}} + s.MealPlans = []store.MealPlan{{ID: "m1", Date: today, Meal: "dinner", RecipeID: "r1", Title: "Nudeln mit Tomatensauce", Servings: 2, CreatedAt: store.Now()}} + return nil + }) + a.runHomeAutomations(context.Background(), "test-initial") + out, err := a.runToolAgent(context.Background(), "Ändere beim Rezept Nudeln mit Tomatensauce die Zutaten auf 300 g Nudeln und 120 ml Tomatensauce") + if err != nil { + t.Fatal(err) + } + if len(out.Results) == 0 || out.Results[0].Tool != "recipes_update" || !out.Results[0].Mutated { + t.Fatalf("out=%+v", out) + } + st := a.db.Snapshot() + if len(st.Recipes) != 1 || len(st.Recipes[0].Ingredients) != 2 { + t.Fatalf("recipe=%+v", st.Recipes) + } + found := map[string]string{} + for _, g := range st.Groceries { + found[g.Name] = g.Quantity + } + if found["Nudeln"] != "300" || found["Tomatensauce"] != "120" { + t.Fatalf("groceries=%+v", st.Groceries) + } +} + +func TestGroceriesSyncMealplanReportsUnlinkedMeals(t *testing.T) { + a := toolTestApp(t, "http://127.0.0.1:1") + today := time.Now().In(a.timezone).Format("2006-01-02") + _ = a.db.Update(func(s *store.State) error { + s.MealPlans = []store.MealPlan{{ID: "m1", Date: today, Meal: "dinner", Title: "Freies Gericht", Servings: 2, CreatedAt: store.Now()}} + return nil + }) + res := a.tools.Execute(context.Background(), "groceries_sync_mealplan", map[string]any{"days": 14}) + if !res.Success { + t.Fatalf("res=%+v", res) + } + b, _ := json.Marshal(res.Data) + var data struct { + Missing []map[string]any `json:"missing_recipe_meals"` + } + if err := json.Unmarshal(b, &data); err != nil { + t.Fatal(err) + } + if len(data.Missing) != 1 { + t.Fatalf("data=%s", string(b)) + } +} + +func TestCalendarDeleteConfirmationUsesExactListedEventNotStaleRecentEntity(t *testing.T) { + var toolCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tools"]; ok { + switch toolCalls.Add(1) { + case 1: + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "calendar_list", "arguments": map[string]any{"query": "Friseur"}}}}}) + default: + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Ich habe den Termin Friseur gefunden. Soll ich den Termin löschen?"}) + } + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": `{}`}) + })) + defer srv.Close() + + a := toolTestApp(t, srv.URL) + _ = a.db.Update(func(s *store.State) error { + s.Events = []store.CalendarEvent{{ID: "event_friseur", Title: "Friseur", Start: "2026-08-28T09:15:00+02:00", Source: "jarvis-tool", CreatedAt: store.Now(), UpdatedAt: store.Now()}} + s.Reminders = []store.Reminder{{ID: "rem_friseur", Title: "Termin: Friseur", At: "2026-08-28T08:45:00+02:00", LinkedType: "event", LinkedID: "event_friseur", Source: "calendar:auto", CreatedAt: store.Now(), UpdatedAt: store.Now()}} + return nil + }) + // Reproduce the real bug: a stale recent mutation points to a meal-plan ID. + a.recent = &recentMutation{Kind: "meal_plan", ID: "meal_stale", At: time.Now()} + + first, err := a.runToolAgent(context.Background(), "im kalender Friseur") + if err != nil { + t.Fatal(err) + } + if first.Mode != "clarification" { + t.Fatalf("first mode=%s reply=%q", first.Mode, first.Reply) + } + if a.confirm == nil || a.confirm.Tool != "calendar_delete" || fmt.Sprint(a.confirm.Arguments["id"]) != "event_friseur" { + t.Fatalf("pending confirmation=%+v", a.confirm) + } + callsBeforeConfirm := toolCalls.Load() + + second, err := a.runToolAgent(context.Background(), "ja") + if err != nil { + t.Fatal(err) + } + if toolCalls.Load() != callsBeforeConfirm { + t.Fatalf("confirmation must not call LLM again; before=%d after=%d", callsBeforeConfirm, toolCalls.Load()) + } + if len(second.Results) != 1 || second.Results[0].Tool != "calendar_delete" || !second.Results[0].Success || !second.Results[0].Mutated { + t.Fatalf("second=%+v", second) + } + st := a.db.Snapshot() + if len(st.Events) != 0 { + t.Fatalf("event not deleted: %+v", st.Events) + } + if len(st.Reminders) != 0 { + t.Fatalf("linked reminder not deleted: %+v", st.Reminders) + } +} + +func TestFailedWriteCannotBeReportedAsSuccessByModel(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if _, ok := body["tools"]; ok { + if calls.Add(1) == 1 { + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "", "tool_calls": []any{map[string]any{"function": map[string]any{"name": "calendar_delete", "arguments": map[string]any{"id": "meal_stale"}}}}}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": "Der Termin wurde aus deinem Kalender entfernt."}) + return + } + writeOllamaTurn(w, map[string]any{"role": "assistant", "content": `{}`}) + })) + defer srv.Close() + + a := toolTestApp(t, srv.URL) + _ = a.db.Update(func(s *store.State) error { + s.Events = []store.CalendarEvent{{ID: "event_friseur", Title: "Friseur", Start: "2026-08-28T09:15:00+02:00", CreatedAt: store.Now(), UpdatedAt: store.Now()}} + return nil + }) + + out, err := a.runToolAgent(context.Background(), "lösche Friseur aus dem Kalender") + if err != nil { + t.Fatal(err) + } + if out.Mode != "tool-error" || !strings.Contains(out.Reply, "Keine Änderung gespeichert") { + t.Fatalf("out=%+v", out) + } + if strings.Contains(out.Reply, "wurde aus deinem Kalender entfernt") { + t.Fatalf("model success hallucination leaked through: %q", out.Reply) + } + if len(a.db.Snapshot().Events) != 1 { + t.Fatal("failed delete must not mutate state") + } +} diff --git a/cmd/homehub/tools.go b/cmd/homehub/tools.go new file mode 100644 index 0000000..5bc7d4a --- /dev/null +++ b/cmd/homehub/tools.go @@ -0,0 +1,1263 @@ +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"` + 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) +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..804c676 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,44 @@ +services: + jarvis: + build: . + ports: + - "8080:8080" + volumes: + - ./data:/app/data + - ./models:/models:ro + - ./local-tools:/tools:ro + environment: + JARVIS_ADDR: :8080 + JARVIS_DATA: /app/data/store.json + JARVIS_RAG_DATA: /app/data/rag.json + JARVIS_WAKE_WORD: jarvis + JARVIS_TIMEZONE: Europe/Berlin + TZ: Europe/Berlin + OLLAMA_URL: http://ollama:11434 + OLLAMA_MODEL: auto + OLLAMA_EMBED_MODEL: auto + TASK_SCOUT_INTERVAL: 2m + HOME_AUTOMATION_INTERVAL: 1m + KANBAN_AI_INTERVAL: 5m + JARVIS_DEBUG_TRACE: "true" + JARVIS_DEBUG_DIR: /app/data/debug + JARVIS_DEBUG_MAX_MB: 25 + JARVIS_DEBUG_KEEP_FILES: 4 + VOICE_FFMPEG_BIN: ffmpeg + VOICE_WHISPER_BIN: /tools/whisper-cli + VOICE_WHISPER_MODEL: /models/ggml-small.bin + VOICE_LANGUAGE: de + VOICE_PIPER_BIN: /tools/piper + VOICE_PIPER_MODEL: /models/de_DE-thorsten-medium.onnx + depends_on: + - ollama + + ollama: + image: ollama/ollama:latest + ports: + - "11434:11434" + volumes: + - ollama-data:/root/.ollama + +volumes: + ollama-data: diff --git a/data/debug/jarvis-debug.jsonl b/data/debug/jarvis-debug.jsonl new file mode 100644 index 0000000..b459b62 --- /dev/null +++ b/data/debug/jarvis-debug.jsonl @@ -0,0 +1,80 @@ +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:14:35.7595558+02:00","trace_id":"automation_1787908475759_fae897e7e5a0","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:13:35+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:13:35+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:14:35.7621557+02:00","trace_id":"automation_1787908475759_fae897e7e5a0","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:14:35+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:14:35+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:15:35.759562+02:00","trace_id":"automation_1787908535759_b6ce56b1bb64","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:14:35+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:14:35+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:15:35.7600647+02:00","trace_id":"scout_1787908535759_75aba3ddcb3c","component":"scout","stage":"llm_request","direction":"input","level":"info","data":{"context":"AKTUELLE SERVERZEIT: 2026-08-28T11:15:35+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","estimated_tokens":109,"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"system":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:15:35.7600647+02:00","trace_id":"scout_1787908535759_75aba3ddcb3c","component":"ollama","stage":"chat.request","direction":"input","level":"info","data":{"json_mode":true,"messages":[{"content":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","role":"system"},{"content":"AKTUELLE SERVERZEIT: 2026-08-28T11:15:35+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:15:35.7627133+02:00","trace_id":"automation_1787908535759_b6ce56b1bb64","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":3,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:15:35+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:15:35+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:15:48.1736314+02:00","trace_id":"scout_1787908535759_75aba3ddcb3c","component":"ollama","stage":"chat.response","direction":"output","level":"info","duration_ms":12413,"data":{"content":"{\"tasks\":[]}","message":{"content":"{\"tasks\":[]}","role":"assistant"},"status":200}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:15:48.1736314+02:00","trace_id":"scout_1787908535759_75aba3ddcb3c","component":"scout","stage":"llm_response","direction":"output","level":"info","duration_ms":12413,"data":{"raw":"{\"tasks\":[]}"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:15:48.1736314+02:00","trace_id":"scout_1787908535759_75aba3ddcb3c","component":"scout","stage":"parsed","direction":"output","level":"info","data":{"tasks":[]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:15:48.1736314+02:00","trace_id":"scout_1787908535759_75aba3ddcb3c","component":"scout","stage":"completed","direction":"output","level":"info","data":{"created":0,"decisions":[],"trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:16:35.7595324+02:00","trace_id":"automation_1787908595759_f654c1ec2c02","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:15:35+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:15:35+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:16:35.7627335+02:00","trace_id":"automation_1787908595759_f654c1ec2c02","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":3,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:16:35+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:16:35+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:17:26.6172851+02:00","trace_id":"startup_1787908646617_98f6acc389de","component":"ollama","stage":"models.request","direction":"input","level":"info","data":{"base_url":"http://localhost:11434","requested_embedding_model":"auto","requested_model":"auto"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:17:26.6508585+02:00","trace_id":"startup_1787908646617_98f6acc389de","component":"ollama","stage":"models.response","direction":"output","level":"info","duration_ms":33,"data":{"chatReady":true,"embeddingModel":"embeddinggemma:latest","embeddingReady":true,"fallback":false,"installed":["embeddinggemma:latest","gemma4:e4b","gemma4:latest","orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","qwen3-embedding:8b","qwen3.6:27b-q4_K_M","qwen3:8b"],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","reachable":true,"requestedEmbeddingModel":"auto","requestedModel":"auto"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:17:26.6524422+02:00","trace_id":"automation_1787908646652_31df4648f5c4","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:16:35+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:16:35+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"startup","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:17:26.65509+02:00","trace_id":"automation_1787908646652_31df4648f5c4","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:17:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:17:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:18:26.6515314+02:00","trace_id":"automation_1787908706651_acb8392d013f","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:17:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:17:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:18:26.6542519+02:00","trace_id":"automation_1787908706651_acb8392d013f","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:18:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:18:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:19:26.6519224+02:00","trace_id":"automation_1787908766651_4e3e83afa1f6","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:18:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:18:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:19:26.6524352+02:00","trace_id":"scout_1787908766651_aa7a6b2e3c6d","component":"scout","stage":"llm_request","direction":"input","level":"info","data":{"context":"AKTUELLE SERVERZEIT: 2026-08-28T11:19:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","estimated_tokens":109,"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"system":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:19:26.6524352+02:00","trace_id":"scout_1787908766651_aa7a6b2e3c6d","component":"ollama","stage":"chat.request","direction":"input","level":"info","data":{"json_mode":true,"messages":[{"content":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","role":"system"},{"content":"AKTUELLE SERVERZEIT: 2026-08-28T11:19:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:19:26.6544895+02:00","trace_id":"automation_1787908766651_4e3e83afa1f6","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:19:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:19:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:19:38.0728479+02:00","trace_id":"scout_1787908766651_aa7a6b2e3c6d","component":"ollama","stage":"chat.response","direction":"output","level":"info","duration_ms":11420,"data":{"content":"{\"tasks\":[]}","message":{"content":"{\"tasks\":[]}","role":"assistant"},"status":200}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:19:38.0728479+02:00","trace_id":"scout_1787908766651_aa7a6b2e3c6d","component":"scout","stage":"llm_response","direction":"output","level":"info","duration_ms":11420,"data":{"raw":"{\"tasks\":[]}"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:19:38.0728479+02:00","trace_id":"scout_1787908766651_aa7a6b2e3c6d","component":"scout","stage":"parsed","direction":"output","level":"info","data":{"tasks":[]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:19:38.0728479+02:00","trace_id":"scout_1787908766651_aa7a6b2e3c6d","component":"scout","stage":"completed","direction":"output","level":"info","data":{"created":0,"decisions":[],"trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:20:26.6518208+02:00","trace_id":"automation_1787908826651_1a11d232b693","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:19:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:19:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:20:26.6544354+02:00","trace_id":"automation_1787908826651_1a11d232b693","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:20:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:20:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:21:26.6515323+02:00","trace_id":"automation_1787908886651_386578bb4600","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:20:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:20:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:21:26.6520359+02:00","trace_id":"scout_1787908886651_158c0b11ea14","component":"scout","stage":"llm_request","direction":"input","level":"info","data":{"context":"AKTUELLE SERVERZEIT: 2026-08-28T11:21:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","estimated_tokens":109,"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"system":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:21:26.6520359+02:00","trace_id":"scout_1787908886651_158c0b11ea14","component":"ollama","stage":"chat.request","direction":"input","level":"info","data":{"json_mode":true,"messages":[{"content":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","role":"system"},{"content":"AKTUELLE SERVERZEIT: 2026-08-28T11:21:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:21:26.6542068+02:00","trace_id":"automation_1787908886651_386578bb4600","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:21:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:21:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:21:37.8073018+02:00","trace_id":"scout_1787908886651_158c0b11ea14","component":"ollama","stage":"chat.response","direction":"output","level":"info","duration_ms":11155,"data":{"content":"{\"tasks\":[]}","message":{"content":"{\"tasks\":[]}","role":"assistant"},"status":200}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:21:37.8078065+02:00","trace_id":"scout_1787908886651_158c0b11ea14","component":"scout","stage":"llm_response","direction":"output","level":"info","duration_ms":11155,"data":{"raw":"{\"tasks\":[]}"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:21:37.8078065+02:00","trace_id":"scout_1787908886651_158c0b11ea14","component":"scout","stage":"parsed","direction":"output","level":"info","data":{"tasks":[]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:21:37.8078065+02:00","trace_id":"scout_1787908886651_158c0b11ea14","component":"scout","stage":"completed","direction":"output","level":"info","data":{"created":0,"decisions":[],"trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:22:26.6526635+02:00","trace_id":"kanban_1787908946651_b1c8a5fbe015","component":"kanban","stage":"tool_request","direction":"input","level":"info","data":{"messages":[{"content":"Du bist der Kanban-Organizer von JARVIS. Analysiere ausschließlich die übergebenen bestehenden Aufgaben. Verändere keine manuellen Karten. Für jede sinnvolle offene Aufgabe darfst du eine Empfehlung für inbox|next|doing|waiting|done ausgeben. Nutze Priorität, Fälligkeit, Kalender und Erinnerungen. Rufe zwingend das Tool kanban_recommend genau einmal auf. Erfinde keine taskId.","role":"system"},{"content":"ZEIT: 2026-08-28T11:22:26+02:00\nAUFGABEN:\n- id=task_tool_dl0dbme5o3ls | Hausflur putzen | status=open | prio=normal | due= | notes=\n\nNÄCHSTE TERMINE:\n- 2026-08-29T08:00:00+02:00 | Einkaufen | \n\nREMINDER:\n- 2026-08-29T07:30:00+02:00 | Termin: Einkaufen\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"reason":"interval","tool":{"function":{"description":"Liefert strukturierte Kanban-Empfehlungen für bestehende Aufgaben. Dieses Tool verändert noch keinen Zustand.","name":"kanban_recommend","parameters":{"additionalProperties":false,"properties":{"cards":{"items":{"additionalProperties":false,"properties":{"column":{"enum":["inbox","next","doing","waiting","done"],"type":"string"},"confidence":{"maximum":1,"minimum":0,"type":"number"},"due":{"type":"string"},"notes":{"type":"string"},"priority":{"enum":["low","normal","high"],"type":"string"},"taskId":{"type":"string"},"title":{"type":"string"}},"required":["taskId","column","confidence"],"type":"object"},"maxItems":30,"type":"array"}},"required":["cards"],"type":"object"}},"type":"function"}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:22:26.6526635+02:00","trace_id":"automation_1787908946651_2586b4e25418","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:21:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:21:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:22:26.6526635+02:00","trace_id":"kanban_1787908946651_b1c8a5fbe015","component":"ollama","stage":"tools.request","direction":"input","level":"info","data":{"messages":[{"content":"Du bist der Kanban-Organizer von JARVIS. Analysiere ausschließlich die übergebenen bestehenden Aufgaben. Verändere keine manuellen Karten. Für jede sinnvolle offene Aufgabe darfst du eine Empfehlung für inbox|next|doing|waiting|done ausgeben. Nutze Priorität, Fälligkeit, Kalender und Erinnerungen. Rufe zwingend das Tool kanban_recommend genau einmal auf. Erfinde keine taskId.","role":"system"},{"content":"ZEIT: 2026-08-28T11:22:26+02:00\nAUFGABEN:\n- id=task_tool_dl0dbme5o3ls | Hausflur putzen | status=open | prio=normal | due= | notes=\n\nNÄCHSTE TERMINE:\n- 2026-08-29T08:00:00+02:00 | Einkaufen | \n\nREMINDER:\n- 2026-08-29T07:30:00+02:00 | Termin: Einkaufen\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"tools":[{"function":{"description":"Liefert strukturierte Kanban-Empfehlungen für bestehende Aufgaben. Dieses Tool verändert noch keinen Zustand.","name":"kanban_recommend","parameters":{"additionalProperties":false,"properties":{"cards":{"items":{"additionalProperties":false,"properties":{"column":{"enum":["inbox","next","doing","waiting","done"],"type":"string"},"confidence":{"maximum":1,"minimum":0,"type":"number"},"due":{"type":"string"},"notes":{"type":"string"},"priority":{"enum":["low","normal","high"],"type":"string"},"taskId":{"type":"string"},"title":{"type":"string"}},"required":["taskId","column","confidence"],"type":"object"},"maxItems":30,"type":"array"}},"required":["cards"],"type":"object"}},"type":"function"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:22:26.6542237+02:00","trace_id":"automation_1787908946651_2586b4e25418","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:22:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:22:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:22:36.7562414+02:00","trace_id":"kanban_1787908946651_b1c8a5fbe015","component":"ollama","stage":"tools.response","direction":"output","level":"info","duration_ms":10103,"data":{"message":{"content":"","role":"assistant","tool_calls":[{"function":{"arguments":{"cards":[{"column":"next","confidence":0.62,"notes":"Keine Fälligkeit angegeben. Kann als konkrete nächste Aufgabe eingeplant werden, z. B. vor dem Termin „Einkaufen“ am 29.08., falls Zeit vorhanden ist.","priority":"normal","taskId":"task_tool_dl0dbme5o3ls"}]},"name":"kanban_recommend"}}]},"status":200,"tool_calls":[{"function":{"arguments":{"cards":[{"column":"next","confidence":0.62,"notes":"Keine Fälligkeit angegeben. Kann als konkrete nächste Aufgabe eingeplant werden, z. B. vor dem Termin „Einkaufen“ am 29.08., falls Zeit vorhanden ist.","priority":"normal","taskId":"task_tool_dl0dbme5o3ls"}]},"name":"kanban_recommend"}}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:22:36.7567453+02:00","trace_id":"kanban_1787908946651_b1c8a5fbe015","component":"kanban","stage":"tool_response","direction":"output","level":"info","duration_ms":10104,"data":{"content":"","tool_calls":[{"function":{"arguments":{"cards":[{"column":"next","confidence":0.62,"notes":"Keine Fälligkeit angegeben. Kann als konkrete nächste Aufgabe eingeplant werden, z. B. vor dem Termin „Einkaufen“ am 29.08., falls Zeit vorhanden ist.","priority":"normal","taskId":"task_tool_dl0dbme5o3ls"}]},"name":"kanban_recommend"}}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:22:36.7567453+02:00","trace_id":"kanban_1787908946651_b1c8a5fbe015","component":"kanban","stage":"parsed","direction":"output","level":"info","data":{"mode":"native-tool","plan":{"cards":[]}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:22:36.7578941+02:00","trace_id":"kanban_1787908946651_b1c8a5fbe015","component":"kanban","stage":"persisted","direction":"output","level":"info","data":{"cards":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"error":"","mode":"native-tool","reason":"interval","updated":0}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:23:26.651774+02:00","trace_id":"automation_1787909006651_ebeeeb8dc56c","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:22:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:22:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:23:26.6522782+02:00","trace_id":"scout_1787909006651_df7b74a9e803","component":"scout","stage":"llm_request","direction":"input","level":"info","data":{"context":"AKTUELLE SERVERZEIT: 2026-08-28T11:23:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","estimated_tokens":109,"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"system":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:23:26.6522782+02:00","trace_id":"scout_1787909006651_df7b74a9e803","component":"ollama","stage":"chat.request","direction":"input","level":"info","data":{"json_mode":true,"messages":[{"content":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","role":"system"},{"content":"AKTUELLE SERVERZEIT: 2026-08-28T11:23:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:23:26.654399+02:00","trace_id":"automation_1787909006651_ebeeeb8dc56c","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:23:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:23:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:23:37.2586511+02:00","trace_id":"scout_1787909006651_df7b74a9e803","component":"ollama","stage":"chat.response","direction":"output","level":"info","duration_ms":10606,"data":{"content":"{\"tasks\":[]}","message":{"content":"{\"tasks\":[]}","role":"assistant"},"status":200}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:23:37.2591961+02:00","trace_id":"scout_1787909006651_df7b74a9e803","component":"scout","stage":"llm_response","direction":"output","level":"info","duration_ms":10606,"data":{"raw":"{\"tasks\":[]}"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:23:37.2591961+02:00","trace_id":"scout_1787909006651_df7b74a9e803","component":"scout","stage":"parsed","direction":"output","level":"info","data":{"tasks":[]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:23:37.2591961+02:00","trace_id":"scout_1787909006651_df7b74a9e803","component":"scout","stage":"completed","direction":"output","level":"info","data":{"created":0,"decisions":[],"trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:24:26.6516014+02:00","trace_id":"automation_1787909066651_fb1bc0b25698","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:23:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:23:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:24:26.6536665+02:00","trace_id":"automation_1787909066651_fb1bc0b25698","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:24:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:24:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:25:26.651887+02:00","trace_id":"automation_1787909126651_e278d4e17742","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:24:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:24:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:25:26.6523984+02:00","trace_id":"scout_1787909126651_bd1b5be33897","component":"scout","stage":"llm_request","direction":"input","level":"info","data":{"context":"AKTUELLE SERVERZEIT: 2026-08-28T11:25:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","estimated_tokens":109,"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"system":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:25:26.6523984+02:00","trace_id":"scout_1787909126651_bd1b5be33897","component":"ollama","stage":"chat.request","direction":"input","level":"info","data":{"json_mode":true,"messages":[{"content":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","role":"system"},{"content":"AKTUELLE SERVERZEIT: 2026-08-28T11:25:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:25:26.6550764+02:00","trace_id":"automation_1787909126651_e278d4e17742","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":3,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:25:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:25:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:25:37.0162719+02:00","trace_id":"scout_1787909126651_bd1b5be33897","component":"ollama","stage":"chat.response","direction":"output","level":"info","duration_ms":10363,"data":{"content":"{\"tasks\":[]}","message":{"content":"{\"tasks\":[]}","role":"assistant"},"status":200}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:25:37.0162719+02:00","trace_id":"scout_1787909126651_bd1b5be33897","component":"scout","stage":"llm_response","direction":"output","level":"info","duration_ms":10363,"data":{"raw":"{\"tasks\":[]}"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:25:37.0162719+02:00","trace_id":"scout_1787909126651_bd1b5be33897","component":"scout","stage":"parsed","direction":"output","level":"info","data":{"tasks":[]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:25:37.0162719+02:00","trace_id":"scout_1787909126651_bd1b5be33897","component":"scout","stage":"completed","direction":"output","level":"info","data":{"created":0,"decisions":[],"trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:26:26.6516824+02:00","trace_id":"automation_1787909186651_3d6b18dd595f","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:25:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:25:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:26:26.6537789+02:00","trace_id":"automation_1787909186651_3d6b18dd595f","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:26:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:26:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:26.6521841+02:00","trace_id":"scout_1787909246651_574f038e91f7","component":"scout","stage":"llm_request","direction":"input","level":"info","data":{"context":"AKTUELLE SERVERZEIT: 2026-08-28T11:27:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","estimated_tokens":109,"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"system":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:26.6521841+02:00","trace_id":"scout_1787909246651_574f038e91f7","component":"ollama","stage":"chat.request","direction":"input","level":"info","data":{"json_mode":true,"messages":[{"content":"Du bist der lokale Task-Scout eines persönlichen JARVIS-Systems. Suche ausschließlich nach realen Aufgaben, Verpflichtungen, Zusagen und Fristen, die WÖRTLICH oder sehr eindeutig aus NUTZER-AUSSAGEN oder DOKUMENTEN ableitbar sind. Keine allgemeinen Tipps, keine Vorbereitungs-Vorschläge und keine erfundenen nächsten Schritte. Ignoriere Dinge, die bereits als Aufgabe vorhanden sind. Kalendertermine, Meetings, Arzttermine, Verabredungen, Erinnerungen und deren mögliche Vorbereitung sind KEINE Aufgaben. JARVIS-Antworten sind keine Quelle für Aufgaben. Explizite Home-Befehle werden separat verarbeitet und dürfen nicht dupliziert werden. Gib ausschließlich valides JSON zurück: {\"tasks\":[{\"title\":\"kurz und konkret\",\"notes\":\"Begründung oder Kontext\",\"due\":\"RFC3339 oder leer\",\"priority\":\"low|normal|high\",\"source\":\"chat|Dokumentname\",\"confidence\":0.0}]}. Confidence muss zwischen 0 und 1 liegen. Erzeuge nur Einträge ab 0.78 Confidence und höchstens 10 Aufgaben.","role":"system"},{"content":"AKTUELLE SERVERZEIT: 2026-08-28T11:27:26+02:00\nBEREITS VORHANDENE AUFGABEN:\n- Hausflur putzen | source=jarvis-tool | due=\n\nNUTZER-AUSSAGEN, DIE NOCH KEIN EXPLIZITER HOME-BEFEHL WAREN:\nuser: ertstelle eine aufgabe Hausflur putzen\nuser: sage mir was ich dafür einkaudfen muss\nuser: ich war beim friseur\nuser: Du kannst die aufgabe friseur abhaken\n\nDOKUMENTE (AUSZÜGE):\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:26.6527038+02:00","trace_id":"automation_1787909246651_4a0ae527c313","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:26:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:26:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:26.6527038+02:00","trace_id":"kanban_1787909246651_57cd2219c22c","component":"kanban","stage":"tool_request","direction":"input","level":"info","data":{"messages":[{"content":"Du bist der Kanban-Organizer von JARVIS. Analysiere ausschließlich die übergebenen bestehenden Aufgaben. Verändere keine manuellen Karten. Für jede sinnvolle offene Aufgabe darfst du eine Empfehlung für inbox|next|doing|waiting|done ausgeben. Nutze Priorität, Fälligkeit, Kalender und Erinnerungen. Rufe zwingend das Tool kanban_recommend genau einmal auf. Erfinde keine taskId.","role":"system"},{"content":"ZEIT: 2026-08-28T11:27:26+02:00\nAUFGABEN:\n- id=task_tool_dl0dbme5o3ls | Hausflur putzen | status=open | prio=normal | due= | notes=\n\nNÄCHSTE TERMINE:\n- 2026-08-29T08:00:00+02:00 | Einkaufen | \n\nREMINDER:\n- 2026-08-29T07:30:00+02:00 | Termin: Einkaufen\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"reason":"interval","tool":{"function":{"description":"Liefert strukturierte Kanban-Empfehlungen für bestehende Aufgaben. Dieses Tool verändert noch keinen Zustand.","name":"kanban_recommend","parameters":{"additionalProperties":false,"properties":{"cards":{"items":{"additionalProperties":false,"properties":{"column":{"enum":["inbox","next","doing","waiting","done"],"type":"string"},"confidence":{"maximum":1,"minimum":0,"type":"number"},"due":{"type":"string"},"notes":{"type":"string"},"priority":{"enum":["low","normal","high"],"type":"string"},"taskId":{"type":"string"},"title":{"type":"string"}},"required":["taskId","column","confidence"],"type":"object"},"maxItems":30,"type":"array"}},"required":["cards"],"type":"object"}},"type":"function"}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:26.6527038+02:00","trace_id":"kanban_1787909246651_57cd2219c22c","component":"ollama","stage":"tools.request","direction":"input","level":"info","data":{"messages":[{"content":"Du bist der Kanban-Organizer von JARVIS. Analysiere ausschließlich die übergebenen bestehenden Aufgaben. Verändere keine manuellen Karten. Für jede sinnvolle offene Aufgabe darfst du eine Empfehlung für inbox|next|doing|waiting|done ausgeben. Nutze Priorität, Fälligkeit, Kalender und Erinnerungen. Rufe zwingend das Tool kanban_recommend genau einmal auf. Erfinde keine taskId.","role":"system"},{"content":"ZEIT: 2026-08-28T11:27:26+02:00\nAUFGABEN:\n- id=task_tool_dl0dbme5o3ls | Hausflur putzen | status=open | prio=normal | due= | notes=\n\nNÄCHSTE TERMINE:\n- 2026-08-29T08:00:00+02:00 | Einkaufen | \n\nREMINDER:\n- 2026-08-29T07:30:00+02:00 | Termin: Einkaufen\n","role":"user"}],"model":"orcarouter/Qwen3.8-27B-Uncensored:q4_K_M","options":{"num_ctx":8192,"num_predict":700,"temperature":0},"tools":[{"function":{"description":"Liefert strukturierte Kanban-Empfehlungen für bestehende Aufgaben. Dieses Tool verändert noch keinen Zustand.","name":"kanban_recommend","parameters":{"additionalProperties":false,"properties":{"cards":{"items":{"additionalProperties":false,"properties":{"column":{"enum":["inbox","next","doing","waiting","done"],"type":"string"},"confidence":{"maximum":1,"minimum":0,"type":"number"},"due":{"type":"string"},"notes":{"type":"string"},"priority":{"enum":["low","normal","high"],"type":"string"},"taskId":{"type":"string"},"title":{"type":"string"}},"required":["taskId","column","confidence"],"type":"object"},"maxItems":30,"type":"array"}},"required":["cards"],"type":"object"}},"type":"function"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:26.6547962+02:00","trace_id":"automation_1787909246651_4a0ae527c313","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":3,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:27:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:27:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:38.7979917+02:00","trace_id":"kanban_1787909246651_57cd2219c22c","component":"ollama","stage":"tools.response","direction":"output","level":"info","duration_ms":12145,"data":{"message":{"content":"","role":"assistant","tool_calls":[{"function":{"arguments":{"cards":[{"column":"inbox","confidence":0.62,"notes":"Offene Aufgabe ohne Fälligkeit und ohne erkennbare Abhängigkeit zum Termin \"Einkaufen\". Empfehlung: zunächst in inbox belassen bzw. dort einsortieren, bis Priorität oder Zeitfenster klarer ist.","priority":"normal","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen"}]},"name":"kanban_recommend"}}]},"status":200,"tool_calls":[{"function":{"arguments":{"cards":[{"column":"inbox","confidence":0.62,"notes":"Offene Aufgabe ohne Fälligkeit und ohne erkennbare Abhängigkeit zum Termin \"Einkaufen\". Empfehlung: zunächst in inbox belassen bzw. dort einsortieren, bis Priorität oder Zeitfenster klarer ist.","priority":"normal","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen"}]},"name":"kanban_recommend"}}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:38.7979917+02:00","trace_id":"kanban_1787909246651_57cd2219c22c","component":"kanban","stage":"tool_response","direction":"output","level":"info","duration_ms":12145,"data":{"content":"","tool_calls":[{"function":{"arguments":{"cards":[{"column":"inbox","confidence":0.62,"notes":"Offene Aufgabe ohne Fälligkeit und ohne erkennbare Abhängigkeit zum Termin \"Einkaufen\". Empfehlung: zunächst in inbox belassen bzw. dort einsortieren, bis Priorität oder Zeitfenster klarer ist.","priority":"normal","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen"}]},"name":"kanban_recommend"}}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:38.7979917+02:00","trace_id":"kanban_1787909246651_57cd2219c22c","component":"kanban","stage":"parsed","direction":"output","level":"info","data":{"mode":"native-tool","plan":{"cards":[]}}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:38.7990292+02:00","trace_id":"kanban_1787909246651_57cd2219c22c","component":"kanban","stage":"persisted","direction":"output","level":"info","data":{"cards":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"error":"","mode":"native-tool","reason":"interval","updated":0}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:49.2756684+02:00","trace_id":"scout_1787909246651_574f038e91f7","component":"ollama","stage":"chat.response","direction":"output","level":"info","duration_ms":22623,"data":{"content":"{\"tasks\":[]}","message":{"content":"{\"tasks\":[]}","role":"assistant"},"status":200}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:49.2756684+02:00","trace_id":"scout_1787909246651_574f038e91f7","component":"scout","stage":"llm_response","direction":"output","level":"info","duration_ms":22623,"data":{"raw":"{\"tasks\":[]}"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:49.2756684+02:00","trace_id":"scout_1787909246651_574f038e91f7","component":"scout","stage":"parsed","direction":"output","level":"info","data":{"tasks":[]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:27:49.2756684+02:00","trace_id":"scout_1787909246651_574f038e91f7","component":"scout","stage":"completed","direction":"output","level":"info","data":{"created":0,"decisions":[],"trigger":"interval"}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:28:26.6517001+02:00","trace_id":"automation_1787909306651_36b750c3462c","component":"automation","stage":"home_sync","direction":"input","level":"info","data":{"events":[{"aiManaged":true,"allDay":false,"createdAt":"2026-08-28T08:36:21+02:00","id":"event_tool_dl0dal2ar2lk","source":"jarvis-tool","start":"2026-08-29T08:00:00+02:00","title":"Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:27:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:27:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"mealPlans":[{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-28","id":"meal_tool_dl0gl2ukn1qw","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-29","id":"meal_tool_dl0gl2un5ba8","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:06+02:00","date":"2026-08-30","id":"meal_tool_dl0gl2upn3b0","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:06+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-08-31","id":"meal_tool_dl0gl8dazmbk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-01","id":"meal_tool_dl0gl8de4ogk","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-02","id":"meal_tool_dl0gl8dgo3ic","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"},{"aiManaged":true,"createdAt":"2026-08-28T11:11:18+02:00","date":"2026-09-03","id":"meal_tool_dl0gl8diuk78","meal":"dinner","recipeId":"recipe_dl00opwdaqyc","servings":2,"source":"jarvis-tool","title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T11:11:18+02:00"}],"reason":"interval","recipes":[{"aiManaged":false,"createdAt":"2026-08-27T22:43:34+02:00","description":"Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.","id":"recipe_dl00opwdaqyc","ingredients":[{"name":"Nudeln","quantity":"150","unit":"Gramm"},{"name":"Thomatensauce","quantity":"50","unit":"ml"}],"prepMinutes":15,"servings":2,"steps":[],"title":"Nudeln mit Thomatensauce","updatedAt":"2026-08-28T10:52:50+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"tasks":[{"aiManaged":true,"createdAt":"2026-08-28T08:37:42+02:00","id":"task_tool_dl0dbme5o3ls","priority":"normal","source":"jarvis-tool","status":"open","title":"Hausflur putzen","updatedAt":"2026-08-28T08:37:42+02:00"}]}} +{"schema":"jarvis.debug.v1","timestamp":"2026-08-28T11:28:26.6537348+02:00","trace_id":"automation_1787909306651_36b750c3462c","component":"automation","stage":"home_sync","direction":"output","level":"info","duration_ms":2,"data":{"groceries":[{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Nudeln","quantity":"1050","source":"mealplan:auto:nudeln|gramm","unit":"Gramm","updatedAt":"2026-08-28T11:28:26+02:00"},{"aiManaged":true,"category":"Essensplan","checked":false,"createdAt":"2026-08-28T11:11:06+02:00","id":"grocery_ai_dl0gl2ul9jmg","name":"Thomatensauce","quantity":"350","source":"mealplan:auto:thomatensauce|ml","unit":"ml","updatedAt":"2026-08-28T11:28:26+02:00"}],"kanban":[{"aiManaged":true,"column":"inbox","confidence":0.9,"createdAt":"2026-08-28T08:37:42+02:00","id":"card_ai_dl0dbme6x2a0","priority":"normal","source":"task-sync","taskId":"task_tool_dl0dbme5o3ls","title":"Hausflur putzen","updatedAt":"2026-08-28T11:06:29+02:00"}],"reminders":[{"aiManaged":true,"at":"2026-08-29T07:30:00+02:00","createdAt":"2026-08-28T08:36:21+02:00","done":false,"id":"rem_ai_dl0dal2bdh2w","linkedId":"event_tool_dl0dal2ar2lk","linkedType":"event","source":"calendar:auto","title":"Termin: Einkaufen","updatedAt":"2026-08-28T08:36:21+02:00"}],"result":{"groceries":0,"kanban":0,"ok":true,"reminders":0}}} diff --git a/data/rag.json b/data/rag.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/rag.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/data/store.json b/data/store.json new file mode 100644 index 0000000..647cf3a --- /dev/null +++ b/data/store.json @@ -0,0 +1,718 @@ +{ + "tasks": [ + { + "id": "task_tool_dl0dbme5o3ls", + "title": "Hausflur putzen", + "status": "open", + "priority": "normal", + "source": "jarvis-tool", + "aiManaged": true, + "createdAt": "2026-08-28T08:37:42+02:00", + "updatedAt": "2026-08-28T08:37:42+02:00" + } + ], + "events": [ + { + "id": "event_tool_dl0dal2ar2lk", + "title": "Einkaufen", + "start": "2026-08-29T08:00:00+02:00", + "allDay": false, + "source": "jarvis-tool", + "aiManaged": true, + "createdAt": "2026-08-28T08:36:21+02:00", + "updatedAt": "2026-08-28T08:36:21+02:00" + } + ], + "reminders": [ + { + "id": "rem_ai_dl0dal2bdh2w", + "title": "Termin: Einkaufen", + "at": "2026-08-29T07:30:00+02:00", + "done": false, + "linkedType": "event", + "linkedId": "event_tool_dl0dal2ar2lk", + "source": "calendar:auto", + "aiManaged": true, + "createdAt": "2026-08-28T08:36:21+02:00", + "updatedAt": "2026-08-28T08:36:21+02:00" + } + ], + "groceries": [ + { + "id": "grocery_ai_dl0gl2ul9jmg", + "name": "Nudeln", + "quantity": "1050", + "unit": "Gramm", + "category": "Essensplan", + "checked": false, + "source": "mealplan:auto:nudeln|gramm", + "aiManaged": true, + "createdAt": "2026-08-28T11:11:06+02:00", + "updatedAt": "2026-08-28T11:28:26+02:00" + }, + { + "id": "grocery_ai_dl0gl2ul9jmg", + "name": "Thomatensauce", + "quantity": "350", + "unit": "ml", + "category": "Essensplan", + "checked": false, + "source": "mealplan:auto:thomatensauce|ml", + "aiManaged": true, + "createdAt": "2026-08-28T11:11:06+02:00", + "updatedAt": "2026-08-28T11:28:26+02:00" + } + ], + "recipes": [ + { + "id": "recipe_dl00opwdaqyc", + "title": "Nudeln mit Thomatensauce", + "description": "Einfache Nudeln mit Thomatensauce. Einfache Zubereitung.", + "servings": 2, + "prepMinutes": 15, + "ingredients": [ + { + "name": "Nudeln", + "quantity": "150", + "unit": "Gramm" + }, + { + "name": "Thomatensauce", + "quantity": "50", + "unit": "ml" + } + ], + "steps": [], + "aiManaged": false, + "createdAt": "2026-08-27T22:43:34+02:00", + "updatedAt": "2026-08-28T10:52:50+02:00" + } + ], + "mealPlans": [ + { + "id": "meal_tool_dl0gl2ukn1qw", + "date": "2026-08-28", + "meal": "dinner", + "recipeId": "recipe_dl00opwdaqyc", + "title": "Nudeln mit Thomatensauce", + "servings": 2, + "aiManaged": true, + "source": "jarvis-tool", + "createdAt": "2026-08-28T11:11:06+02:00", + "updatedAt": "2026-08-28T11:11:06+02:00" + }, + { + "id": "meal_tool_dl0gl2un5ba8", + "date": "2026-08-29", + "meal": "dinner", + "recipeId": "recipe_dl00opwdaqyc", + "title": "Nudeln mit Thomatensauce", + "servings": 2, + "aiManaged": true, + "source": "jarvis-tool", + "createdAt": "2026-08-28T11:11:06+02:00", + "updatedAt": "2026-08-28T11:11:06+02:00" + }, + { + "id": "meal_tool_dl0gl2upn3b0", + "date": "2026-08-30", + "meal": "dinner", + "recipeId": "recipe_dl00opwdaqyc", + "title": "Nudeln mit Thomatensauce", + "servings": 2, + "aiManaged": true, + "source": "jarvis-tool", + "createdAt": "2026-08-28T11:11:06+02:00", + "updatedAt": "2026-08-28T11:11:06+02:00" + }, + { + "id": "meal_tool_dl0gl8dazmbk", + "date": "2026-08-31", + "meal": "dinner", + "recipeId": "recipe_dl00opwdaqyc", + "title": "Nudeln mit Thomatensauce", + "servings": 2, + "aiManaged": true, + "source": "jarvis-tool", + "createdAt": "2026-08-28T11:11:18+02:00", + "updatedAt": "2026-08-28T11:11:18+02:00" + }, + { + "id": "meal_tool_dl0gl8de4ogk", + "date": "2026-09-01", + "meal": "dinner", + "recipeId": "recipe_dl00opwdaqyc", + "title": "Nudeln mit Thomatensauce", + "servings": 2, + "aiManaged": true, + "source": "jarvis-tool", + "createdAt": "2026-08-28T11:11:18+02:00", + "updatedAt": "2026-08-28T11:11:18+02:00" + }, + { + "id": "meal_tool_dl0gl8dgo3ic", + "date": "2026-09-02", + "meal": "dinner", + "recipeId": "recipe_dl00opwdaqyc", + "title": "Nudeln mit Thomatensauce", + "servings": 2, + "aiManaged": true, + "source": "jarvis-tool", + "createdAt": "2026-08-28T11:11:18+02:00", + "updatedAt": "2026-08-28T11:11:18+02:00" + }, + { + "id": "meal_tool_dl0gl8diuk78", + "date": "2026-09-03", + "meal": "dinner", + "recipeId": "recipe_dl00opwdaqyc", + "title": "Nudeln mit Thomatensauce", + "servings": 2, + "aiManaged": true, + "source": "jarvis-tool", + "createdAt": "2026-08-28T11:11:18+02:00", + "updatedAt": "2026-08-28T11:11:18+02:00" + } + ], + "kanban": [ + { + "id": "card_ai_dl0dbme6x2a0", + "title": "Hausflur putzen", + "column": "inbox", + "priority": "normal", + "taskId": "task_tool_dl0dbme5o3ls", + "source": "task-sync", + "aiManaged": true, + "confidence": 0.9, + "createdAt": "2026-08-28T08:37:42+02:00", + "updatedAt": "2026-08-28T11:06:29+02:00" + } + ], + "documents": [], + "chat": [ + { + "id": "chat_dl00p0rrcfc8", + "role": "user", + "content": "Kannst du für Montag zum Abendessen Nudeln thomatensauce einplanen?", + "createdAt": "2026-08-27T22:43:57+02:00" + }, + { + "id": "chat_dl00p4atogu8", + "role": "assistant", + "content": "Essensplan eingetragen: „Kannst du Nudeln thomatensauce einplanen“ · Montag, 31.08.2026 · Abendessen · 2 Portionen.", + "createdAt": "2026-08-27T22:44:05+02:00" + }, + { + "id": "chat_dl00pjzdou78", + "role": "user", + "content": "Sonntag wären Pommes Currywurst toll. Also zu Mittag", + "createdAt": "2026-08-27T22:44:39+02:00" + }, + { + "id": "chat_dl00pno6ytdw", + "role": "assistant", + "content": "Essensplan eingetragen: „Sonntag wären Pommes Currywurst toll. Also zu Mittag“ · Sonntag, 30.08.2026 · Mittagessen.", + "createdAt": "2026-08-27T22:44:47+02:00" + }, + { + "id": "chat_dl0dafyynhcs", + "role": "user", + "content": "erstelle einen termin für morgen 08:oo Uhr Einkaufen", + "createdAt": "2026-08-28T08:36:10+02:00" + }, + { + "id": "chat_dl0dalze9sjw", + "role": "assistant", + "content": "Kalender: „Einkaufen“ · Samstag, 29.08.2026 um 08:00 Uhr.", + "createdAt": "2026-08-28T08:36:23+02:00" + }, + { + "id": "chat_dl0dbbbj2fo0", + "role": "user", + "content": "Setze Nudeln auf die Einkaufsliste", + "createdAt": "2026-08-28T08:37:18+02:00" + }, + { + "id": "chat_dl0dbcroivm8", + "role": "assistant", + "content": "Einkauf gespeichert: „Nudeln“.", + "createdAt": "2026-08-28T08:37:21+02:00" + }, + { + "id": "chat_dl0dblfwsqu0", + "role": "user", + "content": "ertstelle eine aufgabe Hausflur putzen", + "createdAt": "2026-08-28T08:37:40+02:00" + }, + { + "id": "chat_dl0dbms32oqw", + "role": "assistant", + "content": "Aufgabe gespeichert: „Hausflur putzen“.", + "createdAt": "2026-08-28T08:37:43+02:00" + }, + { + "id": "chat_dl0do6a81euk", + "role": "user", + "content": "Plane einen Termin heute 09:15 Friseur", + "createdAt": "2026-08-28T08:54:06+02:00" + }, + { + "id": "chat_dl0dohft1cr8", + "role": "assistant", + "content": "Kalender: „Friseur“ · Freitag, 28.08.2026 um 09:15 Uhr.", + "createdAt": "2026-08-28T08:54:30+02:00" + }, + { + "id": "chat_dl0fog5mr9xk", + "role": "user", + "content": "erstelle einen essensplan für die ganze woche abere nur abendessen", + "createdAt": "2026-08-28T10:28:29+02:00" + }, + { + "id": "chat_dl0fowkq99q0", + "role": "assistant", + "content": "Ich habe keine Änderung vorgenommen, weil kein gültiges Home-Tool sicher ausgewählt werden konnte. Bitte formuliere Ziel, Zeitpunkt und gewünschte Aktion etwas eindeutiger.", + "createdAt": "2026-08-28T10:29:05+02:00" + }, + { + "id": "chat_dl0fw8sanfh8", + "role": "user", + "content": "erstelle einen essensplan für die ganze woche abere nur abendessen", + "createdAt": "2026-08-28T10:38:40+02:00" + }, + { + "id": "chat_dl0fwotu1gls", + "role": "assistant", + "content": "Essensplan: 7 Abendessen geplant.\n• Montag, 31.08. · Pasta mit Gemüse und Kräutern\n• Dienstag, 01.09. · Gebratenes Lachsfilet mit Kartoffelpüree\n• Mittwoch, 02.09. · Curry-Hähnchen mit Reis\n• Donnerstag, 03.09. · Gemüse-Lasagne\n• Freitag, 04.09. · Pizzaabend (selbstgemacht)\n• Samstag, 05.09. · Steak mit Kräuterkartoffeln\n• Sonntag, 06.09. · Braten mit Rotkohl und Knödeln", + "createdAt": "2026-08-28T10:39:15+02:00" + }, + { + "id": "chat_dl0fx6rwzkz8", + "role": "user", + "content": "sage mir was ich dafür einkaudfen muss", + "createdAt": "2026-08-28T10:39:54+02:00" + }, + { + "id": "chat_dl0fxbbcsjmk", + "role": "assistant", + "content": "Ich kann dir leider nicht automatisch eine Einkaufsliste für den gesamten Essensplan erstellen, da mir die Funktion fehlt, die Zutaten aus einem Mealplan zu extrahieren.\n\nIch kann dir aber gerne helfen, wenn du mir bestimmte Zutaten nennst, die du hinzufügen möchtest, oder wenn du die aktuelle Einkaufsliste einsehen möchtest.", + "createdAt": "2026-08-28T10:40:04+02:00" + }, + { + "id": "chat_dl0g6fln21w0", + "role": "user", + "content": "Räume die Essensplanung auf", + "createdAt": "2026-08-28T10:51:59+02:00" + }, + { + "id": "chat_dl0g6uabm6nc", + "role": "assistant", + "content": "Essensplan-Eintrag gelöscht.\nEssensplan-Eintrag gelöscht.\nEssensplan-Eintrag gelöscht.\nEssensplan-Eintrag gelöscht.\nEssensplan-Eintrag gelöscht.\nEssensplan-Eintrag gelöscht.\nEssensplan-Eintrag gelöscht.", + "createdAt": "2026-08-28T10:52:31+02:00" + }, + { + "id": "chat_dl0g7ejmucho", + "role": "user", + "content": "ich war beim friseur", + "createdAt": "2026-08-28T10:53:15+02:00" + }, + { + "id": "chat_dl0g7ivqdhdk", + "role": "assistant", + "content": "Alles klar. Ich habe dies zur Kenntnis genommen.", + "createdAt": "2026-08-28T10:53:24+02:00" + }, + { + "id": "chat_dl0g84l6abbo", + "role": "user", + "content": "Du kannst die aufgabe friseur abhaken", + "createdAt": "2026-08-28T10:54:11+02:00" + }, + { + "id": "chat_dl0g89bqpvv4", + "role": "assistant", + "content": "Ich kann die Aufgabe \"Friseur\" nicht abhaken, da ich sie in deiner Aufgabenliste nicht finden konnte. Könntest du mir bitte den genauen Titel oder die ID der Aufgabe nennen?", + "createdAt": "2026-08-28T10:54:22+02:00" + }, + { + "id": "chat_dl0g8kjrq4ss", + "role": "user", + "content": "im kalender Friseur", + "createdAt": "2026-08-28T10:54:46+02:00" + }, + { + "id": "chat_dl0g8mua38uo", + "role": "assistant", + "content": "Ich habe den Termin \"Friseur\" vom 28.08.2026 um 09:15 Uhr gefunden.\n\nIch kann Kalendertermine nicht direkt als \"erledigt\" markieren, aber ich kann ihn für dich löschen, wenn du möchtest. Soll ich den Termin löschen?", + "createdAt": "2026-08-28T10:54:51+02:00" + }, + { + "id": "chat_dl0g8t17bs9c", + "role": "user", + "content": "ja", + "createdAt": "2026-08-28T10:55:05+02:00" + }, + { + "id": "chat_dl0g8ua7ogvo", + "role": "assistant", + "content": "Der Termin \"Friseur\" wurde aus deinem Kalender entfernt.", + "createdAt": "2026-08-28T10:55:07+02:00" + }, + { + "id": "chat_dl0giyrk56fk", + "role": "user", + "content": "bitte entferne es nun aus dem kalender", + "createdAt": "2026-08-28T11:08:21+02:00" + }, + { + "id": "chat_dl0gj6mkysh4", + "role": "assistant", + "content": "Kalendertermin gelöscht.", + "createdAt": "2026-08-28T11:08:38+02:00" + }, + { + "id": "chat_dl0gjijape8w", + "role": "user", + "content": "stelle eine einkaufsliste zusammen", + "createdAt": "2026-08-28T11:09:04+02:00" + }, + { + "id": "chat_dl0gjq9t6u38", + "role": "assistant", + "content": "Aktuell sind sowohl der Essensplan als auch die Einkaufsliste leer – es gibt also noch keine Grundlage, aus der ich automatisch eine Einkaufsliste generieren kann.\n\nMöchtest du:\n1. **Erst wieder einen Essensplan anlegen** (z. B. für die kommende Woche), und ich erstelle daraus die Einkaufsliste?\n2. **Einzelne Artikel manuell hinzufügen** – sag mir einfach, was du brauchst.\n\nWie soll ich vorgehen?", + "createdAt": "2026-08-28T11:09:21+02:00" + }, + { + "id": "chat_dl0gkp7dwzk8", + "role": "user", + "content": "Erst wieder einen Essensplan anlegen", + "createdAt": "2026-08-28T11:10:37+02:00" + }, + { + "id": "chat_dl0glbhn18hw", + "role": "assistant", + "content": "Essensplan: 7 Abendessen geplant.\n• Freitag, 28.08. · Nudeln mit Thomatensauce\n• Samstag, 29.08. · Nudeln mit Thomatensauce\n• Sonntag, 30.08. · Nudeln mit Thomatensauce\n• Montag, 31.08. · Nudeln mit Thomatensauce\n• Dienstag, 01.09. · Nudeln mit Thomatensauce\n• Mittwoch, 02.09. · Nudeln mit Thomatensauce\n• Donnerstag, 03.09. · Nudeln mit Thomatensauce", + "createdAt": "2026-08-28T11:11:25+02:00" + } + ], + "activity": [ + { + "id": "act_dl0glbhnyzgs", + "kind": "assistant", + "message": "JARVIS hat Home-Tools erfolgreich ausgeführt", + "createdAt": "2026-08-28T11:11:25+02:00" + }, + { + "id": "act_dl0gl8dkf8k0", + "kind": "automation", + "message": "Home-Automation (tool:mealplan_create): Einkauf 2 · Reminder 0 · Kanban 0", + "createdAt": "2026-08-28T11:11:18+02:00" + }, + { + "id": "act_dl0gl8di846c", + "kind": "automation", + "message": "Home-Automation (tool:mealplan_create): Einkauf 2 · Reminder 0 · Kanban 0", + "createdAt": "2026-08-28T11:11:18+02:00" + }, + { + "id": "act_dl0gl8dg0d6s", + "kind": "automation", + "message": "Home-Automation (tool:mealplan_create): Einkauf 2 · Reminder 0 · Kanban 0", + "createdAt": "2026-08-28T11:11:18+02:00" + }, + { + "id": "act_dl0gl8dd6kok", + "kind": "automation", + "message": "Home-Automation (tool:mealplan_create): Einkauf 2 · Reminder 0 · Kanban 0", + "createdAt": "2026-08-28T11:11:18+02:00" + }, + { + "id": "act_dl0gl2ur8s5o", + "kind": "automation", + "message": "Home-Automation (tool:mealplan_create): Einkauf 2 · Reminder 0 · Kanban 0", + "createdAt": "2026-08-28T11:11:06+02:00" + }, + { + "id": "act_dl0gl2up0nwc", + "kind": "automation", + "message": "Home-Automation (tool:mealplan_create): Einkauf 2 · Reminder 0 · Kanban 0", + "createdAt": "2026-08-28T11:11:06+02:00" + }, + { + "id": "act_dl0gl2umirk0", + "kind": "automation", + "message": "Home-Automation (tool:mealplan_create): Einkauf 2 · Reminder 0 · Kanban 0", + "createdAt": "2026-08-28T11:11:06+02:00" + }, + { + "id": "act_dl0gjq9ttd4g", + "kind": "assistant", + "message": "JARVIS Tool-Agent hat eine Anfrage beantwortet", + "createdAt": "2026-08-28T11:09:21+02:00" + }, + { + "id": "act_dl0gj6mlwdt8", + "kind": "assistant", + "message": "JARVIS hat Home-Tools erfolgreich ausgeführt", + "createdAt": "2026-08-28T11:08:38+02:00" + }, + { + "id": "act_dl0ghj9fisgs", + "kind": "kanban", + "message": "KI-Kanban (interval/json-retry): 1 Karten gepflegt", + "createdAt": "2026-08-28T11:06:29+02:00" + }, + { + "id": "act_dl0gdrofh53g", + "kind": "kanban", + "message": "KI-Kanban (interval/deterministic-fallback): 1 Karten gepflegt", + "createdAt": "2026-08-28T11:01:33+02:00" + }, + { + "id": "act_dl0g8ua8mb8s", + "kind": "assistant", + "message": "JARVIS Tool-Agent hat eine Anfrage beantwortet", + "createdAt": "2026-08-28T10:55:07+02:00" + }, + { + "id": "act_dl0g8mub1m4c", + "kind": "assistant", + "message": "JARVIS Tool-Agent hat eine Anfrage beantwortet", + "createdAt": "2026-08-28T10:54:51+02:00" + }, + { + "id": "act_dl0g89brc5k8", + "kind": "assistant", + "message": "JARVIS Tool-Agent hat eine Anfrage beantwortet", + "createdAt": "2026-08-28T10:54:22+02:00" + }, + { + "id": "act_dl0g7ivrb740", + "kind": "assistant", + "message": "JARVIS Tool-Agent hat eine Anfrage beantwortet", + "createdAt": "2026-08-28T10:53:24+02:00" + }, + { + "id": "act_dl0g73exyfmw", + "kind": "recipe", + "message": "Rezept gespeichert: Nudeln mit Thomatensauce", + "createdAt": "2026-08-28T10:52:50+02:00" + }, + { + "id": "act_dl0g6uacjv84", + "kind": "assistant", + "message": "JARVIS hat Home-Tools erfolgreich ausgeführt", + "createdAt": "2026-08-28T10:52:31+02:00" + }, + { + "id": "act_dl0g3u27bpx4", + "kind": "kanban", + "message": "KI-Kanban (interval/json-retry): 1 Karten gepflegt", + "createdAt": "2026-08-28T10:48:35+02:00" + }, + { + "id": "act_dl0fzw0kgiyg", + "kind": "kanban", + "message": "KI-Kanban (interval/native-tool): 1 Karten gepflegt", + "createdAt": "2026-08-28T10:43:26+02:00" + }, + { + "id": "act_dl0fxbbdsa4g", + "kind": "assistant", + "message": "JARVIS Tool-Agent hat eine Anfrage beantwortet", + "createdAt": "2026-08-28T10:40:04+02:00" + }, + { + "id": "act_dl0fwvy3j05w", + "kind": "kanban", + "message": "KI-Kanban (tool-agent/json-retry): 1 Karten gepflegt", + "createdAt": "2026-08-28T10:39:31+02:00" + }, + { + "id": "act_dl0fwotuzb1o", + "kind": "assistant", + "message": "JARVIS hat Home-Tools erfolgreich ausgeführt", + "createdAt": "2026-08-28T10:39:15+02:00" + }, + { + "id": "act_dl0fowkr6kkc", + "kind": "assistant", + "message": "JARVIS Tool-Agent hat eine Anfrage beantwortet", + "createdAt": "2026-08-28T10:29:05+02:00" + }, + { + "id": "act_dl0fd8ud3x9c", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T10:13:51+02:00" + }, + { + "id": "act_dl0f9dne4fbw", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T10:08:48+02:00" + }, + { + "id": "act_dl0f5lajcggc", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T10:03:52+02:00" + }, + { + "id": "act_dl0f1o1bnrxg", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:58:44+02:00" + }, + { + "id": "act_dl0exws90hk8", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:53:50+02:00" + }, + { + "id": "act_dl0eu22oce6w", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:48:48+02:00" + }, + { + "id": "act_dl0eqah9bfe0", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:43:53+02:00" + }, + { + "id": "act_dl0ein1qpmx4", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:33:53+02:00" + }, + { + "id": "act_dl0eer50msew", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:28:48+02:00" + }, + { + "id": "act_dl0eazx7ufaw", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:23:54+02:00" + }, + { + "id": "act_dl0e72g644oo", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:18:46+02:00" + }, + { + "id": "act_dl0e3bqgqifc", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:13:53+02:00" + }, + { + "id": "act_dl0dvnmwc4a4", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T09:03:52+02:00" + }, + { + "id": "act_dl0drrusgvhw", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T08:58:48+02:00" + }, + { + "id": "act_dl0dohftnngg", + "kind": "assistant", + "message": "JARVIS hat Home-Tools erfolgreich ausgeführt", + "createdAt": "2026-08-28T08:54:30+02:00" + }, + { + "id": "act_dl0dogj29yok", + "kind": "automation", + "message": "Home-Automation (tool:calendar_create): Einkauf 0 · Reminder 1 · Kanban 0", + "createdAt": "2026-08-28T08:54:28+02:00" + }, + { + "id": "act_dl0dlrccyobc", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T08:50:56+02:00" + }, + { + "id": "act_dl0dhfo9li4k", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T08:45:17+02:00" + }, + { + "id": "act_dl0ddk0451m8", + "kind": "kanban", + "message": "KI-Kanban (interval): 1 Karten gepflegt", + "createdAt": "2026-08-28T08:40:13+02:00" + }, + { + "id": "act_dl0dbueq52xw", + "kind": "kanban", + "message": "KI-Kanban (tool-agent): 1 Karten gepflegt", + "createdAt": "2026-08-28T08:37:59+02:00" + }, + { + "id": "act_dl0dbms3owqw", + "kind": "assistant", + "message": "JARVIS hat Home-Tools erfolgreich ausgeführt", + "createdAt": "2026-08-28T08:37:43+02:00" + }, + { + "id": "act_dl0dbme6x2a0", + "kind": "automation", + "message": "Home-Automation (tool:tasks_create): Einkauf 0 · Reminder 0 · Kanban 1", + "createdAt": "2026-08-28T08:37:42+02:00" + }, + { + "id": "act_dl0dbcrp5a3k", + "kind": "assistant", + "message": "JARVIS hat Home-Tools erfolgreich ausgeführt", + "createdAt": "2026-08-28T08:37:21+02:00" + }, + { + "id": "act_dl0dalzewhrc", + "kind": "assistant", + "message": "JARVIS hat Home-Tools erfolgreich ausgeführt", + "createdAt": "2026-08-28T08:36:23+02:00" + }, + { + "id": "act_dl0dal2c09zo", + "kind": "automation", + "message": "Home-Automation (tool:calendar_create): Einkauf 0 · Reminder 1 · Kanban 0", + "createdAt": "2026-08-28T08:36:21+02:00" + }, + { + "id": "act_dl00pno7a368", + "kind": "assistant", + "message": "JARVIS hat eine Anfrage beantwortet", + "createdAt": "2026-08-27T22:44:47+02:00" + }, + { + "id": "act_dl00p4atzqxo", + "kind": "assistant", + "message": "JARVIS hat eine Home-Aktion bestätigt", + "createdAt": "2026-08-27T22:44:05+02:00" + }, + { + "id": "act_dl00p4as3k5g", + "kind": "automation", + "message": "Sprach/Text-Automation: 1 Aktion(en)", + "createdAt": "2026-08-27T22:44:05+02:00" + }, + { + "id": "act_dl00opwdaqyc", + "kind": "recipe", + "message": "Rezept gespeichert: Nudeln mit Thomatensauce", + "createdAt": "2026-08-27T22:43:34+02:00" + } + ] +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..14fe74f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module homehub + +go 1.26 diff --git a/homehub b/homehub new file mode 100644 index 0000000..1f76fb4 Binary files /dev/null and b/homehub differ diff --git a/internal/debugtrace/logger.go b/internal/debugtrace/logger.go new file mode 100644 index 0000000..b27aff8 --- /dev/null +++ b/internal/debugtrace/logger.go @@ -0,0 +1,314 @@ +package debugtrace + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type contextKey struct{} + +type Config struct { + Enabled bool `json:"enabled"` + Dir string `json:"dir"` + MaxBytes int64 `json:"maxBytes"` + KeepFiles int `json:"keepFiles"` + MaxString int `json:"maxString"` + LogHTTPState bool `json:"logHttpState"` +} + +type Event struct { + Schema string `json:"schema"` + Timestamp string `json:"timestamp"` + TraceID string `json:"trace_id"` + Component string `json:"component"` + Stage string `json:"stage"` + Direction string `json:"direction,omitempty"` + Level string `json:"level,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + Data any `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +type Logger struct { + mu sync.Mutex + cfg Config + path string + file *os.File + size int64 +} + +func New(cfg Config) (*Logger, error) { + if cfg.Dir == "" { + cfg.Dir = "./data/debug" + } + if cfg.MaxBytes <= 0 { + cfg.MaxBytes = 25 << 20 + } + if cfg.KeepFiles <= 0 { + cfg.KeepFiles = 4 + } + if cfg.MaxString <= 0 { + cfg.MaxString = 24000 + } + l := &Logger{cfg: cfg, path: filepath.Join(cfg.Dir, "jarvis-debug.jsonl")} + if !cfg.Enabled { + return l, nil + } + if err := os.MkdirAll(cfg.Dir, 0o755); err != nil { + return nil, err + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + l.file = f + if st, err := f.Stat(); err == nil { + l.size = st.Size() + } + return l, nil +} + +func (l *Logger) Config() Config { return l.cfg } +func (l *Logger) Enabled() bool { return l != nil && l.cfg.Enabled } + +func NewTraceID(prefix string) string { + if strings.TrimSpace(prefix) == "" { + prefix = "trace" + } + var b [6]byte + if _, err := rand.Read(b[:]); err == nil { + return fmt.Sprintf("%s_%d_%s", prefix, time.Now().UnixMilli(), hex.EncodeToString(b[:])) + } + return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) +} + +func WithTrace(ctx context.Context, traceID string) context.Context { + if strings.TrimSpace(traceID) == "" { + traceID = NewTraceID("trace") + } + return context.WithValue(ctx, contextKey{}, traceID) +} +func TraceID(ctx context.Context) string { + if ctx == nil { + return "" + } + if v, ok := ctx.Value(contextKey{}).(string); ok { + return v + } + return "" +} +func EnsureTrace(ctx context.Context, prefix string) (context.Context, string) { + if id := TraceID(ctx); id != "" { + return ctx, id + } + id := NewTraceID(prefix) + return WithTrace(ctx, id), id +} + +func (l *Logger) Record(ctx context.Context, component, stage, direction, level string, data any, err error, duration time.Duration) { + if !l.Enabled() { + return + } + traceID := TraceID(ctx) + if traceID == "" { + traceID = NewTraceID("bg") + } + ev := Event{Schema: "jarvis.debug.v1", Timestamp: time.Now().Format(time.RFC3339Nano), TraceID: traceID, Component: component, Stage: stage, Direction: direction, Level: level, Data: l.sanitize(data)} + if err != nil { + ev.Error = err.Error() + } + if duration > 0 { + ev.DurationMS = duration.Milliseconds() + } + b, merr := json.Marshal(ev) + if merr != nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if l.file == nil { + return + } + if l.size+int64(len(b)+1) > l.cfg.MaxBytes { + _ = l.rotateLocked() + } + n, werr := l.file.Write(append(b, '\n')) + if werr == nil { + l.size += int64(n) + } +} + +func (l *Logger) rotateLocked() error { + if l.file != nil { + _ = l.file.Close() + l.file = nil + } + for i := l.cfg.KeepFiles - 1; i >= 1; i-- { + old := fmt.Sprintf("%s.%d", l.path, i) + next := fmt.Sprintf("%s.%d", l.path, i+1) + if i == l.cfg.KeepFiles-1 { + _ = os.Remove(next) + } + _ = os.Rename(old, next) + } + _ = os.Rename(l.path, l.path+".1") + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + l.file = f + l.size = 0 + return nil +} + +func (l *Logger) sanitize(v any) any { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + var x any + if err := json.Unmarshal(b, &x); err != nil { + return string(b) + } + return sanitizeValue(x, l.cfg.MaxString) +} +func sanitizeValue(v any, max int) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, val := range x { + lk := strings.ToLower(k) + if secretKey(lk) { + out[k] = "[REDACTED]" + } else { + out[k] = sanitizeValue(val, max) + } + } + return out + case []any: + out := make([]any, len(x)) + for i := range x { + out[i] = sanitizeValue(x[i], max) + } + return out + case string: + if max > 0 && len([]rune(x)) > max { + r := []rune(x) + return string(r[:max]) + fmt.Sprintf("\n[… %d chars truncated …]", len(r)-max) + } + return x + default: + return v + } +} +func secretKey(k string) bool { + for _, s := range []string{"password", "passwd", "authorization", "api_key", "apikey", "secret", "bearer", "cookie"} { + if strings.Contains(k, s) { + return true + } + } + return false +} + +func (l *Logger) Clear() error { + if !l.Enabled() { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + if l.file != nil { + _ = l.file.Close() + l.file = nil + } + matches, _ := filepath.Glob(filepath.Join(l.cfg.Dir, "jarvis-debug.jsonl*")) + for _, p := range matches { + _ = os.Remove(p) + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + l.file = f + l.size = 0 + return nil +} + +func (l *Logger) Events(limit int) ([]json.RawMessage, error) { + if !l.Enabled() { + return []json.RawMessage{}, nil + } + l.mu.Lock() + if l.file != nil { + _ = l.file.Sync() + } + l.mu.Unlock() + paths := []string{} + for i := l.cfg.KeepFiles; i >= 1; i-- { + p := fmt.Sprintf("%s.%d", l.path, i) + if _, err := os.Stat(p); err == nil { + paths = append(paths, p) + } + } + if _, err := os.Stat(l.path); err == nil { + paths = append(paths, l.path) + } + events := []json.RawMessage{} + for _, p := range paths { + f, err := os.Open(p) + if err != nil { + continue + } + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 64*1024), 4<<20) + for sc.Scan() { + line := append([]byte(nil), sc.Bytes()...) + if json.Valid(line) { + events = append(events, json.RawMessage(line)) + } + } + _ = f.Close() + } + if limit > 0 && len(events) > limit { + events = events[len(events)-limit:] + } + return events, nil +} + +func (l *Logger) WriteJSONL(w io.Writer, limit int) error { + events, err := l.Events(limit) + if err != nil { + return err + } + for _, e := range events { + if _, err := w.Write(append(e, '\n')); err != nil { + return err + } + } + return nil +} + +func (l *Logger) Files() ([]string, error) { + if !l.Enabled() { + return nil, nil + } + matches, err := filepath.Glob(filepath.Join(l.cfg.Dir, "jarvis-debug.jsonl*")) + if err != nil { + return nil, err + } + sort.Strings(matches) + return matches, nil +} + +var ErrDisabled = errors.New("debug tracing disabled") diff --git a/internal/debugtrace/logger_test.go b/internal/debugtrace/logger_test.go new file mode 100644 index 0000000..b06e458 --- /dev/null +++ b/internal/debugtrace/logger_test.go @@ -0,0 +1,57 @@ +package debugtrace + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestLoggerWritesTraceAndRedactsSecrets(t *testing.T) { + l, err := New(Config{Enabled: true, Dir: t.TempDir(), MaxBytes: 1 << 20, KeepFiles: 2, MaxString: 1000}) + if err != nil { + t.Fatal(err) + } + ctx := WithTrace(context.Background(), "trace_test") + l.Record(ctx, "planner", "request", "input", "info", map[string]any{"message": "Montag Nudeln", "authorization": "Bearer secret"}, nil, 0) + evs, err := l.Events(10) + if err != nil { + t.Fatal(err) + } + if len(evs) != 1 { + t.Fatalf("events=%d", len(evs)) + } + var ev Event + if err := json.Unmarshal(evs[0], &ev); err != nil { + t.Fatal(err) + } + if ev.TraceID != "trace_test" || ev.Component != "planner" || ev.Stage != "request" { + t.Fatalf("event=%+v", ev) + } + b, _ := json.Marshal(ev.Data) + s := string(b) + if strings.Contains(s, "Bearer secret") || !strings.Contains(s, "REDACTED") { + t.Fatalf("secret not redacted: %s", s) + } + if !strings.Contains(s, "Montag Nudeln") { + t.Fatalf("message missing: %s", s) + } +} + +func TestLoggerClear(t *testing.T) { + l, err := New(Config{Enabled: true, Dir: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + l.Record(WithTrace(context.Background(), "x"), "http", "request", "input", "info", map[string]any{"a": 1}, nil, 0) + if err := l.Clear(); err != nil { + t.Fatal(err) + } + evs, err := l.Events(10) + if err != nil { + t.Fatal(err) + } + if len(evs) != 0 { + t.Fatalf("events=%d", len(evs)) + } +} diff --git a/internal/documents/extract.go b/internal/documents/extract.go new file mode 100644 index 0000000..ec85f44 --- /dev/null +++ b/internal/documents/extract.go @@ -0,0 +1,131 @@ +package documents + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "fmt" + "io" + "net/mail" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" +) + +var tags = regexp.MustCompile(`(?s)<[^>]*>`) + +func Extract(name string, data []byte) (string, error) { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".txt", ".md", ".markdown", ".csv", ".json", ".yaml", ".yml", ".xml", ".ics", ".log": + return cleanText(string(data)), nil + case ".html", ".htm": + return cleanText(tags.ReplaceAllString(string(data), " ")), nil + case ".docx": + return extractDOCX(data) + case ".pdf": + return externalExtract(name, data, "pdftotext", func(path string) *exec.Cmd { + return exec.Command("pdftotext", "-layout", path, "-") + }) + case ".png", ".jpg", ".jpeg", ".webp", ".tif", ".tiff": + return externalExtract(name, data, "tesseract", func(path string) *exec.Cmd { + return exec.Command("tesseract", path, "stdout", "-l", "deu+eng") + }) + case ".eml": + msg, err := mail.ReadMessage(bytes.NewReader(data)) + if err != nil { + return "", err + } + body, err := io.ReadAll(io.LimitReader(msg.Body, 4<<20)) + if err != nil { + return "", err + } + return cleanText("Subject: " + msg.Header.Get("Subject") + "\nFrom: " + msg.Header.Get("From") + "\n\n" + string(body)), nil + default: + return "", fmt.Errorf("Dateityp %q wird noch nicht direkt unterstützt", ext) + } +} + +func extractDOCX(data []byte) (string, error) { + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return "", err + } + for _, f := range zr.File { + if f.Name != "word/document.xml" { + continue + } + rc, err := f.Open() + if err != nil { + return "", err + } + defer rc.Close() + dec := xml.NewDecoder(rc) + var b strings.Builder + for { + tok, err := dec.Token() + if err == io.EOF { + break + } + if err != nil { + return "", err + } + switch t := tok.(type) { + case xml.CharData: + b.WriteString(string(t)) + case xml.EndElement: + if t.Name.Local == "p" || t.Name.Local == "tr" { + b.WriteByte('\n') + } + } + } + return cleanText(b.String()), nil + } + return "", fmt.Errorf("word/document.xml fehlt") +} + +func externalExtract(name string, data []byte, tool string, build func(string) *exec.Cmd) (string, error) { + if _, err := exec.LookPath(tool); err != nil { + return "", fmt.Errorf("%s ist nicht installiert; für %s bitte lokal installieren", tool, filepath.Ext(name)) + } + f, err := os.CreateTemp("", "homehub-*"+filepath.Ext(name)) + if err != nil { + return "", err + } + path := f.Name() + defer os.Remove(path) + if _, err := f.Write(data); err != nil { + f.Close() + return "", err + } + if err := f.Close(); err != nil { + return "", err + } + out, err := build(path).CombinedOutput() + if err != nil { + return "", fmt.Errorf("%s: %w: %s", tool, err, string(out)) + } + return cleanText(string(out)), nil +} + +func cleanText(s string) string { + s = strings.ReplaceAll(s, "\x00", "") + lines := strings.Split(s, "\n") + out := make([]string, 0, len(lines)) + blank := false + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + if !blank { + out = append(out, "") + } + blank = true + continue + } + blank = false + out = append(out, line) + } + return strings.TrimSpace(strings.Join(out, "\n")) +} diff --git a/internal/ollama/client.go b/internal/ollama/client.go new file mode 100644 index 0000000..f1ac325 --- /dev/null +++ b/internal/ollama/client.go @@ -0,0 +1,605 @@ +package ollama + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + "time" +) + +var ( + ErrNoChatModel = errors.New("kein lokales Ollama-Chatmodell verfügbar") + ErrNoEmbeddingModel = errors.New("kein lokales Ollama-Embeddingmodell verfügbar") +) + +type Status struct { + Reachable bool `json:"reachable"` + ChatReady bool `json:"chatReady"` + EmbeddingReady bool `json:"embeddingReady"` + Model string `json:"model"` + EmbeddingModel string `json:"embeddingModel"` + RequestedModel string `json:"requestedModel"` + RequestedEmbeddingModel string `json:"requestedEmbeddingModel"` + Fallback bool `json:"fallback"` + Installed []string `json:"installed,omitempty"` + LastError string `json:"lastError,omitempty"` +} + +type Client struct { + BaseURL string + HTTP *http.Client + + hookMu sync.RWMutex + hook TraceHook + mu sync.RWMutex + Model string + EmbeddingModel string + requestedModel string + requestedEmbeddingModel string + status Status + chatOptions GenerationOptions + plannerOptions GenerationOptions +} + +type GenerationOptions struct { + NumCtx int `json:"num_ctx,omitempty"` + NumPredict int `json:"num_predict,omitempty"` + Temperature float64 `json:"temperature"` +} + +type ToolFunctionCall struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + +// UnmarshalJSON tolerates APIs/models that serialize tool arguments as a JSON +// string instead of an object. Ollama normally returns an object, but this +// keeps the local-agent boundary robust across model templates. +func (f *ToolFunctionCall) UnmarshalJSON(data []byte) error { + var raw struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + f.Name = raw.Name + f.Arguments = map[string]any{} + if len(raw.Arguments) == 0 || string(raw.Arguments) == "null" { + return nil + } + if err := json.Unmarshal(raw.Arguments, &f.Arguments); err == nil { + return nil + } + var encoded string + if err := json.Unmarshal(raw.Arguments, &encoded); err != nil { + return err + } + if strings.TrimSpace(encoded) == "" { + return nil + } + return json.Unmarshal([]byte(encoded), &f.Arguments) +} + +type ToolCall struct { + Function ToolFunctionCall `json:"function"` +} + +type Turn struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +type ToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` +} + +type Tool struct { + Type string `json:"type"` + Function ToolFunction `json:"function"` +} + +type TraceHook func(ctx context.Context, stage, direction string, data any, err error, duration time.Duration) + +type chatRequest struct { + Model string `json:"model"` + Messages []Turn `json:"messages"` + Stream bool `json:"stream"` + Format any `json:"format,omitempty"` + Tools []Tool `json:"tools,omitempty"` + Options *GenerationOptions `json:"options,omitempty"` +} +type chatResponse struct { + Message Turn `json:"message"` + Error string `json:"error,omitempty"` +} +type embedRequest struct { + Model string `json:"model"` + Input []string `json:"input"` + Truncate bool `json:"truncate"` +} +type embedResponse struct { + Embeddings [][]float64 `json:"embeddings"` + Error string `json:"error,omitempty"` +} +type tagsResponse struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + } `json:"models"` +} + +func New(baseURL, model, embeddingModel string) *Client { + c := &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTP: &http.Client{Timeout: 120 * time.Second}, + requestedModel: cleanRequested(model), + requestedEmbeddingModel: cleanRequested(embeddingModel), + } + c.Model = c.requestedModel + c.EmbeddingModel = c.requestedEmbeddingModel + c.status = Status{RequestedModel: c.requestedModel, RequestedEmbeddingModel: c.requestedEmbeddingModel, Model: c.Model, EmbeddingModel: c.EmbeddingModel} + return c +} + +func (c *Client) SetTraceHook(h TraceHook) { + c.hookMu.Lock() + c.hook = h + c.hookMu.Unlock() +} +func (c *Client) emitTrace(ctx context.Context, stage, direction string, data any, err error, started time.Time) { + c.hookMu.RLock() + h := c.hook + c.hookMu.RUnlock() + if h == nil { + return + } + d := time.Duration(0) + if !started.IsZero() { + d = time.Since(started) + } + h(ctx, stage, direction, data, err, d) +} + +func (c *Client) ConfigureGeneration(chat, planner GenerationOptions) { + c.mu.Lock() + defer c.mu.Unlock() + c.chatOptions = chat + c.plannerOptions = planner +} + +func (c *Client) GenerationOptions(jsonMode bool) GenerationOptions { + c.mu.RLock() + defer c.mu.RUnlock() + if jsonMode { + return c.plannerOptions + } + return c.chatOptions +} +func cleanRequested(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "auto" + } + return v +} + +func (c *Client) Status() Status { + c.mu.RLock() + defer c.mu.RUnlock() + s := c.status + s.Installed = append([]string(nil), s.Installed...) + return s +} + +func (c *Client) ResolveModels(ctx context.Context) Status { + started := time.Now() + c.emitTrace(ctx, "models.request", "input", map[string]any{"base_url": c.BaseURL, "requested_model": c.requestedModel, "requested_embedding_model": c.requestedEmbeddingModel}, nil, time.Time{}) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/api/tags", nil) + if err != nil { + c.emitTrace(ctx, "models.response", "output", nil, err, started) + return c.setResolveError(err) + } + resp, err := c.HTTP.Do(req) + if err != nil { + e := fmt.Errorf("ollama nicht erreichbar: %w", err) + c.emitTrace(ctx, "models.response", "output", nil, e, started) + return c.setResolveError(e) + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + e := fmt.Errorf("ollama tags HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + c.emitTrace(ctx, "models.response", "output", map[string]any{"status": resp.StatusCode, "body": string(body)}, e, started) + return c.setResolveError(e) + } + var out tagsResponse + if err := json.Unmarshal(body, &out); err != nil { + e := fmt.Errorf("ollama tags ungültig: %w", err) + c.emitTrace(ctx, "models.response", "output", map[string]any{"body": string(body)}, e, started) + return c.setResolveError(e) + } + installed := make([]string, 0, len(out.Models)) + seen := map[string]bool{} + for _, m := range out.Models { + name := strings.TrimSpace(m.Name) + if name == "" { + name = strings.TrimSpace(m.Model) + } + if name != "" && !seen[name] { + seen[name] = true + installed = append(installed, name) + } + } + sort.Strings(installed) + + c.mu.Lock() + chat, chatExact := chooseRequested(c.requestedModel, installed, false) + embed, embedExact := chooseRequested(c.requestedEmbeddingModel, installed, true) + c.Model = chat + c.EmbeddingModel = embed + fallback := (!isAuto(c.requestedModel) && chat != "" && !chatExact) || (!isAuto(c.requestedEmbeddingModel) && embed != "" && !embedExact) + c.status = Status{ + Reachable: true, + ChatReady: chat != "", + EmbeddingReady: embed != "", + Model: chat, + EmbeddingModel: embed, + RequestedModel: c.requestedModel, + RequestedEmbeddingModel: c.requestedEmbeddingModel, + Fallback: fallback, + Installed: append([]string(nil), installed...), + } + status := c.status + c.mu.Unlock() + c.emitTrace(ctx, "models.response", "output", status, nil, started) + return status +} + +func (c *Client) setResolveError(err error) Status { + c.mu.Lock() + defer c.mu.Unlock() + c.status.Reachable = false + c.status.ChatReady = false + c.status.EmbeddingReady = false + c.status.LastError = err.Error() + return c.status +} + +func isAuto(v string) bool { + return strings.TrimSpace(v) == "" || strings.EqualFold(strings.TrimSpace(v), "auto") +} + +func canonicalModel(v string) string { + v = strings.ToLower(strings.TrimSpace(v)) + return strings.TrimSuffix(v, ":latest") +} + +func exactInstalled(requested string, installed []string) (string, bool) { + want := canonicalModel(requested) + if want == "" || want == "auto" { + return "", false + } + for _, name := range installed { + if canonicalModel(name) == want { + return name, true + } + } + return "", false +} + +func chooseRequested(requested string, installed []string, embedding bool) (string, bool) { + if v, ok := exactInstalled(requested, installed); ok { + return v, true + } + if embedding { + return chooseEmbedding(installed), false + } + return chooseChat(installed), false +} + +func looksEmbedding(name string) bool { + n := canonicalModel(name) + for _, token := range []string{"embed", "embedding", "nomic", "mxbai", "bge-", "bge_", "snowflake-arctic-embed", "all-minilm", "granite-embedding"} { + if strings.Contains(n, token) { + return true + } + } + return false +} + +func chooseEmbedding(installed []string) string { + prefs := []string{"embeddinggemma", "nomic-embed-text", "mxbai-embed-large", "bge-m3", "snowflake-arctic-embed", "all-minilm", "granite-embedding"} + for _, p := range prefs { + for _, name := range installed { + if strings.Contains(canonicalModel(name), p) { + return name + } + } + } + for _, name := range installed { + if looksEmbedding(name) { + return name + } + } + return "" +} + +func chooseChat(installed []string) string { + prefs := []string{"qwen3", "qwen2.5", "llama3", "gemma3", "mistral", "phi", "deepseek", "command-r", "granite"} + for _, p := range prefs { + for _, name := range installed { + if !looksEmbedding(name) && strings.Contains(canonicalModel(name), p) { + return name + } + } + } + for _, name := range installed { + if !looksEmbedding(name) { + return name + } + } + return "" +} + +func (c *Client) activeChatModel() string { + c.mu.RLock() + defer c.mu.RUnlock() + if isAuto(c.Model) { + return "" + } + return c.Model +} +func (c *Client) activeEmbeddingModel() string { + c.mu.RLock() + defer c.mu.RUnlock() + if isAuto(c.EmbeddingModel) { + return "" + } + return c.EmbeddingModel +} + +func (c *Client) ensureChatModel(ctx context.Context) string { + if m := c.activeChatModel(); m != "" { + return m + } + c.ResolveModels(ctx) + return c.activeChatModel() +} +func (c *Client) ensureEmbeddingModel(ctx context.Context) string { + if m := c.activeEmbeddingModel(); m != "" { + return m + } + c.ResolveModels(ctx) + return c.activeEmbeddingModel() +} + +func (c *Client) Chat(ctx context.Context, system, user string, jsonMode bool) (string, error) { + return c.ChatWithHistory(ctx, system, nil, user, jsonMode) +} + +func (c *Client) ChatWithHistory(ctx context.Context, system string, history []Turn, user string, jsonMode bool) (string, error) { + model := c.ensureChatModel(ctx) + if model == "" { + return "", ErrNoChatModel + } + options := c.GenerationOptions(jsonMode) + out, err := c.chatOnce(ctx, model, system, history, user, jsonMode, options) + if err != nil && isModelNotFound(err) { + before := model + st := c.ResolveModels(ctx) + model = st.Model + if st.ChatReady && model != "" && canonicalModel(model) != canonicalModel(before) { + return c.chatOnce(ctx, model, system, history, user, jsonMode, options) + } + } + return out, err +} + +func (c *Client) chatOnce(ctx context.Context, model, system string, history []Turn, user string, jsonMode bool, options GenerationOptions) (string, error) { + messages := make([]Turn, 0, len(history)+2) + if strings.TrimSpace(system) != "" { + messages = append(messages, Turn{Role: "system", Content: system}) + } + for _, h := range history { + role := strings.ToLower(strings.TrimSpace(h.Role)) + if role != "user" && role != "assistant" { + continue + } + if strings.TrimSpace(h.Content) == "" { + continue + } + messages = append(messages, Turn{Role: role, Content: h.Content}) + } + messages = append(messages, Turn{Role: "user", Content: user}) + reqBody := chatRequest{Model: model, Messages: messages, Stream: false} + if jsonMode { + reqBody.Format = "json" + } + if options.NumCtx > 0 || options.NumPredict > 0 || options.Temperature > 0 { + o := options + reqBody.Options = &o + } + b, _ := json.Marshal(reqBody) + started := time.Now() + c.emitTrace(ctx, "chat.request", "input", map[string]any{"model": model, "json_mode": jsonMode, "messages": messages, "options": reqBody.Options}, nil, time.Time{}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/chat", bytes.NewReader(b)) + if err != nil { + c.emitTrace(ctx, "chat.response", "output", nil, err, started) + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + e := fmt.Errorf("ollama nicht erreichbar: %w", err) + c.emitTrace(ctx, "chat.response", "output", nil, e, started) + return "", e + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + e := fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + c.emitTrace(ctx, "chat.response", "output", map[string]any{"status": resp.StatusCode, "body": string(body)}, e, started) + return "", e + } + var out chatResponse + if err := json.Unmarshal(body, &out); err != nil { + c.emitTrace(ctx, "chat.response", "output", map[string]any{"body": string(body)}, err, started) + return "", err + } + if out.Error != "" { + e := fmt.Errorf("ollama: %s", out.Error) + c.emitTrace(ctx, "chat.response", "output", out, e, started) + return "", e + } + content := strings.TrimSpace(out.Message.Content) + c.emitTrace(ctx, "chat.response", "output", map[string]any{"status": resp.StatusCode, "message": out.Message, "content": content}, nil, started) + return content, nil +} + +// ChatWithTools calls Ollama's native tool/function-calling interface. The +// returned assistant turn may contain one or more tool_calls. Callers append +// that turn and one role=tool result message per call before the next round. +func (c *Client) ChatWithTools(ctx context.Context, messages []Turn, tools []Tool, options GenerationOptions) (Turn, error) { + model := c.ensureChatModel(ctx) + if model == "" { + return Turn{}, ErrNoChatModel + } + out, err := c.chatToolsOnce(ctx, model, messages, tools, options) + if err != nil && isModelNotFound(err) { + before := model + st := c.ResolveModels(ctx) + model = st.Model + if st.ChatReady && model != "" && canonicalModel(model) != canonicalModel(before) { + return c.chatToolsOnce(ctx, model, messages, tools, options) + } + } + return out, err +} + +func (c *Client) chatToolsOnce(ctx context.Context, model string, messages []Turn, tools []Tool, options GenerationOptions) (Turn, error) { + reqBody := chatRequest{Model: model, Messages: messages, Tools: tools, Stream: false} + if options.NumCtx > 0 || options.NumPredict > 0 || options.Temperature > 0 { + o := options + reqBody.Options = &o + } + b, _ := json.Marshal(reqBody) + started := time.Now() + c.emitTrace(ctx, "tools.request", "input", map[string]any{"model": model, "messages": messages, "tools": tools, "options": reqBody.Options}, nil, time.Time{}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/chat", bytes.NewReader(b)) + if err != nil { + c.emitTrace(ctx, "tools.response", "output", nil, err, started) + return Turn{}, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + e := fmt.Errorf("ollama nicht erreichbar: %w", err) + c.emitTrace(ctx, "tools.response", "output", nil, e, started) + return Turn{}, e + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + e := fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + c.emitTrace(ctx, "tools.response", "output", map[string]any{"status": resp.StatusCode, "body": string(body)}, e, started) + return Turn{}, e + } + var out chatResponse + if err := json.Unmarshal(body, &out); err != nil { + c.emitTrace(ctx, "tools.response", "output", map[string]any{"body": string(body)}, err, started) + return Turn{}, err + } + if out.Error != "" { + e := fmt.Errorf("ollama: %s", out.Error) + c.emitTrace(ctx, "tools.response", "output", out, e, started) + return Turn{}, e + } + c.emitTrace(ctx, "tools.response", "output", map[string]any{"status": resp.StatusCode, "message": out.Message, "tool_calls": out.Message.ToolCalls}, nil, started) + return out.Message, nil +} + +func (c *Client) Embed(ctx context.Context, inputs []string) ([][]float64, error) { + if len(inputs) == 0 { + return [][]float64{}, nil + } + model := c.ensureEmbeddingModel(ctx) + if model == "" { + return nil, ErrNoEmbeddingModel + } + out, err := c.embedOnce(ctx, model, inputs) + if err != nil && isModelNotFound(err) { + before := model + st := c.ResolveModels(ctx) + model = st.EmbeddingModel + if st.EmbeddingReady && model != "" && canonicalModel(model) != canonicalModel(before) { + return c.embedOnce(ctx, model, inputs) + } + } + return out, err +} + +func (c *Client) embedOnce(ctx context.Context, model string, inputs []string) ([][]float64, error) { + reqPayload := embedRequest{Model: model, Input: inputs, Truncate: true} + b, _ := json.Marshal(reqPayload) + started := time.Now() + c.emitTrace(ctx, "embed.request", "input", map[string]any{"model": model, "inputs": inputs, "truncate": true}, nil, time.Time{}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/api/embed", bytes.NewReader(b)) + if err != nil { + c.emitTrace(ctx, "embed.response", "output", nil, err, started) + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.HTTP.Do(req) + if err != nil { + e := fmt.Errorf("ollama embeddings nicht erreichbar: %w", err) + c.emitTrace(ctx, "embed.response", "output", nil, e, started) + return nil, e + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + e := fmt.Errorf("ollama embed HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + c.emitTrace(ctx, "embed.response", "output", map[string]any{"status": resp.StatusCode, "body": string(body)}, e, started) + return nil, e + } + var out embedResponse + if err := json.Unmarshal(body, &out); err != nil { + c.emitTrace(ctx, "embed.response", "output", map[string]any{"body_bytes": len(body)}, err, started) + return nil, err + } + if out.Error != "" { + e := fmt.Errorf("ollama embed: %s", out.Error) + c.emitTrace(ctx, "embed.response", "output", nil, e, started) + return nil, e + } + if len(out.Embeddings) != len(inputs) { + e := fmt.Errorf("ollama embed: %d Vektoren für %d Inputs", len(out.Embeddings), len(inputs)) + c.emitTrace(ctx, "embed.response", "output", nil, e, started) + return nil, e + } + dim := 0 + if len(out.Embeddings) > 0 { + dim = len(out.Embeddings[0]) + } + c.emitTrace(ctx, "embed.response", "output", map[string]any{"status": resp.StatusCode, "vectors": len(out.Embeddings), "dimension": dim, "note": "embedding vectors intentionally omitted"}, nil, started) + return out.Embeddings, nil +} + +func isModelNotFound(err error) bool { + if err == nil { + return false + } + s := strings.ToLower(err.Error()) + return strings.Contains(s, "model") && strings.Contains(s, "not found") +} diff --git a/internal/ollama/client_test.go b/internal/ollama/client_test.go new file mode 100644 index 0000000..4dc9512 --- /dev/null +++ b/internal/ollama/client_test.go @@ -0,0 +1,82 @@ +package ollama + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestResolveModelsFallsBackToInstalled(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/tags", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{ + {"name": "qwen3:8b"}, + {"name": "nomic-embed-text:latest"}, + }}) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + c := New(srv.URL, "qwen3:4b", "embeddinggemma") + st := c.ResolveModels(context.Background()) + if !st.Reachable || !st.ChatReady || !st.EmbeddingReady { + t.Fatalf("status=%+v", st) + } + if st.Model != "qwen3:8b" { + t.Fatalf("chat=%q", st.Model) + } + if st.EmbeddingModel != "nomic-embed-text:latest" { + t.Fatalf("embed=%q", st.EmbeddingModel) + } + if !st.Fallback { + t.Fatal("fallback should be reported") + } +} + +func TestAutoUsesInstalledModels(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/tags", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{ + {"name": "llama3.2:3b"}, {"name": "embeddinggemma:latest"}, + }}) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + c := New(srv.URL, "auto", "auto") + st := c.ResolveModels(context.Background()) + if st.Model != "llama3.2:3b" || st.EmbeddingModel != "embeddinggemma:latest" { + t.Fatalf("status=%+v", st) + } +} + +func TestChatWithHistorySendsRealMessagesAndContextOptions(t *testing.T) { + var got chatRequest + mux := http.NewServeMux() + mux.HandleFunc("/api/tags", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "gemma3:4b"}}}) + }) + mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]any{"role": "assistant", "content": "ok"}}) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + c := New(srv.URL, "auto", "auto") + c.ConfigureGeneration(GenerationOptions{NumCtx: 16384, NumPredict: 1200, Temperature: .15}, GenerationOptions{NumCtx: 8192, NumPredict: 700}) + if _, err := c.ChatWithHistory(context.Background(), "system", []Turn{{Role: "user", Content: "Montag Nudeln"}, {Role: "assistant", Content: "verstanden"}}, "stattdessen Dienstag", false); err != nil { + t.Fatal(err) + } + if len(got.Messages) != 4 { + t.Fatalf("messages=%d", len(got.Messages)) + } + if got.Messages[1].Content != "Montag Nudeln" || got.Messages[2].Role != "assistant" { + t.Fatalf("history=%+v", got.Messages) + } + if got.Options == nil || got.Options.NumCtx != 16384 || got.Options.NumPredict != 1200 { + t.Fatalf("options=%+v", got.Options) + } +} diff --git a/internal/rag/index.go b/internal/rag/index.go new file mode 100644 index 0000000..0fe21c5 --- /dev/null +++ b/internal/rag/index.go @@ -0,0 +1,204 @@ +package rag + +import ( + "encoding/json" + "errors" + "math" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +type Chunk struct { + ID string `json:"id"` + DocumentID string `json:"documentId"` + Name string `json:"name"` + Text string `json:"text"` + Embedding []float64 `json:"embedding"` +} + +type Hit struct { + DocumentID string `json:"documentId"` + Name string `json:"name"` + Text string `json:"text"` + Score float64 `json:"score"` +} + +type Index struct { + mu sync.RWMutex + path string + chunks []Chunk +} + +func New(path string) (*Index, error) { + i := &Index{path: path, chunks: []Chunk{}} + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, err + } + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return i, i.saveLocked() + } + if err != nil { + return nil, err + } + if len(b) > 0 { + if err := json.Unmarshal(b, &i.chunks); err != nil { + return nil, err + } + } + return i, nil +} + +func ChunkText(text string, size, overlap int) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + if size < 200 { + size = 1200 + } + if overlap < 0 { + overlap = 0 + } + if overlap >= size { + overlap = size / 5 + } + r := []rune(text) + var out []string + for start := 0; start < len(r); { + end := start + size + if end > len(r) { + end = len(r) + } + // Prefer a natural boundary near the end. + if end < len(r) { + floor := start + size*2/3 + for j := end; j > floor; j-- { + if r[j-1] == '\n' || r[j-1] == '.' || r[j-1] == '!' || r[j-1] == '?' { + end = j + break + } + } + } + piece := strings.TrimSpace(string(r[start:end])) + if piece != "" { + out = append(out, piece) + } + if end == len(r) { + break + } + start = end - overlap + if start < 0 { + start = 0 + } + } + return out +} + +func (i *Index) ReplaceDocument(docID, name string, texts []string, vectors [][]float64) error { + if len(texts) != len(vectors) { + return errors.New("chunk/vector count mismatch") + } + i.mu.Lock() + defer i.mu.Unlock() + kept := i.chunks[:0] + for _, c := range i.chunks { + if c.DocumentID != docID { + kept = append(kept, c) + } + } + i.chunks = append([]Chunk(nil), kept...) + for idx, text := range texts { + i.chunks = append(i.chunks, Chunk{ID: docID + "#" + itoa(idx), DocumentID: docID, Name: name, Text: text, Embedding: vectors[idx]}) + } + return i.saveLocked() +} + +func (i *Index) DeleteDocument(docID string) error { + i.mu.Lock() + defer i.mu.Unlock() + out := i.chunks[:0] + for _, c := range i.chunks { + if c.DocumentID != docID { + out = append(out, c) + } + } + i.chunks = append([]Chunk(nil), out...) + return i.saveLocked() +} + +func (i *Index) HasDocument(docID string) bool { + i.mu.RLock() + defer i.mu.RUnlock() + for _, c := range i.chunks { + if c.DocumentID == docID { + return true + } + } + return false +} + +func (i *Index) Count() int { i.mu.RLock(); defer i.mu.RUnlock(); return len(i.chunks) } + +func (i *Index) Search(query []float64, k int) []Hit { + i.mu.RLock() + defer i.mu.RUnlock() + if k <= 0 { + k = 6 + } + hits := make([]Hit, 0, len(i.chunks)) + for _, c := range i.chunks { + if len(c.Embedding) == 0 || len(c.Embedding) != len(query) { + continue + } + hits = append(hits, Hit{DocumentID: c.DocumentID, Name: c.Name, Text: c.Text, Score: cosine(query, c.Embedding)}) + } + sort.Slice(hits, func(a, b int) bool { return hits[a].Score > hits[b].Score }) + if len(hits) > k { + hits = hits[:k] + } + return hits +} + +func (i *Index) saveLocked() error { + b, err := json.Marshal(i.chunks) + if err != nil { + return err + } + tmp := i.path + ".tmp" + if err := os.WriteFile(tmp, b, 0600); err != nil { + return err + } + return os.Rename(tmp, i.path) +} + +func cosine(a, b []float64) float64 { + var dot, aa, bb float64 + for idx := range a { + dot += a[idx] * b[idx] + aa += a[idx] * a[idx] + bb += b[idx] * b[idx] + } + if aa == 0 || bb == 0 { + return 0 + } + return dot / (math.Sqrt(aa) * math.Sqrt(bb)) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + const d = "0123456789" + var b [20]byte + p := len(b) + for n > 0 { + p-- + b[p] = d[n%10] + n /= 10 + } + return string(b[p:]) +} diff --git a/internal/rag/index_test.go b/internal/rag/index_test.go new file mode 100644 index 0000000..d0c5e8b --- /dev/null +++ b/internal/rag/index_test.go @@ -0,0 +1,25 @@ +package rag + +import ( + "strings" + "testing" +) + +func TestChunkTextOverlap(t *testing.T) { + text := strings.Repeat("Alpha beta gamma delta. ", 120) + chunks := ChunkText(text, 220, 30) + if len(chunks) < 2 { + t.Fatalf("expected multiple chunks, got %d", len(chunks)) + } + for _, c := range chunks { + if c == "" { + t.Fatal("empty chunk") + } + } +} + +func TestCosine(t *testing.T) { + if s := cosine([]float64{1, 0}, []float64{1, 0}); s < 0.99 { + t.Fatalf("unexpected score %f", s) + } +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..4de1768 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,256 @@ +package store + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "time" +) + +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + Notes string `json:"notes,omitempty"` + Due string `json:"due,omitempty"` + Status string `json:"status"` + Priority string `json:"priority"` + Source string `json:"source,omitempty"` + Confidence float64 `json:"confidence,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + AIManaged bool `json:"aiManaged"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type CalendarEvent struct { + ID string `json:"id"` + Title string `json:"title"` + Start string `json:"start"` + End string `json:"end,omitempty"` + AllDay bool `json:"allDay"` + Location string `json:"location,omitempty"` + Notes string `json:"notes,omitempty"` + Source string `json:"source,omitempty"` + AIManaged bool `json:"aiManaged"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type Reminder struct { + ID string `json:"id"` + Title string `json:"title"` + At string `json:"at"` + Notes string `json:"notes,omitempty"` + Done bool `json:"done"` + LinkedType string `json:"linkedType,omitempty"` + LinkedID string `json:"linkedId,omitempty"` + Source string `json:"source,omitempty"` + AIManaged bool `json:"aiManaged"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type GroceryItem struct { + ID string `json:"id"` + Name string `json:"name"` + Quantity string `json:"quantity,omitempty"` + Unit string `json:"unit,omitempty"` + Category string `json:"category,omitempty"` + Checked bool `json:"checked"` + Source string `json:"source,omitempty"` + AIManaged bool `json:"aiManaged"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type Ingredient struct { + Name string `json:"name"` + Quantity string `json:"quantity,omitempty"` + Unit string `json:"unit,omitempty"` +} + +type Recipe struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Servings int `json:"servings,omitempty"` + PrepMinutes int `json:"prepMinutes,omitempty"` + Ingredients []Ingredient `json:"ingredients"` + Steps []string `json:"steps"` + Tags []string `json:"tags,omitempty"` + Source string `json:"source,omitempty"` + AIManaged bool `json:"aiManaged"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type MealPlan struct { + ID string `json:"id"` + Date string `json:"date"` + Meal string `json:"meal"` + RecipeID string `json:"recipeId,omitempty"` + Title string `json:"title"` + Servings int `json:"servings,omitempty"` + Notes string `json:"notes,omitempty"` + AIManaged bool `json:"aiManaged"` + Source string `json:"source,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type KanbanCard struct { + ID string `json:"id"` + Title string `json:"title"` + Notes string `json:"notes,omitempty"` + Column string `json:"column"` + Priority string `json:"priority"` + Due string `json:"due,omitempty"` + TaskID string `json:"taskId,omitempty"` + Source string `json:"source,omitempty"` + AIManaged bool `json:"aiManaged"` + Confidence float64 `json:"confidence,omitempty"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +type Document struct { + ID string `json:"id"` + Name string `json:"name"` + Mime string `json:"mime,omitempty"` + Text string `json:"text"` + Tags []string `json:"tags,omitempty"` + ChunkCount int `json:"chunkCount"` + IndexedAt string `json:"indexedAt,omitempty"` + CreatedAt string `json:"createdAt"` +} + +type ChatTurn struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt string `json:"createdAt"` +} + +type Activity struct { + ID string `json:"id"` + Kind string `json:"kind"` + Message string `json:"message"` + CreatedAt string `json:"createdAt"` +} + +type State struct { + Tasks []Task `json:"tasks"` + Events []CalendarEvent `json:"events"` + Reminders []Reminder `json:"reminders"` + Groceries []GroceryItem `json:"groceries"` + Recipes []Recipe `json:"recipes"` + MealPlans []MealPlan `json:"mealPlans"` + Kanban []KanbanCard `json:"kanban"` + Documents []Document `json:"documents"` + Chat []ChatTurn `json:"chat"` + Activity []Activity `json:"activity"` +} + +type Store struct { + mu sync.RWMutex + path string + data State +} + +func New(path string) (*Store, error) { + s := &Store{path: path} + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + s.data = emptyState() + if err := s.saveLocked(); err != nil { + return nil, err + } + return s, nil + } + if err != nil { + return nil, err + } + if len(b) > 0 { + if err := json.Unmarshal(b, &s.data); err != nil { + return nil, err + } + } + normalize(&s.data) + return s, nil +} + +func emptyState() State { + return State{Tasks: []Task{}, Events: []CalendarEvent{}, Reminders: []Reminder{}, Groceries: []GroceryItem{}, Recipes: []Recipe{}, MealPlans: []MealPlan{}, Kanban: []KanbanCard{}, Documents: []Document{}, Chat: []ChatTurn{}, Activity: []Activity{}} +} + +func normalize(s *State) { + if s.Tasks == nil { + s.Tasks = []Task{} + } + if s.Events == nil { + s.Events = []CalendarEvent{} + } + if s.Reminders == nil { + s.Reminders = []Reminder{} + } + if s.Groceries == nil { + s.Groceries = []GroceryItem{} + } + if s.Recipes == nil { + s.Recipes = []Recipe{} + } + if s.MealPlans == nil { + s.MealPlans = []MealPlan{} + } + if s.Kanban == nil { + s.Kanban = []KanbanCard{} + } + if s.Documents == nil { + s.Documents = []Document{} + } + if s.Chat == nil { + s.Chat = []ChatTurn{} + } + if s.Activity == nil { + s.Activity = []Activity{} + } +} + +func (s *Store) Snapshot() State { + s.mu.RLock() + defer s.mu.RUnlock() + b, _ := json.Marshal(s.data) + var out State + _ = json.Unmarshal(b, &out) + normalize(&out) + return out +} + +func (s *Store) Update(fn func(*State) error) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := fn(&s.data); err != nil { + return err + } + normalize(&s.data) + return s.saveLocked() +} + +func (s *Store) saveLocked() error { + b, err := json.MarshalIndent(s.data, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func Now() string { return time.Now().Format(time.RFC3339) } diff --git a/internal/ui/embed.go b/internal/ui/embed.go new file mode 100644 index 0000000..9cbd765 --- /dev/null +++ b/internal/ui/embed.go @@ -0,0 +1,6 @@ +package ui + +import "embed" + +//go:embed static/* +var Files embed.FS diff --git a/internal/ui/static/app.js b/internal/ui/static/app.js new file mode 100644 index 0000000..7e52e36 --- /dev/null +++ b/internal/ui/static/app.js @@ -0,0 +1,98 @@ +const $=(q,root=document)=>root.querySelector(q);const $$=(q,root=document)=>[...root.querySelectorAll(q)]; +let lastTraceId='';let state={tasks:[],events:[],reminders:[],groceries:[],recipes:[],mealPlans:[],kanban:[],documents:[],chat:[],activity:[],system:{}}; +let bootstrappedChat=false,pttRecorder=null,pttStream=null,pttChunks=[],pttContext='',wakeEnabled=false,wakeStream=null,wakeToken=0,wakeArmedUntil=0,wakeBusy=false,speaking=false,commandBusy=false,toastTimer,dialogType='',dialogEditing=null; +const esc=s=>String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); +const localDateKey=d=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +const parseDate=v=>{if(!v)return null;if(/^\d{4}-\d{2}-\d{2}$/.test(v)){const[a,b,c]=v.split('-').map(Number);return new Date(a,b-1,c)}const d=new Date(v);return Number.isNaN(d.getTime())?null:d}; +const fmtTime=iso=>{const d=parseDate(iso);if(!d)return iso||'—';return new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}).format(d)}; +const fmtClock=iso=>{const d=parseDate(iso);if(!d)return'—';return /^\d{4}-\d{2}-\d{2}$/.test(iso)?'GANZTÄGIG':d.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})}; +const fmtDay=d=>d.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',month:'2-digit'}); +const mealName=m=>({breakfast:'FRÜH',lunch:'MITTAG',dinner:'ABEND',snack:'SNACK'}[m]||String(m||'ABEND').toUpperCase()); +const taskOrigin=t=>t.source==='jarvis-command'?'JARVIS':t.aiManaged?'AI SCOUT':'MANUAL'; +const messageHTML=text=>esc(text).replace(/\*\*(.+?)\*\*/g,'$1').replace(/`([^`]+)`/g,'$1').replace(/\n/g,'
'); +const THEME_KEY='jarvis-ui-theme',VIEW_KEY='jarvis-clean-view'; +const cleanViews={ + home:{eyebrow:'Übersicht',title:'Home',subtitle:'Dein Zuhause, Termine und Aufgaben auf einen Blick.'}, + calendar:{eyebrow:'Planung',title:'Kalender',subtitle:'Termine und Erinnerungen in einer ruhigen Zeitansicht.'}, + tasks:{eyebrow:'Fokus',title:'Aufgaben',subtitle:'Offene Punkte priorisieren, abhaken und per Sprache verwalten.'}, + kanban:{eyebrow:'Workflow',title:'Kanban',subtitle:'Arbeit sichtbar machen und den KI-Organizer gezielt einsetzen.'}, + food:{eyebrow:'Haushalt',title:'Essen & Einkauf',subtitle:'Essensplan, Einkaufsliste und Rezepte an einem Ort.'}, + knowledge:{eyebrow:'System',title:'Wissen & System',subtitle:'Dokumentwissen, Sprache, Modelle und Aktivität des lokalen Systems.'} +}; +function applyCleanView(view,persist=true){const v=cleanViews[view]?view:'home';document.body.dataset.view=v;$$('[data-clean-view]').forEach(btn=>btn.classList.toggle('active',btn.dataset.cleanView===v));const m=cleanViews[v];if($('#cleanViewEyebrow'))$('#cleanViewEyebrow').textContent=m.eyebrow;if($('#cleanViewTitle'))$('#cleanViewTitle').textContent=m.title;if($('#cleanViewSubtitle'))$('#cleanViewSubtitle').textContent=m.subtitle;if(persist)try{localStorage.setItem(VIEW_KEY,v)}catch(_){}window.scrollTo({top:0,behavior:'smooth'});} +function applyTheme(theme,persist=false){const t=theme==='apple'?'apple':'jarvis';document.body.dataset.theme=t;document.documentElement.style.colorScheme=t==='apple'?'light':'dark';const meta=document.querySelector('meta[name="theme-color"]');if(meta)meta.setAttribute('content',t==='apple'?'#f4f5f7':'#02060a');$$('.theme-option').forEach(btn=>btn.classList.toggle('active',btn.dataset.theme===t));if(t==='apple'&&!document.body.dataset.view){let view='home';try{view=localStorage.getItem(VIEW_KEY)||view}catch(_){}applyCleanView(view,false)}if(persist)try{localStorage.setItem(THEME_KEY,t)}catch(_){} } +function initTheme(){let theme='jarvis',view='home';try{theme=localStorage.getItem(THEME_KEY)||theme;view=localStorage.getItem(VIEW_KEY)||view}catch(_){}document.body.dataset.view=cleanViews[view]?view:'home';applyTheme(theme,false);applyCleanView(document.body.dataset.view,false);$$('.theme-option').forEach(btn=>btn.onclick=()=>applyTheme(btn.dataset.theme,true));$$('[data-clean-view]').forEach(btn=>btn.onclick=()=>applyCleanView(btn.dataset.cleanView,true));$$('[data-clean-jump]').forEach(btn=>btn.onclick=()=>applyCleanView(btn.dataset.cleanJump,true));if($('#cleanOpenHome'))$('#cleanOpenHome').onclick=()=>applyCleanView('home',true);if($('#cleanQuickMic'))$('#cleanQuickMic').onclick=()=>startPTT('');} +async function api(url,options={}){const res=await fetch(url,options);if(!res.ok){throw new Error((await res.text()).trim()||`HTTP ${res.status}`)}const type=res.headers.get('content-type')||'';return type.includes('application/json')?res.json():res} +function toast(msg){const el=$('#toast');el.textContent=msg;el.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.classList.remove('show'),2800)} +function tickClock(){const n=new Date();$('#clock').textContent=n.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('#date').textContent=n.toLocaleDateString('de-DE',{weekday:'short',day:'2-digit',month:'short',year:'numeric'}).toUpperCase();if($('#cleanTopDate'))$('#cleanTopDate').textContent=n.toLocaleDateString('de-DE',{weekday:'long',day:'numeric',month:'long',year:'numeric'})}setInterval(tickClock,1000);tickClock(); +function setChip(id,ok,label){const dot=$(id+'Dot');if(!dot)return;const chip=dot.closest('.status-chip');chip.classList.toggle('online',!!ok);chip.classList.toggle('offline',ok===false);$(id+'Status').textContent=label} +async function refresh(){try{state=await api('/api/state');renderState();setChip('#core',true,'ONLINE')}catch(e){setChip('#core',false,'OFFLINE')}} +function renderState(){const sys=state.system||{},voice=sys.voice||{},ollama=sys.ollama||{},chunks=Number(sys.ragChunks||0);const chatReady=ollama.chatReady!==false&&!!(ollama.model||sys.model),embedReady=ollama.embeddingReady!==false&&!!(ollama.embeddingModel||sys.embeddingModel);setChip('#core',ollama.reachable===false?false:chatReady,chatReady?'LOCAL':(ollama.reachable===false?'OLLAMA OFF':'NO MODEL'));$('#coreStatus').title=chatReady?`ACTIVE MODEL: ${ollama.model||sys.model}`:(ollama.lastError||'Kein Chat-Modell erkannt');setChip('#rag',embedReady?(chunks>0?true:null):false,embedReady?(chunks?`${chunks} CHUNKS`:'EMPTY'):'NO EMBED');setChip('#stt',!!voice.sttReady,voice.sttReady?'LOCAL':'SETUP');setChip('#tts',!!voice.ttsReady,voice.ttsReady?'LOCAL':'SETUP');const mt=$('#modelTag'),ctxN=Number(sys.context?.numCtx||0),ctxLabel=ctxN>=1024?`${Math.round(ctxN/1024)}K`:(ctxN||'—');if(mt){mt.textContent=chatReady?`LOCAL · ${String(ollama.model||sys.model).toUpperCase()} · CTX ${ctxLabel}`:'LOCAL · DEGRADED';mt.title=`Tool Agent: ${sys.agent?.mode||'unknown'} · ${sys.agent?.tools?.length||0} tools · max ${sys.agent?.maxSteps||0} steps · Conversation ${sys.context?.historyTokens||0} tokens`;}const scout=$('#scoutBtn'),kai=$('#kanbanAI'),reindex=$('#reindexBtn');if(scout){scout.disabled=!chatReady;scout.title=chatReady?'Lokalen Task-Scout starten':'Benötigt ein lokales Ollama-Chatmodell'}if(kai){kai.disabled=!chatReady;kai.title=chatReady?'Kanban mit lokalem Modell organisieren':'Benötigt ein lokales Ollama-Chatmodell'}if(reindex){reindex.disabled=!embedReady;reindex.title=embedReady?'RAG neu indexieren':'Benötigt ein lokales Embedding-Modell'}$('#chunkCount').textContent=chunks;$('#memoryBar').style.width=`${Math.min(100,5+Math.log2(chunks+1)*12)}%`;$('#wakeWord').textContent=String(voice.wakeWord||'jarvis').toUpperCase();$('#whisperDiag').textContent=voice.sttReady?'ONLINE':'MISSING';$('#whisperDiag').className=voice.sttReady?'good':'bad';$('#piperDiag').textContent=voice.ttsReady?'ONLINE':'MISSING';$('#piperDiag').className=voice.ttsReady?'good':'bad';renderCleanHome();renderBrief();renderCalendar();renderTasks();renderKanban();renderGroceries();renderMeals();renderRecipes();renderReminders();renderDocuments();renderActivity();const dbg=sys.debug||{};const dstat=$('#debugTraceStatus');if(dstat){dstat.classList.toggle('off',!dbg.enabled);dstat.innerHTML=` ${dbg.enabled?'DEBUG TRACE':'TRACE OFF'}`};if($('#debugExportBtn'))$('#debugExportBtn').disabled=!dbg.enabled;if($('#debugClearBtn'))$('#debugClearBtn').disabled=!dbg.enabled;if(!bootstrappedChat){renderInitialChat();bootstrappedChat=true}} +function renderCleanHome(){ + const now=new Date(),today=new Date();today.setHours(0,0,0,0);const tomorrow=new Date(today);tomorrow.setDate(tomorrow.getDate()+1);const weekEnd=new Date(today);weekEnd.setDate(weekEnd.getDate()+7); + const events=[...(state.events||[])].filter(e=>{const d=parseDate(e.start);return d&&d>=today}).sort((a,b)=>String(a.start).localeCompare(String(b.start))); + const tasks=[...(state.tasks||[])].filter(t=>t.status!=='done').sort((a,b)=>(b.priority==='high')-(a.priority==='high')||String(a.due||'z').localeCompare(String(b.due||'z'))); + const mealOrder={breakfast:0,lunch:1,dinner:2,snack:3};const meals=[...(state.mealPlans||[])].filter(m=>{const d=parseDate(m.date);return d&&d>=today}).sort((a,b)=>String(a.date).localeCompare(String(b.date))+(mealOrder[a.meal]||0)-(mealOrder[b.meal]||0)); + const hour=now.getHours(),greet=hour<11?'Guten Morgen':hour<17?'Guten Tag':'Guten Abend';if($('#cleanGreetingLabel'))$('#cleanGreetingLabel').textContent=greet; + const eventWeek=events.filter(e=>{const d=parseDate(e.start);return d&&d!g.checked).length; + if($('#cleanGreeting'))$('#cleanGreeting').textContent=openTasks||eventWeek?'Das Wichtigste ist vorbereitet.':'Alles ruhig. Du bist auf dem aktuellen Stand.'; + if($('#cleanHomeSummary'))$('#cleanHomeSummary').textContent=`${eventWeek} Termin${eventWeek===1?'':'e'} in den nächsten 7 Tagen · ${openTasks} offene Aufgabe${openTasks===1?'':'n'} · ${groceryOpen} Einkauf${groceryOpen===1?'':'seinträge'}.`; + if($('#homeCalendarQuick'))$('#homeCalendarQuick').textContent=`${eventWeek} ${eventWeek===1?'Termin':'Termine'}`;if($('#homeTasksQuick'))$('#homeTasksQuick').textContent=`${openTasks} offen`;if($('#homeKanbanQuick'))$('#homeKanbanQuick').textContent=`${(state.kanban||[]).length} Karten`; + const nextMeal=meals[0];if($('#homeFoodQuick'))$('#homeFoodQuick').textContent=nextMeal?`${fmtDay(parseDate(nextMeal.date))} · ${mealName(nextMeal.meal)}`:'Nichts geplant'; + const ev=events[0],evEl=$('#cleanNextEvent');if(evEl)evEl.innerHTML=ev?`${esc(ev.title)}

${esc(fmtDay(parseDate(ev.start)))} · ${esc(ev.allDay?'Ganztägig':fmtClock(ev.start))}${ev.location?' · '+esc(ev.location):''}

`:'Keine Termine

Dein Kalender ist frei.

'; + const task=tasks[0],taskEl=$('#cleanNextTask');if(taskEl)taskEl.innerHTML=task?`${esc(task.title)}

${task.priority==='high'?'Hohe Priorität':'Offen'}${task.due?' · '+esc(fmtTime(task.due)):''}

`:'Keine offenen Aufgaben

Du bist auf dem aktuellen Stand.

'; + const meal=nextMeal,mealEl=$('#cleanNextMeal');if(mealEl)mealEl.innerHTML=meal?`${esc(meal.title||meal.recipeTitle||'Geplante Mahlzeit')}

${esc(fmtDay(parseDate(meal.date)))} · ${esc(mealName(meal.meal))}${meal.servings?` · ${meal.servings} Portionen`:''}

`:'Noch nichts geplant

Plane eine Mahlzeit für heute oder morgen.

'; + const sys=state.system||{},ollama=sys.ollama||{},voice=sys.voice||{};if($('#cleanCoreQuick'))$('#cleanCoreQuick').textContent=ollama.chatReady===false?'Nicht bereit':String(ollama.model||sys.model||'Lokal');if($('#cleanRagQuick'))$('#cleanRagQuick').textContent=Number(sys.ragChunks||0)?`${sys.ragChunks} Chunks`:'Leer';if($('#cleanVoiceQuick'))$('#cleanVoiceQuick').textContent=voice.sttReady?'Bereit':'Setup';if($('#cleanModelStatus'))$('#cleanModelStatus').textContent=ollama.chatReady===false?'Modell fehlt':String(ollama.model||sys.model||'Online'); +} +function renderBrief(){const start=new Date();start.setHours(0,0,0,0);const until=new Date(start);until.setDate(until.getDate()+7);$('#briefEvents').textContent=(state.events||[]).filter(e=>{const d=parseDate(e.start);return d&&d>=start&&dt.status!=='done').length;$('#briefGroceries').textContent=(state.groceries||[]).filter(g=>!g.checked).length;$('#briefReminders').textContent=(state.reminders||[]).filter(r=>!r.done).length} +function renderCalendar(){const today=new Date();today.setHours(0,0,0,0);const end=new Date(today);end.setDate(end.getDate()+7);const events=[...(state.events||[])].sort((a,b)=>String(a.start).localeCompare(String(b.start)));const days=[];for(let i=0;i<7;i++){const d=new Date(today);d.setDate(d.getDate()+i);const key=localDateKey(d),count=events.filter(e=>{const ed=parseDate(e.start);return ed&&localDateKey(ed)===key}).length;days.push(`
${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}${d.getDate()}${count?`${count}`:''}
`)}$('#weekRail').innerHTML=days.join('');const visible=events.filter(e=>{const d=parseDate(e.start);return d&&d>=today&&d`
${esc(e.allDay?'GANZTÄGIG':fmtClock(e.start))}
${esc(e.title)}${esc(fmtDay(parseDate(e.start)))}${e.location?' · '+esc(e.location):''} · ${e.aiManaged?'JARVIS':'MANUAL'}
`).join(''):'
KEINE TERMINE IN DEN NÄCHSTEN 7 TAGEN
';$$('[data-delete-event]').forEach(b=>b.onclick=()=>deleteEntity('/api/events/',b.dataset.deleteEvent,'Termin entfernt'))} +function renderTasks(){const tasks=state.tasks||[],open=tasks.filter(t=>t.status!=='done');$('#taskOpen').textContent=open.length;$('#taskAI').textContent=open.filter(t=>t.aiManaged).length;$('#taskHigh').textContent=open.filter(t=>t.priority==='high').length;const ordered=[...tasks].sort((a,b)=>(a.status==='done')-(b.status==='done')||(b.priority==='high')-(a.priority==='high')||String(a.due||'z').localeCompare(String(b.due||'z')));$('#taskList').innerHTML=ordered.length?ordered.slice(0,24).map(t=>`
${esc(t.title)}
${taskOrigin(t)}${esc(t.priority||'normal')}${t.due?`DUE ${esc(fmtTime(t.due))}`:''}
${t.aiManaged?`
CONFIDENCE ${Math.round((t.confidence||0)*100)}%${t.source?' · '+esc(t.source):''}
`:''}
`).join(''):'
ACTION QUEUE CLEAR
';$$('[data-task-check]').forEach(el=>el.onchange=async()=>{const t=state.tasks.find(x=>x.id===el.dataset.taskCheck);if(!t)return;try{await post('/api/tasks',{...t,status:el.checked?'done':'open'});await refresh()}catch(e){toast(e.message)}});$$('[data-task-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/tasks/',b.dataset.taskDelete,'Aufgabe entfernt'))} +const columns=[['inbox','INBOX'],['next','NEXT'],['doing','DOING'],['waiting','WAITING'],['done','DONE']]; +function renderKanban(){const cards=state.kanban||[];$('#kanbanBoard').innerHTML=columns.map(([key,label])=>{const list=cards.filter(c=>c.column===key);return `
${label}${list.length}
${list.map(c=>`
${esc(c.title)}${c.notes?`

${esc(c.notes)}

`:''}
${c.aiManaged?'AI '+Math.round((c.confidence||0)*100)+'%':'MANUAL'} · ${esc(c.priority||'normal')}${c.due?' · '+esc(fmtTime(c.due)):''}
`).join('')}
`}).join('');$$('.kanban-card').forEach(card=>card.ondragstart=e=>{e.dataTransfer.setData('text/plain',card.dataset.cardId);e.dataTransfer.effectAllowed='move'});$$('[data-kanban-col]').forEach(col=>{col.ondragover=e=>{e.preventDefault();col.classList.add('drag-over')};col.ondragleave=()=>col.classList.remove('drag-over');col.ondrop=async e=>{e.preventDefault();col.classList.remove('drag-over');const id=e.dataTransfer.getData('text/plain'),card=state.kanban.find(c=>c.id===id);if(!card)return;try{await post('/api/kanban',{...card,column:col.dataset.kanbanCol,aiManaged:false,source:'manual-override'});toast('Karte manuell fixiert');await refresh()}catch(err){toast(err.message)}}});$$('[data-card-delete]').forEach(b=>b.onclick=e=>{e.stopPropagation();deleteEntity('/api/kanban/',b.dataset.cardDelete,'Karte entfernt')})} +function renderGroceries(){const xs=state.groceries||[],open=xs.filter(x=>!x.checked);$('#groceryOpen').textContent=open.length;$('#groceryBar').style.width=`${xs.length?Math.round(open.length/xs.length*100):0}%`;const sorted=[...xs].sort((a,b)=>a.checked-b.checked||String(a.category).localeCompare(String(b.category)));$('#groceryList').innerHTML=sorted.length?sorted.map(g=>`
${esc([g.quantity,g.unit,g.name].filter(Boolean).join(' '))}${esc(g.category||'Allgemein')} · ${g.aiManaged?'AI/AUTO':'MANUAL'}${g.source?.startsWith('mealplan:auto:')?' · ESSENSPLAN':''}
`).join(''):'
EINKAUFSLISTE LEER
';$$('[data-grocery-check]').forEach(el=>el.onchange=async()=>{const g=state.groceries.find(x=>x.id===el.dataset.groceryCheck);if(!g)return;try{await post('/api/groceries',{...g,checked:el.checked});await refresh()}catch(e){toast(e.message)}});$$('[data-grocery-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/groceries/',b.dataset.groceryDelete,'Eintrag entfernt'))} +function renderMeals(){const today=new Date();today.setHours(0,0,0,0);const plans=state.mealPlans||[];const out=[];for(let i=0;i<7;i++){const d=new Date(today);d.setDate(d.getDate()+i);const key=localDateKey(d),list=plans.filter(m=>m.date===key).sort((a,b)=>String(a.meal).localeCompare(String(b.meal)));out.push(`
${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}${d.getDate()}.${d.getMonth()+1}.
${list.length?list.map(m=>`
${mealName(m.meal)}${esc(m.title)}${m.servings||2} PORT. ${m.aiManaged?'· AI':''}
`).join(''):'
OPEN
'}
`)}$('#mealTimeline').innerHTML=out.join('');$$('[data-meal-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/meal-plans/',b.dataset.mealDelete,'Plan entfernt'))} +function renderRecipes(){const rs=state.recipes||[];$('#recipeList').innerHTML=rs.length?rs.slice(0,40).map(r=>`
${esc(r.title)}

${esc(r.description||`${(r.ingredients||[]).length} Zutaten · ${(r.steps||[]).length} Schritte`)}

${r.prepMinutes?`${r.prepMinutes} MIN · `:''}${r.servings||2} PORT. ${r.aiManaged?'· AI':''}
`).join(''):'
NO CULINARY MEMORY
';$$('[data-recipe-edit]').forEach(b=>b.onclick=()=>{const r=state.recipes.find(x=>x.id===b.dataset.recipeEdit);if(r)openDialog('recipe',r)});$$('[data-recipe-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/recipes/',b.dataset.recipeDelete,'Rezept entfernt'))} +function renderReminders(){const rs=[...(state.reminders||[])].sort((a,b)=>a.done-b.done||String(a.at).localeCompare(String(b.at)));$('#reminderList').innerHTML=rs.length?rs.slice(0,24).map(r=>`
${esc(r.title)}${esc(fmtTime(r.at))} · ${r.aiManaged?'AI/AUTO':'MANUAL'}${r.linkedType==='event'?' · KALENDER':''}
`).join(''):'
ALERT GRID CLEAR
';$$('[data-reminder-check]').forEach(el=>el.onchange=async()=>{const r=state.reminders.find(x=>x.id===el.dataset.reminderCheck);if(!r)return;try{await post('/api/reminders',{...r,done:el.checked});await refresh()}catch(e){toast(e.message)}});$$('[data-reminder-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/reminders/',b.dataset.reminderDelete,'Erinnerung entfernt'))} +function renderDocuments(){const docs=state.documents||[];$('#documentList').innerHTML=docs.length?docs.slice(0,14).map(d=>`
${esc((d.name.split('.').pop()||'DOC').slice(0,4).toUpperCase())}
${esc(d.name)}${d.chunkCount||0} chunks · ${d.indexedAt?'INDEXED':'PENDING'}
`).join(''):'
KNOWLEDGE VAULT EMPTY
';$$('[data-doc-delete]').forEach(b=>b.onclick=()=>deleteEntity('/api/documents/',b.dataset.docDelete,'Dokument entfernt'))} +function renderActivity(){const xs=state.activity||[];$('#activityList').innerHTML=xs.length?xs.slice(0,20).map(a=>`
${esc(a.message)}${esc(fmtTime(a.createdAt))} · ${esc(String(a.kind||'event').toUpperCase())}
`).join(''):'
NO TELEMETRY EVENTS
'} +function renderInitialChat(){const conv=$('#conversation'),turns=(state.chat||[]).slice(-12);if(!turns.length)return;conv.innerHTML='';turns.forEach(t=>addMessage(t.content,t.role==='user'?'user':'assistant',false));conv.scrollTop=conv.scrollHeight} +function addMessage(text,role='assistant',animate=true,mode=''){const conv=$('#conversation'),el=document.createElement('div');el.className=`message ${role} ${mode||''}`.trim();const label=role==='user'?'OPERATOR':mode==='action'?'JARVIS // COMMIT':mode==='clarification'?'JARVIS // CLARIFY':'JARVIS';el.innerHTML=`
${role==='user'?'U':'J'}
${label}

`;$('p',el).innerHTML=messageHTML(text);if(animate)el.style.opacity='0';conv.appendChild(el);conv.scrollTop=conv.scrollHeight;if(animate)requestAnimationFrame(()=>{el.style.transition='.22s';el.style.opacity='1'});return el} +function setCoreMode(mode,caption){$('#voiceModeLabel').textContent=mode;const active=!['STANDBY','READY'].includes(mode);$('#coreVisual').classList.toggle('active',active);if(caption)$('#overlayTranscript').dataset.caption=caption} +async function post(url,obj){return api(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(obj)})} +async function deleteEntity(prefix,id,msg){try{await api(prefix+encodeURIComponent(id),{method:'DELETE'});toast(msg);await refresh()}catch(e){toast(e.message)}} +async function sendCommand(message,fromVoice=false,contextName=''){const raw=String(message||'').trim();if(!raw)return;if(commandBusy){toast('JARVIS verarbeitet bereits einen Befehl');return}commandBusy=true;const exec=$('#commandForm .execute');exec.disabled=true;exec.textContent='PROCESSING';$('#commandInput').value='';addMessage(contextName?`[${contextName}] ${raw}`:raw,'user');setCoreMode('THINKING');const pending=addMessage('Agent → Tool wählen → JSON validieren → ausführen …','assistant');pending.classList.add('pending');const routed=contextName?`Im Kontext des Moduls ${contextName}: ${raw}`:raw;try{const out=await post('/api/ai/chat',{message:routed});lastTraceId=out.traceId||'';pending.remove();addMessage(out.reply,'assistant',true,out.mode||'');renderSources(out.sources||[],out.automations||[],out.mode||'chat',lastTraceId,out.toolResults||[],out.plan||null);if(out.automations?.length)toast(out.automations.map(a=>a.message).join(' · '));setCoreMode(out.mode==='clarification'?'AWAITING INPUT':'READY');if(fromVoice)await speakLocal(out.reply);await refresh()}catch(e){pending.remove();addMessage(`Systemfehler: ${e.message}`,'assistant');setCoreMode('STANDBY')}finally{commandBusy=false;exec.disabled=false;exec.innerHTML='EXECUTE ↗'}setTimeout(()=>setCoreMode(wakeEnabled?'WAKE ARMED':'STANDBY'),1400)} +function renderSources(sources,actions=[],mode='chat',traceId='',toolResults=[],plan=null){const trace=traceId?`TRACE ${esc(traceId.slice(-12).toUpperCase())}`:'';const planTitle=plan?.steps?.length?plan.steps.map((s,i)=>`${i+1}. ${s.task||s.tool}`).join('\n'):'';const planChip=plan?.steps?.length?`PLAN · ${plan.steps.length} SCHRITTE`:'';const toolChips=(toolResults||[]).slice(0,8).map(t=>`${esc(String(t.tool||'TOOL').toUpperCase())}${t.success===false?' · ERROR':''}`).join('');if(actions.length){$('#sourceRail').innerHTML=`TOOL COMMIT · ${actions.length} MUTATION${actions.length===1?'':'S'}`+planChip+toolChips+trace;return}if(toolChips||planChip){$('#sourceRail').innerHTML=`TOOL AGENT`+planChip+toolChips+trace;return}$('#sourceRail').innerHTML=(sources.length?sources.slice(0,7).map(s=>`${esc(s.name)} · ${Math.round((s.score||0)*100)}%`).join(''):`${mode==='clarification'?'AGENT AWAITS INPUT':'LOCAL TOOL CORE'}`)+trace} +$('#commandForm').onsubmit=e=>{e.preventDefault();sendCommand($('#commandInput').value)};$('#commandInput').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();$('#commandForm').requestSubmit()}}; +$('#clearChatBtn').onclick=async()=>{try{await api('/api/chat',{method:'DELETE'});state.chat=[];const conv=$('#conversation');conv.innerHTML='';addMessage('Command channel bereinigt. Home-Daten, Kalender, Aufgaben und RAG bleiben unverändert.','assistant',false,'action');renderSources([],[],'chat');toast('Chatverlauf geleert')}catch(e){toast(e.message)}}; +$('#debugExportBtn').onclick=()=>{const a=document.createElement('a');a.href='/api/debug/export?limit=10000';a.download='jarvis-debug.json';document.body.appendChild(a);a.click();a.remove();toast(lastTraceId?`Debug-Export · letzter Trace ${lastTraceId.slice(-12)}`:'Debug-Export wird erstellt')}; +$('#debugClearBtn').onclick=async()=>{if(!confirm('Nur den Debug-Trace löschen? Home-Daten und Chat bleiben erhalten.'))return;try{await api('/api/debug',{method:'DELETE'});toast('Debug-Trace geleert')}catch(e){toast(e.message)}}; +$('#automationBtn').onclick=async()=>{const b=$('#automationBtn');b.disabled=true;b.textContent='SYNCHRONIZING';try{const o=await api('/api/automation/run',{method:'POST'});toast(`Einkauf ${o.groceries} · Reminder ${o.reminders} · Kanban ${o.kanban}`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='RUN HOME AUTOMATION'}}; +$('#scoutBtn').onclick=async()=>{const b=$('#scoutBtn');b.disabled=true;b.textContent='SCANNING';setCoreMode('SCANNING');try{const out=await api('/api/ai/scout',{method:'POST'});toast(`${out.created} neue Aufgabe(n)`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='SCAN FOR TASKS';setCoreMode('STANDBY')}}; +$('#kanbanAI').onclick=async()=>{const b=$('#kanbanAI');b.disabled=true;b.textContent='ORGANIZING';try{const out=await api('/api/ai/kanban',{method:'POST'});toast(`${out.updated} KI-Karten gepflegt`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='AI ORGANIZE'}}; +const drop=$('#dropzone'),fileInput=$('#fileInput');['dragenter','dragover'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('drag')}));['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('drag')}));drop.ondrop=e=>{const f=e.dataTransfer.files?.[0];if(f)uploadDocument(f)};fileInput.onchange=()=>{if(fileInput.files?.[0])uploadDocument(fileInput.files[0]);fileInput.value=''}; +async function uploadDocument(file){const fd=new FormData();fd.append('file',file);toast('Dokument wird lokal indexiert …');setCoreMode('INDEXING');try{const out=await api('/api/documents',{method:'POST',body:fd});toast(out.indexed?'Dokument im RAG':'Text gespeichert, Indexierung fehlgeschlagen');await refresh()}catch(e){toast(e.message)}finally{setCoreMode('STANDBY')}} +$('#reindexBtn').onclick=async()=>{const b=$('#reindexBtn');b.disabled=true;b.textContent='INDEXING';try{const o=await api('/api/rag/reindex',{method:'POST'});toast(`${o.indexed} Dokument(e), ${o.chunks} Chunks`);await refresh()}catch(e){toast(e.message)}finally{b.disabled=false;b.textContent='REINDEX'}}; + +const dialogDefs={ + task:{title:'Neue Aufgabe',code:'ACTION QUEUE',endpoint:'/api/tasks',fields:[['title','Titel','text',true],['priority','Priorität','select',false,['normal','high','low']],['due','Fällig','datetime-local'],['notes','Notizen','textarea']]}, + event:{title:'Neuer Termin',code:'TEMPORAL MATRIX',endpoint:'/api/events',fields:[['title','Titel','text',true],['start','Start','datetime-local',true],['end','Ende','datetime-local'],['location','Ort','text'],['notes','Notizen','textarea']]}, + reminder:{title:'Neue Erinnerung',code:'ALERT GRID',endpoint:'/api/reminders',fields:[['title','Titel','text',true],['at','Zeitpunkt','datetime-local',true],['notes','Notizen','textarea']]}, + grocery:{title:'Einkauf ergänzen',code:'SUPPLY MATRIX',endpoint:'/api/groceries',fields:[['name','Artikel','text',true],['quantity','Menge','text'],['unit','Einheit','text'],['category','Kategorie','text']]}, + meal:{title:'Essen planen',code:'NUTRITION SCHEDULE',endpoint:'/api/meal-plans',fields:[['date','Datum','date',true],['meal','Mahlzeit','select',false,['breakfast','lunch','dinner','snack']],['recipeId','Rezept','recipe-select'],['title','Freies Gericht','text'],['servings','Portionen','number'],['notes','Notizen','textarea']]}, + recipe:{title:'Rezept speichern',code:'CULINARY MEMORY',endpoint:'/api/recipes',fields:[['title','Titel','text',true],['servings','Portionen','number'],['prepMinutes','Zubereitung (Min.)','number'],['description','Beschreibung','textarea'],['ingredientsText','Zutaten · je Zeile: Menge | Einheit | Name','textarea'],['stepsText','Schritte · einer je Zeile','textarea'],['tagsText','Tags · kommagetrennt','text']]}, + kanban:{title:'Manuelle Kanban-Karte',code:'FLOW ENGINE',endpoint:'/api/kanban',fields:[['title','Titel','text',true],['column','Spalte','select',false,['inbox','next','doing','waiting','done']],['priority','Priorität','select',false,['normal','high','low']],['due','Fällig','datetime-local'],['notes','Notizen','textarea']]} +}; +function fieldHTML(f){const[name,label,type,required,opts]=f,req=required?'required':'';if(type==='textarea')return `
`;if(type==='select')return `
`;if(type==='recipe-select')return `
`;return `
`} +function openDialog(type,existing=null){const d=dialogDefs[type];if(!d)return;dialogType=type;dialogEditing=existing?structuredClone(existing):null;$('#dialogCode').textContent=d.code;$('#dialogTitle').textContent=existing&&type==='recipe'?'Rezept bearbeiten':d.title;$('#dialogFields').innerHTML=d.fields.map(fieldHTML).join('');const form=$('#entityForm');form.reset();if(type==='meal')form.elements.date.value=localDateKey(new Date());if(type==='task'||type==='kanban')form.elements.priority.value='normal';if(type==='kanban')form.elements.column.value='inbox';if(type==='meal'){form.elements.meal.value='dinner';form.elements.servings.value='2'}if(type==='recipe')form.elements.servings.value='2';if(existing){for(const [k,val] of Object.entries(existing)){if(form.elements[k]&&val!=null)form.elements[k].value=val}if(type==='recipe'){form.elements.ingredientsText.value=(existing.ingredients||[]).map(i=>[i.quantity||'',i.unit||'',i.name||''].join(' | ')).join('\n');form.elements.stepsText.value=(existing.steps||[]).join('\n');form.elements.tagsText.value=(existing.tags||[]).join(', ')}}$('#entityDialog').showModal()} +$$('[data-add]').forEach(b=>b.onclick=()=>openDialog(b.dataset.add));const syncMealGroceriesBtn=$('#syncMealGroceriesBtn');if(syncMealGroceriesBtn)syncMealGroceriesBtn.onclick=async()=>{try{const out=await post('/api/automation/run',{});toast(`Einkauf aus Essensplan aktualisiert${out?.groceries!=null?` · ${out.groceries} Änderungen`:''}`);await refresh()}catch(e){toast(e.message)}};$('#dialogClose').onclick=$('#dialogCancel').onclick=()=>{dialogEditing=null;$('#entityDialog').close()}; +$('#entityForm').onsubmit=async e=>{e.preventDefault();const def=dialogDefs[dialogType],fd=new FormData(e.currentTarget),raw=Object.fromEntries(fd.entries()),v=dialogEditing?{...dialogEditing,...raw}:raw;['due','start','end','at'].forEach(k=>{if(v[k])v[k]=new Date(v[k]).toISOString();else if(k in v)delete v[k]});['servings','prepMinutes'].forEach(k=>{if(v[k])v[k]=Number(v[k])});if(dialogType==='recipe'){v.ingredients=String(v.ingredientsText||'').split('\n').map(x=>x.trim()).filter(Boolean).map(line=>{const p=line.split('|').map(x=>x.trim());return p.length>=3?{quantity:p[0],unit:p[1],name:p.slice(2).join(' | ')}:{name:line}});v.steps=String(v.stepsText||'').split('\n').map(x=>x.trim()).filter(Boolean);v.tags=String(v.tagsText||'').split(',').map(x=>x.trim()).filter(Boolean);delete v.ingredientsText;delete v.stepsText;delete v.tagsText}if(dialogType==='meal'&&v.recipeId){const r=state.recipes.find(x=>x.id===v.recipeId);if(r&&!v.title)v.title=r.title}if(dialogType==='task'){v.status='open';v.aiManaged=false}if(dialogType==='kanban'){v.aiManaged=false;v.source='manual'}try{await post(def.endpoint,v);dialogEditing=null;$('#entityDialog').close();toast('Gespeichert');await refresh()}catch(err){toast(err.message)}}; + +function mediaOptions(){for(const t of['audio/webm;codecs=opus','audio/webm','audio/ogg;codecs=opus','audio/mp4'])if(window.MediaRecorder?.isTypeSupported?.(t))return{mimeType:t};return{}}function extFor(type){if(type.includes('ogg'))return'.ogg';if(type.includes('mp4'))return'.mp4';return'.webm'}async function ensureMic(){if(!navigator.mediaDevices?.getUserMedia)throw new Error('Browser unterstützt keinen Mikrofonzugriff');return navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}})}async function transcribeBlob(blob){const fd=new FormData();fd.append('audio',blob,'voice'+extFor(blob.type||'audio/webm'));const out=await api('/api/voice/transcribe',{method:'POST',body:fd});return String(out.text||'').trim()} +async function startPTT(contextName=''){if(pttRecorder){stopPTT();return}if(!(state.system?.voice?.sttReady)){toast('Lokales Whisper ist noch nicht konfiguriert');return}pttContext=contextName;const resumeWake=wakeEnabled;if(resumeWake)await stopWake(false);try{pttStream=await ensureMic();pttChunks=[];pttRecorder=new MediaRecorder(pttStream,mediaOptions());pttRecorder.ondataavailable=e=>{if(e.data?.size)pttChunks.push(e.data)};pttRecorder.onstop=async()=>{const rec=pttRecorder;pttRecorder=null;const blob=new Blob(pttChunks,{type:rec?.mimeType||'audio/webm'});pttChunks=[];pttStream?.getTracks().forEach(t=>t.stop());pttStream=null;$('#globalMic').classList.remove('recording');$('#voiceOverlay').classList.remove('show');setCoreMode('TRANSCRIBE');try{const text=await transcribeBlob(blob);$('#overlayTranscript').textContent=text||'Kein Sprachsignal erkannt';if(text)await sendCommand(text,true,pttContext)}catch(e){toast(e.message);setCoreMode('STANDBY')}pttContext='';if(resumeWake&&$('#wakeToggle').checked)startWake()};pttRecorder.start();$('#globalMic').classList.add('recording');$('#voiceOverlay').classList.add('show');$('#voiceContextLabel').textContent=(contextName||'GLOBAL').toUpperCase();$('#overlayStatus').textContent='LISTENING';$('#overlayTranscript').textContent='Sprich jetzt · erneut drücken zum Senden';setCoreMode('LISTENING')}catch(e){toast(e.message);pttContext='';if(resumeWake&&$('#wakeToggle').checked)startWake()}} +function stopPTT(){if(pttRecorder&&pttRecorder.state!=='inactive'){$('#overlayStatus').textContent='PROCESSING';pttRecorder.stop()}}$('#globalMic').onclick=()=>startPTT('');$('#inlineMic').onclick=()=>startPTT('');$$('.module-mic').forEach(b=>b.onclick=()=>startPTT(b.dataset.voiceContext||'')); +function recordFixedChunk(stream,ms){return new Promise((resolve,reject)=>{let chunks=[],rec;try{rec=new MediaRecorder(stream,mediaOptions())}catch(e){reject(e);return}rec.ondataavailable=e=>{if(e.data?.size)chunks.push(e.data)};rec.onerror=e=>reject(e.error||new Error('Recorder error'));rec.onstop=()=>resolve(new Blob(chunks,{type:rec.mimeType||'audio/webm'}));rec.start();setTimeout(()=>{if(rec.state!=='inactive')rec.stop()},ms)})} +async function startWake(){if(wakeEnabled)return;if(!(state.system?.voice?.sttReady)){$('#wakeToggle').checked=false;toast('Wake-Word benötigt lokales Whisper');return}wakeEnabled=true;const token=++wakeToken;try{wakeStream=await ensureMic();$('#micDiag').textContent='HOT';$('#micDiag').className='good';$('#wakeHelp').textContent=`Lokaler Wake-Scan aktiv. Sage „${String(state.system.voice.wakeWord||'jarvis').toUpperCase()}“ und danach deinen Befehl.`;setCoreMode('WAKE ARMED')}catch(e){wakeEnabled=false;$('#wakeToggle').checked=false;toast(e.message);return}while(wakeEnabled&&token===wakeToken){try{const blob=await recordFixedChunk(wakeStream,3000);if(!wakeEnabled||token!==wakeToken||speaking||blob.size<1000)continue;if(wakeBusy)continue;wakeBusy=true;let text='';try{text=await transcribeBlob(blob)}catch(e){if(wakeEnabled)console.debug(e.message)}finally{wakeBusy=false}if(text)processWakeTranscript(text)}catch(e){if(wakeEnabled)toast('Wake-Word: '+e.message);break}}} +async function stopWake(updateToggle=true){wakeEnabled=false;++wakeToken;wakeStream?.getTracks().forEach(t=>t.stop());wakeStream=null;wakeArmedUntil=0;$('#micDiag').textContent='READY';$('#micDiag').className='';if(updateToggle)$('#wakeToggle').checked=false;$('#wakeHelp').textContent='Wake-Word aus. Jeder Modul-Button und der globale Voice-Core starten Push-to-talk.';setCoreMode('STANDBY')} +function processWakeTranscript(raw){const text=raw.trim(),wake=String(state.system?.voice?.wakeWord||'jarvis').toLowerCase(),lower=text.toLowerCase(),idx=lower.indexOf(wake);if(idx>=0){const after=text.slice(idx+wake.length).replace(/^[\s,.:;!\-]+/,'').trim();wakeArmedUntil=Date.now()+8500;$('#voiceOverlay').classList.add('show');$('#voiceContextLabel').textContent='GLOBAL';$('#overlayStatus').textContent='WAKE CONFIRMED';$('#overlayTranscript').textContent=after||'Ich höre …';setCoreMode('COMMAND');if(after.length>1){wakeArmedUntil=0;$('#voiceOverlay').classList.remove('show');sendCommand(after,true)}else setTimeout(()=>{if(Date.now()>wakeArmedUntil)$('#voiceOverlay').classList.remove('show')},9000);return}if(Date.now()1){wakeArmedUntil=0;$('#voiceOverlay').classList.remove('show');sendCommand(text,true)}}$('#wakeToggle').onchange=e=>{if(e.target.checked)startWake();else stopWake()}; +async function speakLocal(text){if(!(state.system?.voice?.ttsReady))return;speaking=true;setCoreMode('SPEAKING');try{const res=await fetch('/api/voice/speak',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})});if(!res.ok)throw new Error((await res.text()).trim()||`TTS HTTP ${res.status}`);const blob=await res.blob(),url=URL.createObjectURL(blob),audio=new Audio(url);await new Promise((resolve,reject)=>{audio.onended=resolve;audio.onerror=reject;audio.play().catch(reject)});URL.revokeObjectURL(url)}catch(e){toast('TTS: '+e.message)}finally{speaking=false;setCoreMode(wakeEnabled?'WAKE ARMED':'STANDBY')}} +window.addEventListener('beforeunload',()=>{pttStream?.getTracks().forEach(t=>t.stop());wakeStream?.getTracks().forEach(t=>t.stop())});initTheme();refresh();setInterval(refresh,9000); diff --git a/internal/ui/static/index.html b/internal/ui/static/index.html new file mode 100644 index 0000000..c642029 --- /dev/null +++ b/internal/ui/static/index.html @@ -0,0 +1,177 @@ + + + + + + + + JARVIS // Home Command + + + +
+ + + + + +
+
+
Übersicht

Home

Dein Zuhause, Termine und Aufgaben auf einen Blick.

+
+ — + + +
+
+
+
J
LOCAL AUTONOMOUS HOME OS

JARVIS // HOME COMMAND

+
+
COREBOOT
+
RAG—
+
STT—
+
TTS—
+
+
--:--:----
+
+ +
+
+
+
J
+
NEURAL VOICE CORESTANDBY
+
+
+
00 // UNIFIED COMMAND CHANNEL

Home Intelligence

LOCAL ONLY
+
J
JARVIS

Home-System online. Ich kann Kalender, Aufgaben, Einkauf, Rezepte, Essensplan, Erinnerungen und Kanban lokal verwalten.

+
+
+
+
+ LIVE HOME MATRIX +
0TERMINE / 7T
0OFFENE TASKS
0EINKAUF
0REMINDER
+ + +
+
+ +
+
+
Guten Morgen

Alles im Blick.

Jarvis synchronisiert gerade deinen lokalen Home-State.

+
+ + + + +
+
+
+
Als Nächstes
+
Keine Termine

Dein Kalender ist frei.

+
+
+
Heute erledigen
+
Keine offenen Aufgaben

Du bist auf dem aktuellen Stand.

+
+
+
Essen
+
Noch nichts geplant

Plane eine Mahlzeit für heute oder morgen.

+
+
+
Local AI
+
Core—
RAG—
Voice—
+
+
+ +
+
+
01 // TEMPORAL MATRIX

Kalender · 7 Tage

+
+
+ +
+
02 // ACTION QUEUE

Aufgaben

+
OPEN0
AI FOUND0
HIGH0
+
+
+ +
+
03 // AUTONOMOUS FLOW ENGINE

KI Kanban

Drag & Drop · manuelle Karten bleiben geschützt
+
+
+ +
+
04 // SUPPLY MATRIX

Einkaufsliste

+
OFFEN0
+
+ +
+
05 // NUTRITION SCHEDULE

Essensplanung

+
+
+ +
+
06 // CULINARY MEMORY

Rezepte

+
+
+ +
+
07 // ALERT GRID

Erinnerungen

+
+
+ +
+
08 // KNOWLEDGE VAULT

RAG Memory

+ +
VECTOR CHUNKS0
+
+ +
+
09 // VOICE MATRIX

Wake Protocol

+
CODEWORDJARVIS
+

Wake-Word aus. Jeder Modul-Button und der globale Voice-Core starten Push-to-talk.

+
MICREADY
WHISPERCHECK
PIPERCHECK
+
+ +
+
10 // SYSTEM TELEMETRY

Activity Stream

DEBUG TRACE
+
+
+
+ + +
VOICE CHANNEL GLOBALLISTENING

Sprich jetzt …

+
+ +
NEW ENTITY

Eintrag erstellen

+ + + + diff --git a/internal/ui/static/styles.css b/internal/ui/static/styles.css new file mode 100644 index 0000000..54ed2f4 --- /dev/null +++ b/internal/ui/static/styles.css @@ -0,0 +1,354 @@ +:root{--bg:#010508;--bg2:#041018;--panel:rgba(4,14,21,.82);--panel2:rgba(7,24,33,.9);--line:rgba(89,229,255,.16);--line2:rgba(89,229,255,.36);--cyan:#63ecff;--cyan2:#13b9e8;--blue:#4b7dff;--violet:#a574ff;--green:#69ffc1;--amber:#ffd166;--red:#ff617d;--text:#e9fbff;--muted:#7198a4;--shadow:0 24px 80px rgba(0,0,0,.48),inset 0 0 60px rgba(55,190,230,.025)} +*{box-sizing:border-box}html{background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{margin:0;min-height:100vh;background:radial-gradient(circle at 50% -15%,rgba(8,121,169,.2),transparent 38%),radial-gradient(circle at 100% 40%,rgba(63,58,190,.07),transparent 25%),#010508;overflow-x:hidden}button,input,textarea,select{font:inherit;color:inherit}.noise{position:fixed;inset:0;pointer-events:none;opacity:.055;z-index:80;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 140 140' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E")}.grid-plane{position:fixed;inset:0;pointer-events:none;background-image:linear-gradient(rgba(99,236,255,.025) 1px,transparent 1px),linear-gradient(90deg,rgba(99,236,255,.025) 1px,transparent 1px);background-size:42px 42px;mask-image:linear-gradient(to bottom,rgba(0,0,0,.9),transparent 96%)}.scan-sweep{position:fixed;left:0;right:0;height:1px;top:-2px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);opacity:.13;box-shadow:0 0 30px var(--cyan);animation:sweep 12s linear infinite;pointer-events:none;z-index:1}@keyframes sweep{to{transform:translateY(100vh)}} +.shell{width:min(1880px,calc(100% - 30px));margin:auto;position:relative;z-index:2}.hud-top{min-height:88px;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;border-bottom:1px solid var(--line);position:relative}.hud-top:after{content:"";position:absolute;bottom:-1px;left:0;width:230px;height:1px;background:var(--cyan);box-shadow:0 0 14px var(--cyan)}.identity{display:flex;align-items:center;gap:13px}.sigil{width:44px;height:44px;display:grid;place-items:center;border:1px solid var(--line2);clip-path:polygon(18% 0,82% 0,100% 18%,100% 82%,82% 100%,18% 100%,0 82%,0 18%);color:var(--cyan);font-weight:900;font-size:20px;text-shadow:0 0 14px var(--cyan);background:rgba(99,236,255,.03)}.micro-label{display:block;color:#5e8996;font-size:9px;letter-spacing:.22em;font-weight:800;text-transform:uppercase}.identity h1{font-size:17px;letter-spacing:.14em;margin:3px 0 0}.identity h1 b{color:var(--cyan);font-weight:500}.system-strip{display:flex;gap:6px}.status-chip{display:grid;grid-template-columns:auto auto;grid-template-rows:auto auto;column-gap:8px;align-items:center;padding:7px 11px;border:1px solid var(--line);background:rgba(4,18,26,.68);min-width:84px}.status-chip i{grid-row:1/3;width:6px;height:6px;border-radius:50%;background:var(--amber);box-shadow:0 0 10px currentColor}.status-chip span{font-size:7px;letter-spacing:.18em;color:var(--muted)}.status-chip strong{font-size:9px;color:#b8dce5;letter-spacing:.07em}.status-chip.online i{background:var(--green)}.status-chip.offline i{background:var(--red)}.clock{text-align:right}.clock strong{display:block;font-weight:500;font-variant-numeric:tabular-nums;letter-spacing:.14em;color:#c8f3ff}.clock span{font-size:9px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase} +.panel{position:relative;border:1px solid var(--line);background:linear-gradient(135deg,rgba(99,236,255,.025),transparent 30%),var(--panel);box-shadow:var(--shadow);padding:16px;overflow:hidden}.panel:before{content:"";position:absolute;top:-1px;left:-1px;width:54px;height:1px;background:var(--cyan);box-shadow:0 0 10px var(--cyan)}.panel:after{content:"";position:absolute;right:-1px;bottom:-1px;width:26px;height:26px;border-right:1px solid rgba(99,236,255,.32);border-bottom:1px solid rgba(99,236,255,.32)}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:12px;border-bottom:1px solid rgba(99,236,255,.09)}.panel-head.no-border{border:0;padding-bottom:5px}.panel-head h2{font-size:15px;letter-spacing:.05em;margin:3px 0 0;font-weight:650}.head-actions{display:flex;gap:7px;align-items:center;flex-wrap:wrap;justify-content:flex-end}.hint{font-size:9px;color:var(--muted);letter-spacing:.08em}.live-tag{font-size:8px;letter-spacing:.16em;color:var(--green);display:flex;align-items:center;gap:6px}.live-tag i{width:5px;height:5px;border-radius:50%;background:var(--green);box-shadow:0 0 9px var(--green)}.hud-button,.add-button,.module-mic,.mic-button,.execute,.dialog-close{border:1px solid var(--line2);background:rgba(11,45,57,.45);cursor:pointer;transition:.18s}.hud-button:hover,.add-button:hover,.module-mic:hover,.mic-button:hover{border-color:var(--cyan);box-shadow:0 0 18px rgba(99,236,255,.12)}.hud-button,.add-button{font-size:8px;font-weight:800;letter-spacing:.12em;padding:8px 9px;color:#bfeef7}.hud-button.secondary{border-color:rgba(165,116,255,.3);color:#cdb6ff;background:rgba(94,47,147,.13)}.hud-button.wide{width:100%;margin-top:8px}.add-button{color:var(--cyan)}.module-mic,.mic-button{width:31px;height:31px;border-radius:50%;color:var(--cyan);font-size:12px;box-shadow:inset 0 0 15px rgba(99,236,255,.05)} +.command-deck{display:grid;grid-template-columns:220px minmax(0,1fr) 220px;gap:18px;margin-top:12px;min-height:390px}.command-core{position:relative;display:grid;place-items:center;min-height:315px}.radar-ring{position:absolute;border:1px solid rgba(99,236,255,.22);border-radius:50%;animation:spin 16s linear infinite}.r1{width:190px;height:190px;border-style:dashed}.r2{width:145px;height:145px;animation-direction:reverse;animation-duration:10s;border-color:rgba(165,116,255,.2)}.r3{width:105px;height:105px;animation-duration:7s}.command-core.active .radar-ring{border-color:rgba(99,236,255,.62);box-shadow:0 0 16px rgba(99,236,255,.11)}@keyframes spin{to{transform:rotate(360deg)}}.core-orb{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;border:1px solid var(--cyan);box-shadow:0 0 35px rgba(99,236,255,.25),inset 0 0 26px rgba(99,236,255,.15);background:radial-gradient(circle,rgba(99,236,255,.22),rgba(99,236,255,.02) 60%,transparent)}.core-orb span{font-weight:900;color:var(--cyan);font-size:28px;text-shadow:0 0 12px var(--cyan)}.voice-state{position:absolute;bottom:24px;text-align:center}.voice-state small{display:block;font-size:7px;letter-spacing:.19em;color:var(--muted)}.voice-state strong{font-size:10px;letter-spacing:.18em;color:var(--cyan)}.command-main{display:flex;min-width:0;flex-direction:column}.conversation{height:235px;overflow:auto;padding:5px 4px 8px;scrollbar-width:thin;scrollbar-color:var(--line2) transparent}.message{display:flex;gap:9px;margin:10px 0;max-width:92%}.message.user{margin-left:auto;flex-direction:row-reverse}.avatar{width:25px;height:25px;min-width:25px;border:1px solid var(--line2);display:grid;place-items:center;font-size:9px;color:var(--cyan)}.message.user .avatar{color:var(--violet);border-color:rgba(165,116,255,.35)}.message small{font-size:7px;letter-spacing:.15em;color:var(--muted)}.message p{margin:2px 0 0;padding:8px 10px;background:rgba(99,236,255,.035);border-left:1px solid var(--line2);font-size:12px;line-height:1.55;color:#cce7ed;white-space:pre-wrap}.message.user p{border-left:0;border-right:1px solid rgba(165,116,255,.45);background:rgba(165,116,255,.045)}.message.pending p{opacity:.6;animation:blink 1.2s ease-in-out infinite}@keyframes blink{50%{opacity:.3}}.command-bar{display:flex;gap:8px;align-items:flex-end;border-top:1px solid rgba(99,236,255,.1);padding-top:10px}.command-bar textarea{flex:1;resize:none;min-height:38px;max-height:90px;background:rgba(0,8,13,.7);border:1px solid var(--line);padding:10px 12px;outline:0;font-size:12px}.command-bar textarea:focus{border-color:var(--line2);box-shadow:inset 0 0 20px rgba(99,236,255,.03)}.execute{height:38px;padding:0 15px;color:#031015;background:var(--cyan);border-color:var(--cyan);font-size:9px;font-weight:900;letter-spacing:.13em}.execute:hover{box-shadow:0 0 20px rgba(99,236,255,.24)}.source-rail{display:flex;gap:5px;overflow:auto;min-height:24px;padding-top:7px}.source-chip{white-space:nowrap;border:1px solid rgba(99,236,255,.1);padding:4px 6px;color:#668b95;font-size:7px}.source-chip b{color:#8dcbd8}.mission-brief{border-left:1px solid var(--line);padding-left:16px;align-self:stretch;display:flex;flex-direction:column;justify-content:center}.brief-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px;margin:12px 0}.brief-grid div{border:1px solid var(--line);padding:10px;background:rgba(0,8,13,.42)}.brief-grid strong{font-size:23px;font-weight:350;color:var(--cyan);display:block}.brief-grid span{font-size:7px;color:var(--muted);letter-spacing:.12em} +.dashboard-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:12px;padding:12px 0 110px}.calendar-panel{grid-column:span 8}.tasks-panel{grid-column:span 4}.kanban-panel{grid-column:1/-1}.grocery-panel{grid-column:span 4}.meals-panel{grid-column:span 4}.recipes-panel{grid-column:span 4}.reminders-panel{grid-column:span 4}.knowledge-panel{grid-column:span 5}.voice-panel{grid-column:span 3}.telemetry-panel{grid-column:1/-1}.full-span{grid-column:1/-1} +.week-rail{display:grid;grid-template-columns:repeat(7,1fr);gap:5px;margin:12px 0}.day-cell{padding:9px 5px;border:1px solid rgba(99,236,255,.09);background:rgba(0,8,13,.35);text-align:center;position:relative;min-height:62px}.day-cell.today{border-color:var(--line2);background:rgba(99,236,255,.045)}.day-cell span{display:block;font-size:7px;color:var(--muted);letter-spacing:.13em}.day-cell strong{font-size:18px;font-weight:350}.day-cell b{display:inline-block;margin-top:4px;min-width:17px;padding:2px 4px;font-size:7px;color:#001014;background:var(--cyan);border-radius:10px}.agenda{display:grid;gap:6px;max-height:250px;overflow:auto}.agenda-item{display:grid;grid-template-columns:62px 1fr auto;gap:10px;align-items:center;padding:8px;border-left:2px solid var(--cyan2);background:rgba(99,236,255,.025)}.agenda-time{font-size:9px;color:var(--cyan);letter-spacing:.08em}.agenda-item strong{font-size:11px}.agenda-item span{display:block;font-size:8px;color:var(--muted);margin-top:2px}.icon-delete{border:0;background:transparent;color:#527681;cursor:pointer;font-size:16px}.icon-delete:hover{color:var(--red)} +.task-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:11px 0}.task-kpis div{border:1px solid var(--line);padding:8px}.task-kpis span{display:block;font-size:6px;color:var(--muted);letter-spacing:.14em}.task-kpis strong{font-size:18px;font-weight:400;color:var(--cyan)}.task-list,.grocery-list,.reminder-list,.document-list{display:grid;gap:5px;max-height:275px;overflow:auto}.task,.grocery-item,.reminder,.doc{display:flex;align-items:flex-start;gap:8px;padding:8px;border:1px solid rgba(99,236,255,.07);background:rgba(0,8,13,.28)}.task.done,.grocery-item.done,.reminder.done{opacity:.42}.task.done strong,.grocery-item.done strong,.reminder.done strong{text-decoration:line-through}.task-check,.grocery-check,.reminder-check{accent-color:var(--cyan);margin-top:3px}.task-main,.grocery-main,.reminder-main{min-width:0;flex:1}.task-main strong,.grocery-main strong,.reminder-main strong,.doc strong{font-size:10px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.task-meta{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}.tag{border:1px solid rgba(99,236,255,.12);font-size:6px;padding:2px 4px;color:#668d98;letter-spacing:.08em}.tag.ai{color:var(--violet);border-color:rgba(165,116,255,.22)}.tag.high{color:var(--red);border-color:rgba(255,97,125,.2)}.confidence{font-size:6px;color:#516f77;margin-top:4px;letter-spacing:.08em} +.kanban-board{display:grid;grid-template-columns:repeat(5,minmax(190px,1fr));gap:7px;overflow-x:auto;padding-top:11px}.kanban-col{border:1px solid rgba(99,236,255,.09);background:rgba(0,8,13,.28);min-height:280px;padding:7px}.kanban-col.drag-over{border-color:var(--cyan);box-shadow:inset 0 0 28px rgba(99,236,255,.05)}.kanban-col-head{display:flex;justify-content:space-between;align-items:center;padding:4px 3px 8px;border-bottom:1px solid rgba(99,236,255,.08)}.kanban-col-head strong{font-size:8px;letter-spacing:.14em;color:#9fd7e3}.kanban-col-head span{font-size:8px;color:var(--cyan)}.kanban-cards{display:grid;gap:6px;padding-top:6px}.kanban-card{padding:9px;border:1px solid rgba(99,236,255,.09);border-left:2px solid var(--cyan2);background:rgba(6,21,29,.72);cursor:grab}.kanban-card.manual{border-left-color:var(--violet)}.kanban-card:active{cursor:grabbing}.kanban-card strong{display:block;font-size:9px}.kanban-card p{font-size:7px;color:var(--muted);line-height:1.4;margin:5px 0}.kanban-foot{display:flex;justify-content:space-between;gap:5px;align-items:center}.kanban-foot span{font-size:6px;color:#618690;letter-spacing:.07em}.kanban-foot button{border:0;background:transparent;color:#4f727b;cursor:pointer}.kanban-foot button:hover{color:var(--red)} +.mini-meter{display:grid;grid-template-columns:auto auto 1fr;gap:8px;align-items:center;margin:11px 0}.mini-meter span{font-size:7px;color:var(--muted);letter-spacing:.12em}.mini-meter strong{color:var(--cyan);font-size:16px}.mini-meter i{height:4px;background:rgba(99,236,255,.08);position:relative}.mini-meter b{position:absolute;inset:0 auto 0 0;background:var(--cyan);box-shadow:0 0 8px var(--cyan)}.grocery-main span,.reminder-main span{font-size:7px;color:var(--muted);display:block;margin-top:3px}.meal-timeline{display:grid;grid-template-columns:repeat(7,minmax(105px,1fr));gap:5px;overflow-x:auto;padding-top:11px}.meal-day{border:1px solid rgba(99,236,255,.08);padding:7px;min-height:205px;background:rgba(0,8,13,.28)}.meal-day.today{border-color:var(--line2)}.meal-day header{margin-bottom:7px}.meal-day header span{font-size:6px;color:var(--muted);letter-spacing:.12em}.meal-day header strong{display:block;font-size:13px}.meal-card{padding:7px 6px;margin-bottom:5px;background:rgba(99,236,255,.035);border-left:1px solid var(--cyan2);position:relative}.meal-card span{font-size:6px;color:var(--cyan);text-transform:uppercase;letter-spacing:.1em}.meal-card strong{display:block;font-size:8px;margin-top:2px;padding-right:12px}.meal-card small{font-size:6px;color:var(--muted)}.meal-card button{position:absolute;right:2px;top:3px;border:0;background:transparent;color:#4f727b;cursor:pointer}.recipe-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:6px;max-height:275px;overflow:auto;padding-top:11px}.recipe-card{border:1px solid rgba(99,236,255,.08);padding:9px;background:rgba(0,8,13,.28);position:relative}.recipe-card strong{font-size:10px;display:block;padding-right:16px}.recipe-card p{font-size:7px;color:var(--muted);line-height:1.45;margin:5px 0}.recipe-card footer{font-size:6px;color:#58808a;letter-spacing:.08em}.recipe-card button{position:absolute;right:4px;top:4px;border:0;background:transparent;color:#4f727b;cursor:pointer}.recipe-card button:hover{color:var(--red)} +.dropzone{display:flex;align-items:center;gap:10px;border:1px dashed rgba(99,236,255,.2);padding:10px;margin:11px 0;cursor:pointer;background:rgba(99,236,255,.02)}.dropzone.drag{border-color:var(--cyan);background:rgba(99,236,255,.06)}.hex-upload{width:35px;height:35px;display:grid;place-items:center;border:1px solid var(--line2);color:var(--cyan);clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%)}.dropzone strong{display:block;font-size:8px;letter-spacing:.1em}.dropzone span{font-size:7px;color:var(--muted)}.memory-meter{display:grid;grid-template-columns:auto auto 1fr;gap:8px;align-items:center;margin-bottom:8px}.memory-meter>span{font-size:6px;color:var(--muted);letter-spacing:.12em}.memory-meter>strong{font-size:15px;color:var(--cyan)}.memory-meter>div{height:4px;background:rgba(99,236,255,.08)}.memory-meter i{display:block;height:100%;background:var(--cyan);box-shadow:0 0 8px var(--cyan);width:0}.doc{align-items:center}.doc-icon{width:31px;height:31px;display:grid;place-items:center;border:1px solid rgba(99,236,255,.14);font-size:6px;color:var(--cyan)}.doc>div:nth-child(2){flex:1;min-width:0}.doc span{font-size:6px;color:var(--muted)} +.wake-row{display:flex;align-items:center;justify-content:space-between;margin:18px 0}.wake-word span{font-size:6px;letter-spacing:.13em;color:var(--muted);display:block}.wake-word strong{font-size:24px;letter-spacing:.18em;color:var(--cyan);font-weight:350}.switch input{display:none}.switch span{display:block;width:43px;height:22px;border:1px solid var(--line2);position:relative;cursor:pointer}.switch span:after{content:"";position:absolute;width:14px;height:14px;left:3px;top:3px;background:#42626b;transition:.2s}.switch input:checked+span:after{left:24px;background:var(--green);box-shadow:0 0 12px var(--green)}.voice-panel p{font-size:8px;color:var(--muted);line-height:1.5}.voice-diag{display:grid;grid-template-columns:repeat(3,1fr);gap:5px}.voice-diag div{border:1px solid rgba(99,236,255,.08);padding:7px}.voice-diag span{font-size:6px;color:var(--muted);display:block}.voice-diag b{font-size:8px}.voice-diag .good{color:var(--green)}.voice-diag .bad{color:var(--red)}.activity-list{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;max-height:180px;overflow:auto;padding-top:10px}.activity{display:flex;gap:7px;padding:7px;border:1px solid rgba(99,236,255,.06)}.activity i{width:5px;height:5px;border-radius:50%;background:var(--cyan);margin-top:3px;box-shadow:0 0 7px currentColor}.activity.warning i{background:var(--amber)}.activity.scout i,.activity.kanban i,.activity.automation i{background:var(--violet)}.activity strong{display:block;font-size:7px;line-height:1.4}.activity span{font-size:6px;color:var(--muted)}.empty{padding:16px;text-align:center;font-size:7px;color:#46646d;letter-spacing:.14em;border:1px dashed rgba(99,236,255,.08)} +.global-mic{position:fixed;z-index:60;right:22px;bottom:22px;width:72px;height:72px;border-radius:50%;border:1px solid var(--cyan);background:rgba(2,14,20,.94);color:var(--cyan);cursor:pointer;box-shadow:0 0 35px rgba(99,236,255,.18),inset 0 0 22px rgba(99,236,255,.08)}.global-mic .mic-icon{font-size:20px;display:block}.global-mic small{font-size:6px;letter-spacing:.16em;display:block}.mic-halo{position:absolute;inset:-8px;border:1px solid rgba(99,236,255,.16);border-radius:50%;animation:pulse 2.2s ease-out infinite}@keyframes pulse{50%{transform:scale(1.08);opacity:.35}}.global-mic.recording{border-color:var(--red);color:var(--red);box-shadow:0 0 35px rgba(255,97,125,.28)}.global-mic.recording .mic-halo{border-color:var(--red);animation-duration:.8s}.voice-overlay{position:fixed;z-index:55;inset:0;background:rgba(0,5,8,.78);backdrop-filter:blur(8px);display:grid;place-items:center;opacity:0;pointer-events:none;transition:.2s}.voice-overlay.show{opacity:1;pointer-events:auto}.voice-overlay-inner{width:min(500px,calc(100% - 35px));padding:35px;border:1px solid var(--line2);background:rgba(3,16,23,.95);text-align:center;box-shadow:0 0 70px rgba(99,236,255,.12)}.voice-overlay-inner strong{font-size:24px;letter-spacing:.18em;color:var(--cyan);display:block;margin:8px}.voice-overlay-inner p{color:#a8d1da;font-size:13px}.voice-overlay-inner .micro-label b{color:var(--violet)}.toast{position:fixed;z-index:100;left:50%;bottom:28px;transform:translate(-50%,20px);opacity:0;border:1px solid var(--line2);background:#06141b;padding:10px 14px;color:#c9edf4;font-size:9px;pointer-events:none;transition:.2s;max-width:80vw}.toast.show{opacity:1;transform:translate(-50%,0)} +.entity-dialog{width:min(650px,calc(100% - 28px));border:1px solid var(--line2);background:#031018;color:var(--text);padding:0;box-shadow:0 30px 110px rgba(0,0,0,.75),0 0 40px rgba(99,236,255,.09)}.entity-dialog::backdrop{background:rgba(0,4,7,.82);backdrop-filter:blur(5px)}.entity-dialog form>header{display:flex;justify-content:space-between;align-items:center;padding:17px;border-bottom:1px solid var(--line)}.entity-dialog h2{font-size:16px;margin:3px 0}.dialog-close{width:32px;height:32px;color:var(--muted);font-size:20px}.dialog-fields{display:grid;grid-template-columns:1fr 1fr;gap:11px;padding:17px;max-height:65vh;overflow:auto}.field{display:grid;gap:5px}.field.full{grid-column:1/-1}.field label{font-size:7px;letter-spacing:.13em;color:var(--muted);text-transform:uppercase}.field input,.field textarea,.field select{width:100%;border:1px solid var(--line);background:rgba(0,7,11,.8);padding:9px;outline:0;font-size:11px}.field textarea{min-height:75px;resize:vertical}.field input:focus,.field textarea:focus,.field select:focus{border-color:var(--line2)}.entity-dialog footer{display:flex;justify-content:flex-end;gap:8px;padding:13px 17px;border-top:1px solid var(--line)} +@media(max-width:1200px){.command-deck{grid-template-columns:170px 1fr}.mission-brief{display:none}.calendar-panel{grid-column:span 7}.tasks-panel{grid-column:span 5}.grocery-panel,.meals-panel,.recipes-panel{grid-column:span 6}.reminders-panel,.knowledge-panel{grid-column:span 6}.voice-panel{grid-column:span 12}.activity-list{grid-template-columns:repeat(3,1fr)}} +@media(max-width:850px){.shell{width:min(100% - 18px,1880px)}.hud-top{grid-template-columns:1fr auto;min-height:76px}.system-strip{display:none}.identity h1{font-size:14px}.clock strong{font-size:12px}.command-deck{grid-template-columns:1fr;padding:12px}.command-core{min-height:170px}.r1{width:150px;height:150px}.r2{width:112px;height:112px}.r3{width:80px;height:80px}.core-orb{width:58px;height:58px}.voice-state{bottom:5px}.conversation{height:210px}.dashboard-grid{grid-template-columns:1fr}.calendar-panel,.tasks-panel,.grocery-panel,.meals-panel,.recipes-panel,.reminders-panel,.knowledge-panel,.voice-panel,.full-span{grid-column:1}.kanban-board{grid-template-columns:repeat(5,220px)}.meal-timeline{grid-template-columns:repeat(7,125px)}.activity-list{grid-template-columns:1fr 1fr}.global-mic{width:64px;height:64px;right:14px;bottom:14px}.hint{display:none}} +@media(max-width:560px){.panel{padding:12px}.identity .micro-label{display:none}.sigil{width:37px;height:37px}.identity h1{font-size:12px}.clock span{font-size:7px}.week-rail{grid-template-columns:repeat(7,68px);overflow:auto}.day-cell{min-height:56px}.panel-head{align-items:flex-start}.panel-head h2{font-size:13px}.head-actions{gap:4px}.add-button{font-size:0;width:31px;height:31px;padding:0}.add-button:before{content:"+";font-size:15px}.agenda-item{grid-template-columns:50px 1fr auto}.recipe-grid{grid-template-columns:1fr}.activity-list{grid-template-columns:1fr}.command-bar{display:grid;grid-template-columns:32px 1fr}.command-bar textarea{grid-column:2}.execute{grid-column:1/-1}.dialog-fields{grid-template-columns:1fr}.field.full{grid-column:1}.voice-diag{grid-template-columns:1fr}.mission-brief{display:none}} + +/* Command router polish */ +.message.action p{border-left-color:var(--green);background:rgba(105,255,193,.045);box-shadow:inset 12px 0 24px rgba(105,255,193,.025)} +.message.action small{color:var(--green)} +.message.clarification p{border-left-color:var(--amber);background:rgba(255,209,102,.04)} +.message.clarification small{color:var(--amber)} +.source-chip.committed{border-color:rgba(105,255,193,.26);color:#91d8b9;background:rgba(105,255,193,.035)} +.source-chip.committed b{color:var(--green)} +.execute:disabled,.hud-button:disabled{opacity:.48;cursor:wait;box-shadow:none} +.agenda-item{transition:.16s} +.agenda-item:hover{background:rgba(99,236,255,.055);border-left-color:var(--cyan)} +.task,.reminder,.grocery-item{transition:.16s} +.task:hover,.reminder:hover,.grocery-item:hover{background:rgba(99,236,255,.035)} +@media(min-width:851px){.command-deck{min-height:350px}.command-core{min-height:280px}.conversation{height:210px}.voice-state{bottom:14px}} +.message p strong{color:#eefcff;font-weight:700}.message p code{color:var(--cyan);font-size:.92em;background:rgba(99,236,255,.055);padding:1px 4px;border:1px solid rgba(99,236,255,.08)} +.command-tools{display:flex;align-items:center;gap:9px}.hud-button.ghost{padding:6px 8px;border-color:rgba(99,236,255,.12);background:transparent;color:#668b95}.hud-button.ghost:hover{color:var(--cyan);border-color:var(--line2)} +@media(max-width:560px){.command-tools .hud-button.ghost{display:none}} +.live-tag.off{opacity:.45}.live-tag.off i{background:#60727a;box-shadow:none}.debug-export{border-color:rgba(88,245,255,.22)!important;color:#85eaf7!important}.trace-chip{cursor:help;border-color:rgba(162,112,255,.28)!important;color:#bb9cff!important} +@media(max-width:560px){.command-tools .debug-export{display:inline-flex;font-size:8px;padding:6px}} + +/* Alternate Clean / Apple-inspired theme */ +.theme-switch{display:inline-flex;align-items:center;gap:4px;padding:4px;border:1px solid rgba(99,236,255,.12);background:rgba(255,255,255,.02);border-radius:999px} +.theme-option{border:0;background:transparent;color:#6f97a1;padding:6px 10px;border-radius:999px;font-size:8px;font-weight:800;letter-spacing:.12em;cursor:pointer;transition:.18s} +.theme-option.active{background:rgba(99,236,255,.18);color:#effdff;box-shadow:inset 0 0 0 1px rgba(99,236,255,.18)} + +body[data-theme="apple"]{--bg:#eef1f5;--bg2:#ffffff;--panel:rgba(255,255,255,.78);--panel2:rgba(255,255,255,.9);--line:rgba(15,23,42,.08);--line2:rgba(17,24,39,.14);--cyan:#111827;--cyan2:#4b5563;--blue:#2563eb;--violet:#7c3aed;--green:#059669;--amber:#d97706;--red:#dc2626;--text:#111827;--muted:#6b7280;--shadow:0 20px 50px rgba(15,23,42,.08),0 2px 12px rgba(15,23,42,.04)} +body[data-theme="apple"]{background:linear-gradient(180deg,#f7f8fa 0%,#eef1f5 100%)} +body[data-theme="apple"] .noise{opacity:.02} +body[data-theme="apple"] .grid-plane,body[data-theme="apple"] .scan-sweep{opacity:0} +body[data-theme="apple"] .hud-top{border-bottom-color:rgba(15,23,42,.08)} +body[data-theme="apple"] .hud-top:after{width:180px;background:rgba(17,24,39,.22);box-shadow:none} +body[data-theme="apple"] .sigil{clip-path:none;border-radius:14px;background:rgba(255,255,255,.7);text-shadow:none;box-shadow:0 8px 20px rgba(15,23,42,.08)} +body[data-theme="apple"] .micro-label{color:#8b93a1} +body[data-theme="apple"] .identity h1{letter-spacing:.03em;font-size:18px} +body[data-theme="apple"] .identity h1 b{color:#6b7280;font-weight:600} +body[data-theme="apple"] .status-chip{background:rgba(255,255,255,.64);border-radius:16px;min-width:92px} +body[data-theme="apple"] .status-chip strong{color:#111827} +body[data-theme="apple"] .panel{border-radius:28px;background:linear-gradient(180deg,rgba(255,255,255,.9),rgba(248,250,252,.74));backdrop-filter:blur(22px);-webkit-backdrop-filter:blur(22px);border-color:rgba(15,23,42,.08)} +body[data-theme="apple"] .panel:before,body[data-theme="apple"] .panel:after{display:none} +body[data-theme="apple"] .panel-head{border-bottom-color:rgba(15,23,42,.06)} +body[data-theme="apple"] .panel-head h2{font-size:18px;font-weight:700;letter-spacing:-.01em} +body[data-theme="apple"] .hint{letter-spacing:0;text-transform:none} +body[data-theme="apple"] .hud-button,body[data-theme="apple"] .add-button,body[data-theme="apple"] .module-mic,body[data-theme="apple"] .mic-button,body[data-theme="apple"] .execute,body[data-theme="apple"] .dialog-close{border-radius:999px;background:rgba(255,255,255,.78);border-color:rgba(17,24,39,.09);box-shadow:0 8px 22px rgba(15,23,42,.06)} +body[data-theme="apple"] .hud-button,body[data-theme="apple"] .add-button{font-size:10px;letter-spacing:.06em;color:#111827;padding:9px 12px} +body[data-theme="apple"] .hud-button.secondary{background:rgba(245,247,250,.95);color:#475569;border-color:rgba(148,163,184,.18)} +body[data-theme="apple"] .hud-button.ghost{background:rgba(255,255,255,.56);color:#6b7280} +body[data-theme="apple"] .hud-button:hover,body[data-theme="apple"] .add-button:hover,body[data-theme="apple"] .module-mic:hover,body[data-theme="apple"] .mic-button:hover{border-color:rgba(17,24,39,.18);box-shadow:0 12px 28px rgba(15,23,42,.1)} +body[data-theme="apple"] .module-mic,body[data-theme="apple"] .mic-button{color:#111827} +body[data-theme="apple"] .execute{color:#fff;background:#111827;border-color:#111827} +body[data-theme="apple"] .command-deck{gap:16px} +body[data-theme="apple"] .command-core{background:radial-gradient(circle at 50% 45%,rgba(255,255,255,.8),rgba(244,247,250,.42) 60%,transparent 68%);border-radius:24px} +body[data-theme="apple"] .radar-ring{border-style:solid;border-color:rgba(148,163,184,.3);animation-duration:20s} +body[data-theme="apple"] .command-core.active .radar-ring{border-color:rgba(17,24,39,.25);box-shadow:none} +body[data-theme="apple"] .core-orb{width:84px;height:84px;border:0;background:radial-gradient(circle at 30% 30%,#fff 0%,#e9eef5 60%,#dbe2ea 100%);box-shadow:0 18px 40px rgba(15,23,42,.12), inset 0 2px 6px rgba(255,255,255,.9)} +body[data-theme="apple"] .core-orb span{color:#111827;text-shadow:none} +body[data-theme="apple"] .voice-state strong{color:#111827} +body[data-theme="apple"] .conversation{padding-top:8px} +body[data-theme="apple"] .message{max-width:94%} +body[data-theme="apple"] .avatar{border-radius:50%;border-color:rgba(17,24,39,.08);background:#fff;color:#111827} +body[data-theme="apple"] .message p{background:rgba(255,255,255,.86);border:1px solid rgba(15,23,42,.06);border-left:0;border-radius:18px;padding:12px 14px;color:#1f2937;box-shadow:0 10px 24px rgba(15,23,42,.05)} +body[data-theme="apple"] .message.user p{background:linear-gradient(180deg,#f6f7fb,#eef2f7);border-right:0} +body[data-theme="apple"] .message.action p{background:linear-gradient(180deg,#f0fdf4,#ecfdf5);border-color:rgba(5,150,105,.12);box-shadow:0 10px 24px rgba(5,150,105,.07)} +body[data-theme="apple"] .message.clarification p{background:linear-gradient(180deg,#fffbeb,#fff7ed);border-color:rgba(217,119,6,.14)} +body[data-theme="apple"] .command-bar{border-top-color:rgba(15,23,42,.06)} +body[data-theme="apple"] .command-bar textarea{border-radius:22px;background:rgba(255,255,255,.88);border-color:rgba(15,23,42,.08);padding:13px 15px;color:#111827;box-shadow:inset 0 1px 0 rgba(255,255,255,.9)} +body[data-theme="apple"] .source-chip{border-radius:999px;border-color:rgba(15,23,42,.08);color:#667085;background:rgba(255,255,255,.7);padding:5px 8px} +body[data-theme="apple"] .source-chip b{color:#111827} +body[data-theme="apple"] .source-chip.committed{background:rgba(240,253,244,.95);border-color:rgba(5,150,105,.16);color:#065f46} +body[data-theme="apple"] .mission-brief{border-left-color:rgba(15,23,42,.08)} +body[data-theme="apple"] .brief-grid div{border-radius:20px;background:rgba(255,255,255,.72);border-color:rgba(15,23,42,.06)} +body[data-theme="apple"] .brief-grid strong{color:#111827;font-size:28px;font-weight:500} +body[data-theme="apple"] .brief-grid span{font-size:9px;letter-spacing:.04em} +body[data-theme="apple"] .day-cell,body[data-theme="apple"] .agenda-item,body[data-theme="apple"] .task,body[data-theme="apple"] .grocery-item,body[data-theme="apple"] .reminder,body[data-theme="apple"] .recipe-card,body[data-theme="apple"] .kanban-card,body[data-theme="apple"] .kanban-col,body[data-theme="apple"] .meal-day,body[data-theme="apple"] .doc,body[data-theme="apple"] .activity,body[data-theme="apple"] .dropzone,body[data-theme="apple"] .voice-diag div{border-radius:20px;background:rgba(255,255,255,.68);border-color:rgba(15,23,42,.06)} +body[data-theme="apple"] .day-cell.today{background:rgba(255,255,255,.92);border-color:rgba(17,24,39,.12)} +body[data-theme="apple"] .day-cell b{background:#111827;color:#fff;border-radius:999px} +body[data-theme="apple"] .agenda-item{grid-template-columns:64px 1fr auto;border-left:0;box-shadow:0 8px 20px rgba(15,23,42,.04)} +body[data-theme="apple"] .agenda-item:hover,body[data-theme="apple"] .task:hover,body[data-theme="apple"] .reminder:hover,body[data-theme="apple"] .grocery-item:hover{background:rgba(255,255,255,.95);transform:translateY(-1px)} +body[data-theme="apple"] .tag{border-radius:999px;background:#f3f4f6;border-color:rgba(17,24,39,.06);color:#475569} +body[data-theme="apple"] .tag.ai{background:#eef2ff;color:#4338ca} +body[data-theme="apple"] .tag.high{background:#fff7ed;color:#c2410c} +body[data-theme="apple"] .kanban-col-head strong,body[data-theme="apple"] .kanban-card strong,body[data-theme="apple"] .recipe-card strong{font-size:12px;color:#111827} +body[data-theme="apple"] .kanban-card.manual{background:linear-gradient(180deg,#ffffff,#f8fafc)} +body[data-theme="apple"] .task-kpis div,body[data-theme="apple"] .meal-slot{border-radius:18px;background:rgba(255,255,255,.84);border-color:rgba(15,23,42,.06)} +body[data-theme="apple"] .mini-meter i,body[data-theme="apple"] .memory-meter>div{border-radius:999px;background:rgba(15,23,42,.06)} +body[data-theme="apple"] .mini-meter b,body[data-theme="apple"] .memory-meter i{background:#111827;box-shadow:none} +body[data-theme="apple"] .hex-upload,.body[data-theme="apple"] .doc-icon{clip-path:none} +body[data-theme="apple"] .hex-upload,body[data-theme="apple"] .doc-icon{border-radius:14px;background:#fff;color:#111827} +body[data-theme="apple"] .wake-word strong{color:#111827;letter-spacing:.06em;font-size:28px} +body[data-theme="apple"] .switch span{border-radius:999px;background:#e5e7eb;border-color:transparent} +body[data-theme="apple"] .switch span:after{border-radius:50%;background:#fff;box-shadow:0 2px 7px rgba(15,23,42,.18)} +body[data-theme="apple"] .switch input:checked+span{background:#111827} +body[data-theme="apple"] .switch input:checked+span:after{background:#fff;box-shadow:0 2px 10px rgba(0,0,0,.2)} +body[data-theme="apple"] .activity i,body[data-theme="apple"] .live-tag i{box-shadow:none} +body[data-theme="apple"] .global-mic{background:rgba(255,255,255,.86);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px);color:#111827;border-color:rgba(17,24,39,.08);box-shadow:0 18px 42px rgba(15,23,42,.12)} +body[data-theme="apple"] .global-mic.recording{border-color:#dc2626;color:#dc2626;box-shadow:0 18px 42px rgba(220,38,38,.15)} +body[data-theme="apple"] .mic-halo{border-color:rgba(17,24,39,.12)} +body[data-theme="apple"] .voice-overlay{background:rgba(241,245,249,.68)} +body[data-theme="apple"] .voice-overlay-inner{border-radius:30px;border-color:rgba(15,23,42,.08);background:rgba(255,255,255,.78);box-shadow:0 30px 80px rgba(15,23,42,.14)} +body[data-theme="apple"] .voice-overlay-inner strong,body[data-theme="apple"] .voice-overlay-inner p{color:#111827} +body[data-theme="apple"] .toast{border-radius:999px;background:rgba(17,24,39,.92);border-color:transparent;color:#fff} +body[data-theme="apple"] .entity-dialog{border-radius:32px;border-color:rgba(15,23,42,.08);background:rgba(255,255,255,.88);color:#111827;backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);box-shadow:0 32px 100px rgba(15,23,42,.18)} +body[data-theme="apple"] .entity-dialog::backdrop{background:rgba(241,245,249,.55)} +body[data-theme="apple"] .field input,body[data-theme="apple"] .field textarea,body[data-theme="apple"] .field select{border-radius:18px;background:#fff;border-color:rgba(15,23,42,.08);color:#111827} +body[data-theme="apple"] .field input:focus,body[data-theme="apple"] .field textarea:focus,body[data-theme="apple"] .field select:focus,body[data-theme="apple"] .command-bar textarea:focus{border-color:rgba(17,24,39,.18);box-shadow:0 0 0 4px rgba(17,24,39,.05)} +body[data-theme="apple"] .empty{border-radius:20px;border-style:solid;background:rgba(255,255,255,.58);color:#94a3b8} +body[data-theme="apple"] .theme-switch{background:rgba(243,244,246,.92);border-color:rgba(15,23,42,.07);box-shadow:inset 0 1px 0 rgba(255,255,255,.9)} +body[data-theme="apple"] .theme-option{color:#6b7280} +body[data-theme="apple"] .theme-option.active{background:#fff;color:#111827;box-shadow:0 4px 12px rgba(15,23,42,.08)} +@media(max-width:560px){body[data-theme="apple"] .theme-switch{order:3;width:100%;justify-content:center}body[data-theme="apple"] .command-tools{flex-wrap:wrap;justify-content:flex-end}} + +/* ========================================================= + CLEAN APP — independent multi-screen information architecture + ========================================================= */ +.clean-sidebar,.clean-mobile-nav,.clean-topbar,.clean-home-overview{display:none} + +body[data-theme="apple"]{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text",Inter,system-ui,sans-serif;background:#f3f4f6;color:#111827} +body[data-theme="apple"] .clean-sidebar{display:flex;position:fixed;z-index:45;left:18px;top:18px;bottom:18px;width:238px;flex-direction:column;padding:18px;border:1px solid rgba(15,23,42,.07);border-radius:28px;background:rgba(255,255,255,.82);backdrop-filter:blur(26px);-webkit-backdrop-filter:blur(26px);box-shadow:0 22px 60px rgba(15,23,42,.08)} +.clean-brand{display:flex;align-items:center;gap:12px;padding:4px 4px 20px}.clean-brand-mark{width:42px;height:42px;border-radius:14px;display:grid;place-items:center;background:#111827;color:#fff;font-weight:800;font-size:17px;box-shadow:0 9px 22px rgba(15,23,42,.17)}.clean-brand strong{display:block;font-size:15px;letter-spacing:-.02em}.clean-brand span{display:block;font-size:11px;color:#8b93a1;margin-top:2px} +.clean-nav{display:grid;gap:5px}.clean-nav-item{border:0;background:transparent;color:#657080;cursor:pointer;display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:15px;text-align:left;font-size:13px;font-weight:600;transition:.18s}.clean-nav-item:hover{background:#f5f6f8;color:#111827}.clean-nav-item.active{background:#111827;color:#fff;box-shadow:0 8px 20px rgba(15,23,42,.13)}.clean-nav-icon{width:22px;height:22px;display:grid;place-items:center;font-size:17px;font-weight:500}.clean-sidebar-foot{margin-top:auto;padding-top:16px;border-top:1px solid rgba(15,23,42,.06)}.clean-side-status{display:flex;align-items:center;gap:10px;padding:7px 6px 13px}.clean-side-status i{width:8px;height:8px;background:#10b981;border-radius:50%;box-shadow:0 0 0 4px rgba(16,185,129,.09)}.clean-side-status span{display:block;color:#9aa1ad;font-size:10px}.clean-side-status strong{display:block;color:#374151;font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.clean-theme-switch{width:100%;display:grid!important;grid-template-columns:1fr 1fr}.clean-theme-switch .theme-option{font-size:9px;padding:8px} + +body[data-theme="apple"] .shell{width:auto;max-width:none;margin:0 24px 0 274px;padding:0 0 100px} +body[data-theme="apple"] .hud-top{display:none} +body[data-theme="apple"] .clean-topbar{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:30px 4px 21px;min-height:112px} +.clean-page-title span{font-size:11px;color:#9aa1ad;font-weight:650}.clean-page-title h1{margin:2px 0 4px;font-size:30px;line-height:1.05;letter-spacing:-.035em;font-weight:750;color:#111827}.clean-page-title p{margin:0;color:#7a8492;font-size:13px}.clean-top-actions{display:flex;align-items:center;gap:9px}.clean-date{font-size:12px;color:#7d8794;margin-right:6px}.clean-round-action{width:38px;height:38px;border-radius:50%;border:1px solid rgba(15,23,42,.08);background:rgba(255,255,255,.85);color:#111827;cursor:pointer;box-shadow:0 7px 18px rgba(15,23,42,.05);transition:.16s}.clean-round-action:hover{transform:translateY(-1px);box-shadow:0 10px 22px rgba(15,23,42,.1)} + +/* Clean home becomes a dashboard, not the HUD grid. */ +body[data-theme="apple"] .command-deck{display:grid;grid-template-columns:1fr;margin:0 0 18px;min-height:0;padding:24px 26px;border-radius:28px;background:linear-gradient(145deg,rgba(255,255,255,.96),rgba(248,250,252,.82));box-shadow:0 18px 46px rgba(15,23,42,.07)} +body[data-theme="apple"] .command-core,body[data-theme="apple"] .mission-brief{display:none} +body[data-theme="apple"] .command-main{width:100%} +body[data-theme="apple"] .command-main .panel-head{padding:0 0 12px} +body[data-theme="apple"] .command-main .panel-head h2{font-size:20px} +body[data-theme="apple"] .command-main .panel-head .micro-label{display:none} +body[data-theme="apple"] #themeSwitch{display:none} +body[data-theme="apple"] .conversation{height:190px;padding:5px 0 8px} +body[data-theme="apple"] .message{max-width:min(78%,760px)} +body[data-theme="apple"] .message p{font-size:13px;line-height:1.55} +body[data-theme="apple"] .command-bar{max-width:100%;padding-top:13px} +body[data-theme="apple"] .command-bar textarea{min-height:46px;font-size:13px} +body[data-theme="apple"] .execute{height:46px;padding:0 20px} +body[data-theme="apple"] .source-rail{min-height:20px} +body[data-theme="apple"]:not([data-view="home"]) .command-deck{display:none} + +body[data-theme="apple"] .clean-home-overview{display:none} +body[data-theme="apple"][data-view="home"] .clean-home-overview{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:14px;margin-bottom:18px} +.clean-welcome-card,.clean-home-card{border:1px solid rgba(15,23,42,.065);border-radius:26px;background:rgba(255,255,255,.82);box-shadow:0 16px 42px rgba(15,23,42,.055)} +.clean-welcome-card{grid-column:span 7;padding:26px;display:grid;grid-template-columns:minmax(0,1.25fr) minmax(300px,.75fr);gap:28px;align-items:center;background:linear-gradient(145deg,#fff 0%,#f9fafb 55%,#f0f4f8 100%)}.clean-eyebrow{font-size:11px;color:#7e8794;font-weight:650}.clean-welcome-card h2{font-size:30px;line-height:1.08;letter-spacing:-.035em;margin:4px 0 9px;color:#111827}.clean-welcome-card p{margin:0;max-width:560px;color:#667085;font-size:13px;line-height:1.55}.clean-quick-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.clean-quick-grid button{display:grid;grid-template-columns:30px 1fr;grid-template-rows:auto auto;align-items:center;column-gap:7px;padding:12px;border-radius:17px;border:1px solid rgba(15,23,42,.06);background:rgba(255,255,255,.8);text-align:left;cursor:pointer;color:#111827;transition:.17s}.clean-quick-grid button:hover{transform:translateY(-1px);background:#fff;box-shadow:0 9px 20px rgba(15,23,42,.07)}.clean-quick-grid button>span{grid-row:1/3;width:30px;height:30px;border-radius:10px;background:#f1f3f6;display:grid;place-items:center;font-size:15px}.clean-quick-grid strong{font-size:11px}.clean-quick-grid small{font-size:9px;color:#8c95a2;margin-top:2px}.clean-home-card{padding:19px 20px}.clean-next-card{grid-column:span 5}.clean-task-card,.clean-meal-card{grid-column:span 6}.clean-home-card header{display:flex;align-items:center;justify-content:space-between;margin-bottom:17px}.clean-home-card header>span{font-size:12px;font-weight:700;color:#374151}.clean-home-card header button{border:0;background:transparent;color:#6b7280;font-size:10px;cursor:pointer;padding:0}.clean-home-card header button:hover{color:#111827}.clean-home-card-body strong{display:block;font-size:17px;letter-spacing:-.02em;color:#111827}.clean-home-card-body p{margin:5px 0 0;font-size:11px;color:#7c8592}.clean-system-card{grid-column:1/-1}.clean-system-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:9px}.clean-system-grid div{padding:12px;border-radius:16px;background:#f5f6f8}.clean-system-grid span{display:block;font-size:9px;color:#8b93a1}.clean-system-grid strong{display:block;margin-top:4px;font-size:11px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +/* Clean views: one job per screen. */ +body[data-theme="apple"] .dashboard-grid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:14px;padding:0 0 45px} +body[data-theme="apple"][data-view="home"] .dashboard-grid{display:none} +body[data-theme="apple"] .dashboard-grid>.panel{display:none!important;margin:0;min-width:0} +body[data-theme="apple"][data-view="calendar"] .calendar-panel,body[data-theme="apple"][data-view="calendar"] .reminders-panel{display:block!important} +body[data-theme="apple"][data-view="tasks"] .tasks-panel{display:block!important} +body[data-theme="apple"][data-view="kanban"] .kanban-panel{display:block!important} +body[data-theme="apple"][data-view="food"] .grocery-panel,body[data-theme="apple"][data-view="food"] .meals-panel,body[data-theme="apple"][data-view="food"] .recipes-panel{display:block!important} +body[data-theme="apple"][data-view="knowledge"] .knowledge-panel,body[data-theme="apple"][data-view="knowledge"] .voice-panel,body[data-theme="apple"][data-view="knowledge"] .telemetry-panel{display:block!important} +body[data-theme="apple"][data-view="calendar"] .calendar-panel{grid-column:span 8}body[data-theme="apple"][data-view="calendar"] .reminders-panel{grid-column:span 4} +body[data-theme="apple"][data-view="tasks"] .tasks-panel{grid-column:1/-1;min-height:560px} +body[data-theme="apple"][data-view="kanban"] .kanban-panel{grid-column:1/-1;min-height:620px} +body[data-theme="apple"][data-view="food"] .meals-panel{grid-column:span 7}body[data-theme="apple"][data-view="food"] .grocery-panel{grid-column:span 5}body[data-theme="apple"][data-view="food"] .recipes-panel{grid-column:1/-1} +body[data-theme="apple"][data-view="knowledge"] .knowledge-panel{grid-column:span 7}body[data-theme="apple"][data-view="knowledge"] .voice-panel{grid-column:span 5}body[data-theme="apple"][data-view="knowledge"] .telemetry-panel{grid-column:1/-1} + +/* Remove HUD language and increase real content contrast in Clean mode. */ +body[data-theme="apple"] .panel{padding:22px 22px 24px;border-radius:26px;box-shadow:0 16px 42px rgba(15,23,42,.055)} +body[data-theme="apple"] .panel>.panel-head .micro-label{display:none} +body[data-theme="apple"] .panel-head{padding-bottom:17px;margin-bottom:2px} +body[data-theme="apple"] .panel-head h2{font-size:22px;letter-spacing:-.025em;color:#111827} +body[data-theme="apple"] .week-rail{gap:8px;margin:18px 0 14px} +body[data-theme="apple"] .day-cell{min-height:82px;padding:14px 7px;border-radius:18px} +body[data-theme="apple"] .day-cell span{color:#7b8491;font-size:9px;letter-spacing:.04em;font-weight:650} +body[data-theme="apple"] .day-cell strong{color:#111827;font-size:22px;font-weight:600} +body[data-theme="apple"] .agenda{display:grid;gap:8px} +body[data-theme="apple"] .agenda-item{padding:13px 15px;min-height:64px;color:#111827}.agenda-time{color:var(--cyan)} +body[data-theme="apple"] .agenda-time{color:#475569!important;font-size:11px;font-weight:650} +body[data-theme="apple"] .agenda-item strong{color:#111827;font-size:13px}body[data-theme="apple"] .agenda-item span{color:#7b8491!important;font-size:10px} +body[data-theme="apple"] .task-kpis{gap:9px;margin:18px 0 12px}body[data-theme="apple"] .task-kpis div{padding:14px 15px}body[data-theme="apple"] .task-kpis span{color:#8b93a1!important;font-size:9px}body[data-theme="apple"] .task-kpis strong{color:#111827!important;font-size:21px} +body[data-theme="apple"] .task-list{max-height:none;overflow:visible}body[data-theme="apple"] .task{padding:13px 14px;border-radius:17px;margin:8px 0}body[data-theme="apple"] .task-main strong{color:#111827;font-size:13px}body[data-theme="apple"] .confidence{color:#8b93a1!important} +body[data-theme="apple"] .kanban-board{grid-template-columns:repeat(5,minmax(210px,1fr));gap:10px;margin-top:16px;overflow:auto;padding-bottom:8px}body[data-theme="apple"] .kanban-col{min-height:500px;padding:11px}body[data-theme="apple"] .kanban-col-head strong{font-size:11px;letter-spacing:.06em}body[data-theme="apple"] .kanban-card{padding:13px;box-shadow:0 6px 16px rgba(15,23,42,.04)}body[data-theme="apple"] .kanban-card p{color:#6b7280!important;font-size:10px;line-height:1.45} +body[data-theme="apple"] .meal-timeline{grid-template-columns:repeat(7,minmax(145px,1fr));gap:8px;overflow:auto;padding-bottom:10px;margin-top:16px}body[data-theme="apple"] .meal-day{min-height:300px;padding:12px}body[data-theme="apple"] .meal-day>header strong{color:#111827!important;font-size:16px}body[data-theme="apple"] .meal-slot{padding:10px;margin-top:9px}body[data-theme="apple"] .meal-slot strong{color:#111827!important;font-size:11px}body[data-theme="apple"] .meal-slot span{color:#6b7280!important} +body[data-theme="apple"] .grocery-list,body[data-theme="apple"] .reminder-list{margin-top:14px}body[data-theme="apple"] .grocery-item,body[data-theme="apple"] .reminder{padding:12px 14px;margin:7px 0}body[data-theme="apple"] .grocery-item strong,body[data-theme="apple"] .reminder strong{color:#111827!important;font-size:12px}body[data-theme="apple"] .grocery-item span,body[data-theme="apple"] .reminder span{color:#7b8491!important} +body[data-theme="apple"] .recipe-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:16px}body[data-theme="apple"] .recipe-card{padding:16px;min-height:120px}body[data-theme="apple"] .recipe-card strong{font-size:13px}body[data-theme="apple"] .recipe-card p{font-size:10px;color:#6b7280!important}body[data-theme="apple"] .recipe-card footer{color:#8b93a1!important;font-size:9px} +body[data-theme="apple"] .dropzone{border-style:solid;min-height:90px;margin:16px 0;padding:16px}body[data-theme="apple"] .dropzone strong{font-size:11px;letter-spacing:.03em;color:#111827}body[data-theme="apple"] .dropzone span{font-size:10px;color:#8b93a1} +body[data-theme="apple"] .document-list{display:grid;gap:7px}body[data-theme="apple"] .doc{padding:10px 12px}body[data-theme="apple"] .activity-list{grid-template-columns:repeat(3,1fr);max-height:none;gap:8px}body[data-theme="apple"] .activity{padding:10px 11px}body[data-theme="apple"] .activity strong{font-size:10px;color:#374151}body[data-theme="apple"] .activity span{font-size:9px;color:#8b93a1} +body[data-theme="apple"] .empty{font-size:10px;letter-spacing:.03em;padding:24px} +body[data-theme="apple"] .global-mic{width:60px;height:60px;right:26px;bottom:26px}body[data-theme="apple"] .global-mic small{font-size:7px} + +@media(max-width:1250px){body[data-theme="apple"] .clean-sidebar{width:205px}body[data-theme="apple"] .shell{margin-left:240px}.clean-welcome-card{grid-column:1/-1}.clean-next-card,.clean-task-card{grid-column:span 6}.clean-meal-card,.clean-system-card{grid-column:1/-1}body[data-theme="apple"][data-view="calendar"] .calendar-panel,body[data-theme="apple"][data-view="calendar"] .reminders-panel{grid-column:1/-1}body[data-theme="apple"][data-view="food"] .meals-panel,body[data-theme="apple"][data-view="food"] .grocery-panel{grid-column:1/-1}} +@media(max-width:850px){body[data-theme="apple"] .clean-sidebar{display:none}body[data-theme="apple"] .clean-mobile-nav{display:grid;position:fixed;z-index:70;left:10px;right:10px;bottom:10px;grid-template-columns:repeat(6,1fr);padding:7px;border:1px solid rgba(15,23,42,.08);border-radius:22px;background:rgba(255,255,255,.9);backdrop-filter:blur(24px);box-shadow:0 15px 50px rgba(15,23,42,.14)}body[data-theme="apple"] .clean-mobile-nav .clean-nav-item{display:grid;place-items:center;gap:2px;padding:6px 2px;border-radius:15px;text-align:center}body[data-theme="apple"] .clean-mobile-nav .clean-nav-item span{font-size:16px;line-height:1}body[data-theme="apple"] .clean-mobile-nav .clean-nav-item small{font-size:8px;font-weight:650}body[data-theme="apple"] .shell{margin:0 12px;padding-bottom:105px}body[data-theme="apple"] .clean-topbar{padding:22px 4px 16px}.clean-page-title h1{font-size:26px}.clean-page-title p{font-size:11px}.clean-date{display:none}.clean-welcome-card{grid-template-columns:1fr;gap:18px}.clean-home-card,.clean-system-card{grid-column:1/-1}.clean-welcome-card h2{font-size:26px}body[data-theme="apple"] .recipe-grid{grid-template-columns:1fr 1fr}body[data-theme="apple"] .global-mic{bottom:88px;right:16px}} +@media(max-width:560px){body[data-theme="apple"] .clean-topbar{align-items:flex-start}.clean-page-title p{max-width:240px}.clean-top-actions{gap:5px}.clean-round-action{width:34px;height:34px}.clean-welcome-card,.clean-home-card{padding:18px;border-radius:22px}.clean-welcome-card h2{font-size:23px}.clean-quick-grid{grid-template-columns:1fr 1fr}.clean-quick-grid button{padding:10px}.clean-system-grid{grid-template-columns:1fr}.clean-system-card{grid-column:1/-1}body[data-theme="apple"] .panel{padding:17px;border-radius:22px}body[data-theme="apple"] .panel-head h2{font-size:20px}body[data-theme="apple"] .week-rail{grid-template-columns:repeat(7,76px);overflow:auto}body[data-theme="apple"] .day-cell{min-height:72px}body[data-theme="apple"] .recipe-grid{grid-template-columns:1fr}body[data-theme="apple"] .clean-mobile-nav{grid-template-columns:repeat(6,minmax(47px,1fr));overflow-x:auto}.clean-mobile-nav .clean-nav-item small{display:none!important}body[data-theme="apple"] .command-tools .debug-export,body[data-theme="apple"] .command-tools .live-tag{display:none}body[data-theme="apple"] .message{max-width:92%}} +.source-chip.plan-chip{border-color:rgba(165,116,255,.28);color:#bfa8ff;background:rgba(165,116,255,.055)} +.source-chip.plan-chip b{color:var(--violet)} +body[data-theme="apple"] .source-chip.plan-chip{background:#f5f3ff;border-color:rgba(124,58,237,.12);color:#6d5aa8} +body[data-theme="apple"] .source-chip.plan-chip b{color:#6d28d9} +.recipe-card .recipe-actions{position:absolute;right:6px;top:5px;display:flex;gap:4px} +.recipe-card .recipe-actions button{position:static;width:24px;height:24px;border:0;border-radius:8px;background:transparent;color:#4f727b;cursor:pointer} +.recipe-card .recipe-actions button:hover{background:rgba(255,255,255,.05);color:var(--cyan)} +body[data-theme="apple"] .recipe-card .recipe-actions button{color:#64748b} +body[data-theme="apple"] .recipe-card .recipe-actions button:hover{background:#f1f5f9;color:#111827} + +/* ========================================================= + CLEAN UI POLISH v7.5 — contrast, readable emphasis, calmer cards + ========================================================= */ +body[data-theme="apple"]{ + --clean-ink:#172033; + --clean-secondary:#667085; + --clean-tertiary:#8a94a3; + --clean-accent:#2563eb; + --clean-accent-hover:#1d4ed8; + --clean-accent-soft:#eff6ff; + --clean-surface:#ffffff; + --clean-surface-soft:#f7f8fa; + --clean-stroke:rgba(15,23,42,.09); +} + +/* Chat copy: never inherit the pale cyan HUD emphasis in Clean mode. */ +body[data-theme="apple"] .message p{ + color:var(--clean-ink)!important; + font-size:14px; + line-height:1.62; + letter-spacing:-.004em; +} +body[data-theme="apple"] .message p strong, +body[data-theme="apple"] .message p b{ + color:var(--clean-accent-hover)!important; + font-weight:700; + text-shadow:none!important; +} +body[data-theme="apple"] .message p em, +body[data-theme="apple"] .message p i{ + color:#475467; +} +body[data-theme="apple"] .message p code{ + color:#344054!important; + background:#f2f4f7!important; + border:1px solid rgba(15,23,42,.07)!important; + border-radius:7px; + padding:2px 6px; + box-shadow:none!important; +} +body[data-theme="apple"] .message small{color:var(--clean-tertiary)!important} +body[data-theme="apple"] .message .avatar{color:#344054!important} +body[data-theme="apple"] .message.user .avatar{color:#6941c6!important} + +/* Slightly narrower prose measure: long agent answers remain easy to scan. */ +body[data-theme="apple"] .conversation{padding-right:8px} +body[data-theme="apple"] .message{max-width:min(88%,980px)} +body[data-theme="apple"] .message:not(.user) p{ + padding:14px 17px; + background:rgba(255,255,255,.94); + border-color:rgba(15,23,42,.075); + box-shadow:0 8px 24px rgba(15,23,42,.045); +} +body[data-theme="apple"] .message.user p{ + color:#27364b!important; + background:#f2f4f7; + border-color:rgba(15,23,42,.07); +} +body[data-theme="apple"] .message.action p{ + color:#14532d!important; + background:#f0fdf4; + border-color:rgba(5,150,105,.14); +} +body[data-theme="apple"] .message.action p strong{color:#047857!important} +body[data-theme="apple"] .message.clarification p{ + color:#713f12!important; + background:#fffbeb; + border-color:rgba(217,119,6,.15); +} +body[data-theme="apple"] .message.clarification p strong{color:#b45309!important} + +/* Controls and status chips: stronger hierarchy on white backgrounds. */ +body[data-theme="apple"] .source-chip{ + color:#667085!important; + background:#f8fafc; + border-color:rgba(15,23,42,.075); +} +body[data-theme="apple"] .source-chip b{color:#344054!important} +body[data-theme="apple"] .source-chip.plan-chip{color:#5b4b8a!important;background:#f5f3ff} +body[data-theme="apple"] .source-chip.plan-chip b{color:#6d28d9!important} +body[data-theme="apple"] .live-tag{color:#667085} +body[data-theme="apple"] .live-tag span{color:#475467} +body[data-theme="apple"] .hint, +body[data-theme="apple"] .confidence, +body[data-theme="apple"] .clean-home-card-body p, +body[data-theme="apple"] .recipe-card footer{color:var(--clean-secondary)!important} + +/* Form/input readability, including placeholders. */ +body[data-theme="apple"] input, +body[data-theme="apple"] textarea, +body[data-theme="apple"] select{color:var(--clean-ink)!important} +body[data-theme="apple"] input::placeholder, +body[data-theme="apple"] textarea::placeholder{color:#8b95a5!important;opacity:1} +body[data-theme="apple"] .command-bar textarea{ + background:#fff; + border-color:rgba(15,23,42,.09); + box-shadow:0 1px 2px rgba(15,23,42,.02),inset 0 1px 0 rgba(255,255,255,.95); +} +body[data-theme="apple"] .command-bar textarea:focus{ + border-color:rgba(37,99,235,.38)!important; + box-shadow:0 0 0 4px rgba(37,99,235,.08)!important; +} + +/* Cards/list content: avoid ultra-light metadata that vanishes on bright displays. */ +body[data-theme="apple"] .day-cell span, +body[data-theme="apple"] .agenda-item span, +body[data-theme="apple"] .grocery-item span, +body[data-theme="apple"] .reminder span, +body[data-theme="apple"] .meal-slot span, +body[data-theme="apple"] .recipe-card p, +body[data-theme="apple"] .kanban-card p, +body[data-theme="apple"] .activity span, +body[data-theme="apple"] .clean-system-grid span, +body[data-theme="apple"] .clean-quick-grid small{ + color:#667085!important; +} +body[data-theme="apple"] .micro-label, +body[data-theme="apple"] .clean-page-title span, +body[data-theme="apple"] .clean-brand span{color:#7b8493!important} +body[data-theme="apple"] .empty{color:#7b8493!important} + +/* Keep interactive text visibly interactive without looking neon. */ +body[data-theme="apple"] button:not(.execute):not(.clean-nav-item):not(.theme-option):focus-visible, +body[data-theme="apple"] input:focus-visible, +body[data-theme="apple"] textarea:focus-visible, +body[data-theme="apple"] select:focus-visible{ + outline:3px solid rgba(37,99,235,.14); + outline-offset:2px; +} +body[data-theme="apple"] .clean-home-card header button, +body[data-theme="apple"] .recipe-card .recipe-actions button{color:#475467!important} +body[data-theme="apple"] .clean-home-card header button:hover, +body[data-theme="apple"] .recipe-card .recipe-actions button:hover{color:#1d4ed8!important} + +@media(max-width:850px){ + body[data-theme="apple"] .message{max-width:94%} + body[data-theme="apple"] .message p{font-size:13.5px;line-height:1.58} +} diff --git a/internal/voice/voice.go b/internal/voice/voice.go new file mode 100644 index 0000000..a25b0f2 --- /dev/null +++ b/internal/voice/voice.go @@ -0,0 +1,120 @@ +package voice + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +type Pipeline struct { + FFmpegBin string + WhisperBin string + WhisperModel string + WhisperLanguage string + PiperBin string + PiperModel string +} + +type Status struct { + STTReady bool `json:"sttReady"` + TTSReady bool `json:"ttsReady"` + FFmpegReady bool `json:"ffmpegReady"` + WhisperReady bool `json:"whisperReady"` + PiperReady bool `json:"piperReady"` + WakeWord string `json:"wakeWord"` +} + +func (p *Pipeline) Status(wakeWord string) Status { + _, fe := exec.LookPath(p.FFmpegBin) + _, we := exec.LookPath(p.WhisperBin) + _, pe := exec.LookPath(p.PiperBin) + wm := fileExists(p.WhisperModel) + pm := fileExists(p.PiperModel) + return Status{STTReady: fe == nil && we == nil && wm, TTSReady: pe == nil && pm, FFmpegReady: fe == nil, WhisperReady: we == nil && wm, PiperReady: pe == nil && pm, WakeWord: wakeWord} +} + +func (p *Pipeline) Transcribe(ctx context.Context, audio []byte, extension string) (string, error) { + if len(audio) == 0 { + return "", fmt.Errorf("leere Audiodatei") + } + if _, err := exec.LookPath(p.FFmpegBin); err != nil { + return "", fmt.Errorf("ffmpeg fehlt: %w", err) + } + if _, err := exec.LookPath(p.WhisperBin); err != nil { + return "", fmt.Errorf("whisper-cli fehlt: %w", err) + } + if !fileExists(p.WhisperModel) { + return "", fmt.Errorf("Whisper-Modell fehlt: %s", p.WhisperModel) + } + if extension == "" { + extension = ".webm" + } + in, err := os.CreateTemp("", "jarvis-audio-*"+extension) + if err != nil { + return "", err + } + inPath := in.Name() + defer os.Remove(inPath) + if _, err := in.Write(audio); err != nil { + in.Close() + return "", err + } + if err := in.Close(); err != nil { + return "", err + } + wav, err := os.CreateTemp("", "jarvis-audio-*.wav") + if err != nil { + return "", err + } + wavPath := wav.Name() + wav.Close() + defer os.Remove(wavPath) + cmd := exec.CommandContext(ctx, p.FFmpegBin, "-hide_banner", "-loglevel", "error", "-y", "-i", inPath, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wavPath) + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out))) + } + cmd = exec.CommandContext(ctx, p.WhisperBin, "-m", p.WhisperModel, "-f", wavPath, "-l", p.WhisperLanguage, "-nt", "-np") + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("whisper: %w: %s", err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +func (p *Pipeline) Speak(ctx context.Context, text string) ([]byte, error) { + text = strings.TrimSpace(text) + if text == "" { + return nil, fmt.Errorf("leerer Text") + } + if _, err := exec.LookPath(p.PiperBin); err != nil { + return nil, fmt.Errorf("piper fehlt: %w", err) + } + if !fileExists(p.PiperModel) { + return nil, fmt.Errorf("Piper-Modell fehlt: %s", p.PiperModel) + } + outFile, err := os.CreateTemp("", "jarvis-tts-*.wav") + if err != nil { + return nil, err + } + outPath := outFile.Name() + outFile.Close() + defer os.Remove(outPath) + cmd := exec.CommandContext(ctx, p.PiperBin, "--model", p.PiperModel, "--output_file", outPath) + cmd.Stdin = bytes.NewBufferString(text) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("piper: %w: %s", err, strings.TrimSpace(string(out))) + } + return os.ReadFile(outPath) +} + +func fileExists(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + st, err := os.Stat(filepath.Clean(path)) + return err == nil && !st.IsDir() +} diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100644 index 0000000..7086686 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -u +ok=1 +check_bin(){ if command -v "$1" >/dev/null 2>&1; then printf '[OK] %s\n' "$1"; else printf '[MISS] %s\n' "$1"; ok=0; fi; } +check_file(){ if [ -f "$1" ]; then printf '[OK] %s\n' "$1"; else printf '[MISS] %s\n' "$1"; ok=0; fi; } +check_bin go +check_bin ollama +check_bin "${VOICE_FFMPEG_BIN:-ffmpeg}" +check_bin "${VOICE_WHISPER_BIN:-whisper-cli}" +check_bin "${VOICE_PIPER_BIN:-piper}" +check_file "${VOICE_WHISPER_MODEL:-./models/ggml-small.bin}" +check_file "${VOICE_PIPER_MODEL:-./models/de_DE-thorsten-medium.onnx}" +printf '\nOllama models:\n' +if command -v ollama >/dev/null 2>&1; then + ollama list 2>/dev/null || printf '[WARN] Ollama daemon nicht erreichbar.\n' + printf '\nJARVIS nutzt OLLAMA_MODEL=%s und OLLAMA_EMBED_MODEL=%s.\n' "${OLLAMA_MODEL:-auto}" "${OLLAMA_EMBED_MODEL:-auto}" + printf 'Bei "auto" werden vorhandene Modelle selbständig erkannt.\n' + printf 'Context: num_ctx=%s · output=%s · planner_ctx=%s · history=%s · RAG=%s\n' "${OLLAMA_NUM_CTX:-16384}" "${OLLAMA_NUM_PREDICT:-1200}" "${OLLAMA_PLANNER_NUM_CTX:-8192}" "${JARVIS_HISTORY_TOKENS:-3200}" "${JARVIS_RAG_TOKENS:-4200}" +fi +printf '\nDebug trace: enabled=%s · dir=%s · max=%sMB · keep=%s\n' "${JARVIS_DEBUG_TRACE:-true}" "${JARVIS_DEBUG_DIR:-./data/debug}" "${JARVIS_DEBUG_MAX_MB:-25}" "${JARVIS_DEBUG_KEEP_FILES:-4}" +if [ "$ok" -eq 1 ]; then printf '\nJARVIS local stack ready.\n'; else printf '\nEinige Voice-Abhängigkeiten fehlen. Text/Home-Core kann trotzdem laufen.\n'; fi +exit 0