init
release-tag / release-image (push) Successful in 3m44s

This commit is contained in:
2026-08-28 11:29:58 +02:00
parent 5cf698e632
commit 112c13f9f0
35 changed files with 11409 additions and 1 deletions
+60
View File
@@ -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
+51
View File
@@ -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 }}
+14
View File
@@ -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"]
+497 -1
View File
@@ -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.
+726
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+372
View File
@@ -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)
}
}
+162
View File
@@ -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())
}
+231
View File
@@ -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})
}
+1717
View File
File diff suppressed because it is too large Load Diff
+158
View File
@@ -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)
}
}
+952
View File
@@ -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))
})
}
+177
View File
@@ -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."
}
+476
View File
@@ -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")
}
}
+1263
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -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:
+80
View File
@@ -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}}}
+1
View File
@@ -0,0 +1 @@
[]
+718
View File
@@ -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"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
module homehub
go 1.26
BIN
View File
Binary file not shown.
+314
View File
@@ -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")
+57
View File
@@ -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))
}
}
+131
View File
@@ -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"))
}
+605
View File
@@ -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")
}
+82
View File
@@ -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)
}
}
+204
View File
@@ -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:])
}
+25
View File
@@ -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)
}
}
+256
View File
@@ -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) }
+6
View File
@@ -0,0 +1,6 @@
package ui
import "embed"
//go:embed static/*
var Files embed.FS
+98
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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,'<strong>$1</strong>').replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\n/g,'<br>');
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=`<i></i> ${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<weekEnd}).length,openTasks=tasks.length,groceryOpen=(state.groceries||[]).filter(g=>!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?`<strong>${esc(ev.title)}</strong><p>${esc(fmtDay(parseDate(ev.start)))} · ${esc(ev.allDay?'Ganztägig':fmtClock(ev.start))}${ev.location?' · '+esc(ev.location):''}</p>`:'<strong>Keine Termine</strong><p>Dein Kalender ist frei.</p>';
const task=tasks[0],taskEl=$('#cleanNextTask');if(taskEl)taskEl.innerHTML=task?`<strong>${esc(task.title)}</strong><p>${task.priority==='high'?'Hohe Priorität':'Offen'}${task.due?' · '+esc(fmtTime(task.due)):''}</p>`:'<strong>Keine offenen Aufgaben</strong><p>Du bist auf dem aktuellen Stand.</p>';
const meal=nextMeal,mealEl=$('#cleanNextMeal');if(mealEl)mealEl.innerHTML=meal?`<strong>${esc(meal.title||meal.recipeTitle||'Geplante Mahlzeit')}</strong><p>${esc(fmtDay(parseDate(meal.date)))} · ${esc(mealName(meal.meal))}${meal.servings?` · ${meal.servings} Portionen`:''}</p>`:'<strong>Noch nichts geplant</strong><p>Plane eine Mahlzeit für heute oder morgen.</p>';
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&&d<until}).length;$('#briefTasks').textContent=(state.tasks||[]).filter(t=>t.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(`<div class="day-cell ${i===0?'today':''}"><span>${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}</span><strong>${d.getDate()}</strong>${count?`<b>${count}</b>`:''}</div>`)}$('#weekRail').innerHTML=days.join('');const visible=events.filter(e=>{const d=parseDate(e.start);return d&&d>=today&&d<end}).slice(0,18);$('#agenda').innerHTML=visible.length?visible.map(e=>`<div class="agenda-item"><div class="agenda-time">${esc(e.allDay?'GANZTÄGIG':fmtClock(e.start))}</div><div><strong>${esc(e.title)}</strong><span>${esc(fmtDay(parseDate(e.start)))}${e.location?' · '+esc(e.location):''} · ${e.aiManaged?'JARVIS':'MANUAL'}</span></div><button class="icon-delete" data-delete-event="${esc(e.id)}">×</button></div>`).join(''):'<div class="empty">KEINE TERMINE IN DEN NÄCHSTEN 7 TAGEN</div>';$$('[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=>`<div class="task ${t.status==='done'?'done':''}"><input class="task-check" type="checkbox" ${t.status==='done'?'checked':''} data-task-check="${esc(t.id)}"><div class="task-main"><strong>${esc(t.title)}</strong><div class="task-meta"><span class="tag ${t.aiManaged?'ai':''}">${taskOrigin(t)}</span><span class="tag ${t.priority==='high'?'high':''}">${esc(t.priority||'normal')}</span>${t.due?`<span class="tag">DUE ${esc(fmtTime(t.due))}</span>`:''}</div>${t.aiManaged?`<div class="confidence">CONFIDENCE ${Math.round((t.confidence||0)*100)}%${t.source?' · '+esc(t.source):''}</div>`:''}</div><button class="icon-delete" data-task-delete="${esc(t.id)}">×</button></div>`).join(''):'<div class="empty">ACTION QUEUE CLEAR</div>';$$('[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 `<div class="kanban-col" data-kanban-col="${key}"><div class="kanban-col-head"><strong>${label}</strong><span>${list.length}</span></div><div class="kanban-cards">${list.map(c=>`<article class="kanban-card ${c.aiManaged?'':'manual'}" draggable="true" data-card-id="${esc(c.id)}"><strong>${esc(c.title)}</strong>${c.notes?`<p>${esc(c.notes)}</p>`:''}<div class="kanban-foot"><span>${c.aiManaged?'AI '+Math.round((c.confidence||0)*100)+'%':'MANUAL'} · ${esc(c.priority||'normal')}${c.due?' · '+esc(fmtTime(c.due)):''}</span><button data-card-delete="${esc(c.id)}">×</button></div></article>`).join('')}</div></div>`}).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=>`<div class="grocery-item ${g.checked?'done':''}"><input class="grocery-check" type="checkbox" ${g.checked?'checked':''} data-grocery-check="${esc(g.id)}"><div class="grocery-main"><strong>${esc([g.quantity,g.unit,g.name].filter(Boolean).join(' '))}</strong><span>${esc(g.category||'Allgemein')} · ${g.aiManaged?'AI/AUTO':'MANUAL'}${g.source?.startsWith('mealplan:auto:')?' · ESSENSPLAN':''}</span></div><button class="icon-delete" data-grocery-delete="${esc(g.id)}">×</button></div>`).join(''):'<div class="empty">EINKAUFSLISTE LEER</div>';$$('[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(`<div class="meal-day ${i===0?'today':''}"><header><span>${d.toLocaleDateString('de-DE',{weekday:'short'}).toUpperCase()}</span><strong>${d.getDate()}.${d.getMonth()+1}.</strong></header>${list.length?list.map(m=>`<div class="meal-card"><span>${mealName(m.meal)}</span><strong>${esc(m.title)}</strong><small>${m.servings||2} PORT. ${m.aiManaged?'· AI':''}</small><button data-meal-delete="${esc(m.id)}">×</button></div>`).join(''):'<div class="empty">OPEN</div>'}</div>`)}$('#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=>`<article class="recipe-card"><strong>${esc(r.title)}</strong><p>${esc(r.description||`${(r.ingredients||[]).length} Zutaten · ${(r.steps||[]).length} Schritte`)}</p><footer>${r.prepMinutes?`${r.prepMinutes} MIN · `:''}${r.servings||2} PORT. ${r.aiManaged?'· AI':''}</footer><div class="recipe-actions"><button data-recipe-edit="${esc(r.id)}" title="Rezept bearbeiten">✎</button><button data-recipe-delete="${esc(r.id)}" title="Rezept löschen">×</button></div></article>`).join(''):'<div class="empty">NO CULINARY MEMORY</div>';$$('[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=>`<div class="reminder ${r.done?'done':''}"><input class="reminder-check" type="checkbox" ${r.done?'checked':''} data-reminder-check="${esc(r.id)}"><div class="reminder-main"><strong>${esc(r.title)}</strong><span>${esc(fmtTime(r.at))} · ${r.aiManaged?'AI/AUTO':'MANUAL'}${r.linkedType==='event'?' · KALENDER':''}</span></div><button class="icon-delete" data-reminder-delete="${esc(r.id)}">×</button></div>`).join(''):'<div class="empty">ALERT GRID CLEAR</div>';$$('[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=>`<div class="doc"><div class="doc-icon">${esc((d.name.split('.').pop()||'DOC').slice(0,4).toUpperCase())}</div><div><strong title="${esc(d.name)}">${esc(d.name)}</strong><span>${d.chunkCount||0} chunks · ${d.indexedAt?'INDEXED':'PENDING'}</span></div><button class="icon-delete" data-doc-delete="${esc(d.id)}">×</button></div>`).join(''):'<div class="empty">KNOWLEDGE VAULT EMPTY</div>';$$('[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=>`<div class="activity ${esc(a.kind)}"><i></i><div><strong>${esc(a.message)}</strong><span>${esc(fmtTime(a.createdAt))} · ${esc(String(a.kind||'event').toUpperCase())}</span></div></div>`).join(''):'<div class="empty">NO TELEMETRY EVENTS</div>'}
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=`<div class="avatar">${role==='user'?'U':'J'}</div><div><small>${label}</small><p></p></div>`;$('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 <span>↗</span>'}setTimeout(()=>setCoreMode(wakeEnabled?'WAKE ARMED':'STANDBY'),1400)}
function renderSources(sources,actions=[],mode='chat',traceId='',toolResults=[],plan=null){const trace=traceId?`<span class="source-chip trace-chip" title="${esc(traceId)}">TRACE ${esc(traceId.slice(-12).toUpperCase())}</span>`:'';const planTitle=plan?.steps?.length?plan.steps.map((s,i)=>`${i+1}. ${s.task||s.tool}`).join('\n'):'';const planChip=plan?.steps?.length?`<span class="source-chip plan-chip" title="${esc(planTitle)}"><b>PLAN</b> · ${plan.steps.length} SCHRITTE</span>`:'';const toolChips=(toolResults||[]).slice(0,8).map(t=>`<span class="source-chip" title="${esc(t.request_id||'')}">${esc(String(t.tool||'TOOL').toUpperCase())}${t.success===false?' · ERROR':''}</span>`).join('');if(actions.length){$('#sourceRail').innerHTML=`<span class="source-chip committed"><b>TOOL COMMIT</b> · ${actions.length} MUTATION${actions.length===1?'':'S'}</span>`+planChip+toolChips+trace;return}if(toolChips||planChip){$('#sourceRail').innerHTML=`<span class="source-chip"><b>TOOL AGENT</b></span>`+planChip+toolChips+trace;return}$('#sourceRail').innerHTML=(sources.length?sources.slice(0,7).map(s=>`<span class="source-chip"><b>${esc(s.name)}</b> · ${Math.round((s.score||0)*100)}%</span>`).join(''):`<span class="source-chip">${mode==='clarification'?'AGENT AWAITS INPUT':'LOCAL TOOL CORE'}</span>`)+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 `<div class="field full"><label>${esc(label)}</label><textarea name="${name}" ${req}></textarea></div>`;if(type==='select')return `<div class="field"><label>${esc(label)}</label><select name="${name}">${opts.map(o=>`<option value="${o}">${esc(o.toUpperCase())}</option>`).join('')}</select></div>`;if(type==='recipe-select')return `<div class="field"><label>${esc(label)}</label><select name="${name}"><option value="">— FREIES GERICHT —</option>${(state.recipes||[]).map(r=>`<option value="${esc(r.id)}">${esc(r.title)}</option>`).join('')}</select></div>`;return `<div class="field"><label>${esc(label)}</label><input name="${name}" type="${type}" ${req}></div>`}
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()<wakeArmedUntil&&text.length>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);
+177
View File
@@ -0,0 +1,177 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#02060a" />
<meta name="color-scheme" content="dark" />
<title>JARVIS // Home Command</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="noise"></div><div class="grid-plane"></div><div class="scan-sweep"></div>
<aside class="clean-sidebar" id="cleanSidebar" aria-label="Clean Navigation">
<div class="clean-brand">
<div class="clean-brand-mark">J</div>
<div><strong>Jarvis Home</strong><span>Local Intelligence</span></div>
</div>
<nav class="clean-nav">
<button class="clean-nav-item active" data-clean-view="home"><span class="clean-nav-icon">⌂</span><span>Home</span></button>
<button class="clean-nav-item" data-clean-view="calendar"><span class="clean-nav-icon">◫</span><span>Kalender</span></button>
<button class="clean-nav-item" data-clean-view="tasks"><span class="clean-nav-icon">✓</span><span>Aufgaben</span></button>
<button class="clean-nav-item" data-clean-view="kanban"><span class="clean-nav-icon">▦</span><span>Kanban</span></button>
<button class="clean-nav-item" data-clean-view="food"><span class="clean-nav-icon">◉</span><span>Essen & Einkauf</span></button>
<button class="clean-nav-item" data-clean-view="knowledge"><span class="clean-nav-icon">◇</span><span>Wissen & System</span></button>
</nav>
<div class="clean-sidebar-foot">
<div class="clean-side-status"><i></i><div><span>Lokaler Core</span><strong id="cleanModelStatus">Online</strong></div></div>
<div class="theme-switch clean-theme-switch" aria-label="Design auswählen">
<button type="button" class="theme-option" data-theme="jarvis">HUD</button>
<button type="button" class="theme-option active" data-theme="apple">CLEAN</button>
</div>
</div>
</aside>
<nav class="clean-mobile-nav" aria-label="Clean Navigation mobil">
<button class="clean-nav-item active" data-clean-view="home"><span>⌂</span><small>Home</small></button>
<button class="clean-nav-item" data-clean-view="calendar"><span>◫</span><small>Kalender</small></button>
<button class="clean-nav-item" data-clean-view="tasks"><span>✓</span><small>Aufgaben</small></button>
<button class="clean-nav-item" data-clean-view="kanban"><span>▦</span><small>Kanban</small></button>
<button class="clean-nav-item" data-clean-view="food"><span>◉</span><small>Essen</small></button>
<button class="clean-nav-item" data-clean-view="knowledge"><span>◇</span><small>System</small></button>
</nav>
<div class="shell">
<header class="clean-topbar">
<div class="clean-page-title"><span id="cleanViewEyebrow">Übersicht</span><h1 id="cleanViewTitle">Home</h1><p id="cleanViewSubtitle">Dein Zuhause, Termine und Aufgaben auf einen Blick.</p></div>
<div class="clean-top-actions">
<span class="clean-date" id="cleanTopDate">—</span>
<button type="button" id="cleanQuickMic" class="clean-round-action" title="Spracheingabe">◉</button>
<button type="button" id="cleanOpenHome" class="clean-round-action" title="Zur Startseite">⌂</button>
</div>
</header>
<header class="hud-top">
<div class="identity"><div class="sigil">J</div><div><span class="micro-label">LOCAL AUTONOMOUS HOME OS</span><h1>JARVIS <b>// HOME COMMAND</b></h1></div></div>
<div class="system-strip">
<div class="status-chip"><i id="coreDot"></i><span>CORE</span><strong id="coreStatus">BOOT</strong></div>
<div class="status-chip"><i id="ragDot"></i><span>RAG</span><strong id="ragStatus">—</strong></div>
<div class="status-chip"><i id="sttDot"></i><span>STT</span><strong id="sttStatus">—</strong></div>
<div class="status-chip"><i id="ttsDot"></i><span>TTS</span><strong id="ttsStatus">—</strong></div>
</div>
<div class="clock"><strong id="clock">--:--:--</strong><span id="date">--</span></div>
</header>
<section class="command-deck panel">
<div class="command-core" id="coreVisual">
<div class="radar-ring r1"></div><div class="radar-ring r2"></div><div class="radar-ring r3"></div>
<div class="core-orb"><span>J</span></div>
<div class="voice-state"><small>NEURAL VOICE CORE</small><strong id="voiceModeLabel">STANDBY</strong></div>
</div>
<div class="command-main">
<div class="panel-head no-border"><div><span class="micro-label">00 // UNIFIED COMMAND CHANNEL</span><h2>Home Intelligence</h2></div><div class="command-tools"><div class="theme-switch" id="themeSwitch" aria-label="Design auswählen"><button type="button" class="theme-option active" data-theme="jarvis">JARVIS</button><button type="button" class="theme-option" data-theme="apple">CLEAN</button></div><button id="debugExportBtn" class="hud-button ghost debug-export" title="Strukturierten JSON-Debug-Trace exportieren">EXPORT DEBUG</button><button id="clearChatBtn" class="hud-button ghost" title="Alten Chatverlauf leeren">CLEAR CHAT</button><div class="live-tag"><i></i><span id="modelTag">LOCAL ONLY</span></div></div></div>
<div class="conversation" id="conversation"><div class="message assistant"><div class="avatar">J</div><div><small>JARVIS</small><p>Home-System online. Ich kann Kalender, Aufgaben, Einkauf, Rezepte, Essensplan, Erinnerungen und Kanban lokal verwalten.</p></div></div></div>
<form id="commandForm" class="command-bar"><button type="button" id="inlineMic" class="mic-button" aria-label="Spracheingabe">◉</button><textarea id="commandInput" rows="1" placeholder="z.B. › Jarvis, trage morgen 15 Uhr Zahnarzt ein …"></textarea><button type="submit" class="execute">EXECUTE <span>↗</span></button></form>
<div id="sourceRail" class="source-rail"></div>
</div>
<div class="mission-brief">
<span class="micro-label">LIVE HOME MATRIX</span>
<div class="brief-grid"><div><strong id="briefEvents">0</strong><span>TERMINE / 7T</span></div><div><strong id="briefTasks">0</strong><span>OFFENE TASKS</span></div><div><strong id="briefGroceries">0</strong><span>EINKAUF</span></div><div><strong id="briefReminders">0</strong><span>REMINDER</span></div></div>
<button id="automationBtn" class="hud-button wide">RUN HOME AUTOMATION</button>
<button id="scoutBtn" class="hud-button wide secondary">SCAN FOR TASKS</button>
</div>
</section>
<section class="clean-home-overview" id="cleanHomeOverview">
<article class="clean-welcome-card">
<div><span class="clean-eyebrow" id="cleanGreetingLabel">Guten Morgen</span><h2 id="cleanGreeting">Alles im Blick.</h2><p id="cleanHomeSummary">Jarvis synchronisiert gerade deinen lokalen Home-State.</p></div>
<div class="clean-quick-grid">
<button data-clean-jump="calendar"><span>◫</span><strong>Kalender</strong><small id="homeCalendarQuick">0 Termine</small></button>
<button data-clean-jump="tasks"><span>✓</span><strong>Aufgaben</strong><small id="homeTasksQuick">0 offen</small></button>
<button data-clean-jump="food"><span>◉</span><strong>Essen</strong><small id="homeFoodQuick">Nichts geplant</small></button>
<button data-clean-jump="kanban"><span>▦</span><strong>Kanban</strong><small id="homeKanbanQuick">0 Karten</small></button>
</div>
</article>
<article class="clean-home-card clean-next-card">
<header><span>Als Nächstes</span><button data-clean-jump="calendar">Alle anzeigen</button></header>
<div id="cleanNextEvent" class="clean-home-card-body"><strong>Keine Termine</strong><p>Dein Kalender ist frei.</p></div>
</article>
<article class="clean-home-card clean-task-card">
<header><span>Heute erledigen</span><button data-clean-jump="tasks">Aufgaben öffnen</button></header>
<div id="cleanNextTask" class="clean-home-card-body"><strong>Keine offenen Aufgaben</strong><p>Du bist auf dem aktuellen Stand.</p></div>
</article>
<article class="clean-home-card clean-meal-card">
<header><span>Essen</span><button data-clean-jump="food">Plan öffnen</button></header>
<div id="cleanNextMeal" class="clean-home-card-body"><strong>Noch nichts geplant</strong><p>Plane eine Mahlzeit für heute oder morgen.</p></div>
</article>
<article class="clean-home-card clean-system-card">
<header><span>Local AI</span><button data-clean-jump="knowledge">System</button></header>
<div class="clean-system-grid"><div><span>Core</span><strong id="cleanCoreQuick">—</strong></div><div><span>RAG</span><strong id="cleanRagQuick">—</strong></div><div><span>Voice</span><strong id="cleanVoiceQuick">—</strong></div></div>
</article>
</section>
<main class="dashboard-grid">
<section class="panel calendar-panel">
<header class="panel-head"><div><span class="micro-label">01 // TEMPORAL MATRIX</span><h2>Kalender · 7 Tage</h2></div><div class="head-actions"><button class="module-mic" data-voice-context="Kalender" title="Kalender per Sprache">◉</button><button class="add-button" data-add="event">+ TERMIN</button></div></header>
<div class="week-rail" id="weekRail"></div><div class="agenda" id="agenda"></div>
</section>
<section class="panel tasks-panel">
<header class="panel-head"><div><span class="micro-label">02 // ACTION QUEUE</span><h2>Aufgaben</h2></div><div class="head-actions"><button class="module-mic" data-voice-context="Aufgaben" title="Aufgaben per Sprache">◉</button><button class="add-button" data-add="task">+ TASK</button></div></header>
<div class="task-kpis"><div><span>OPEN</span><strong id="taskOpen">0</strong></div><div><span>AI FOUND</span><strong id="taskAI">0</strong></div><div><span>HIGH</span><strong id="taskHigh">0</strong></div></div>
<div id="taskList" class="task-list"></div>
</section>
<section class="panel kanban-panel full-span">
<header class="panel-head"><div><span class="micro-label">03 // AUTONOMOUS FLOW ENGINE</span><h2>KI Kanban</h2></div><div class="head-actions"><span class="hint">Drag & Drop · manuelle Karten bleiben geschützt</span><button class="module-mic" data-voice-context="Kanban" title="Kanban per Sprache">◉</button><button id="kanbanAI" class="hud-button">AI ORGANIZE</button><button class="add-button" data-add="kanban">+ CARD</button></div></header>
<div class="kanban-board" id="kanbanBoard"></div>
</section>
<section class="panel grocery-panel">
<header class="panel-head"><div><span class="micro-label">04 // SUPPLY MATRIX</span><h2>Einkaufsliste</h2></div><div class="head-actions"><button id="syncMealGroceriesBtn" class="hud-button ghost" title="Zutaten aus dem Essensplan synchronisieren">↻ AUS PLAN</button><button class="module-mic" data-voice-context="Einkaufsliste" title="Einkauf per Sprache">◉</button><button class="add-button" data-add="grocery">+ ITEM</button></div></header>
<div class="mini-meter"><span>OFFEN</span><strong id="groceryOpen">0</strong><i><b id="groceryBar"></b></i></div><div id="groceryList" class="grocery-list"></div>
</section>
<section class="panel meals-panel">
<header class="panel-head"><div><span class="micro-label">05 // NUTRITION SCHEDULE</span><h2>Essensplanung</h2></div><div class="head-actions"><button class="module-mic" data-voice-context="Essensplanung" title="Essensplan per Sprache">◉</button><button class="add-button" data-add="meal">+ PLAN</button></div></header>
<div id="mealTimeline" class="meal-timeline"></div>
</section>
<section class="panel recipes-panel">
<header class="panel-head"><div><span class="micro-label">06 // CULINARY MEMORY</span><h2>Rezepte</h2></div><div class="head-actions"><button class="module-mic" data-voice-context="Rezepte" title="Rezepte per Sprache">◉</button><button class="add-button" data-add="recipe">+ RECIPE</button></div></header>
<div id="recipeList" class="recipe-grid"></div>
</section>
<section class="panel reminders-panel">
<header class="panel-head"><div><span class="micro-label">07 // ALERT GRID</span><h2>Erinnerungen</h2></div><div class="head-actions"><button class="module-mic" data-voice-context="Erinnerungen" title="Erinnerungen per Sprache">◉</button><button class="add-button" data-add="reminder">+ REMINDER</button></div></header>
<div id="reminderList" class="reminder-list"></div>
</section>
<section class="panel knowledge-panel">
<header class="panel-head"><div><span class="micro-label">08 // KNOWLEDGE VAULT</span><h2>RAG Memory</h2></div><div class="head-actions"><button class="module-mic" data-voice-context="Dokumentwissen" title="Dokumente per Sprache">◉</button><button id="reindexBtn" class="hud-button">REINDEX</button></div></header>
<label class="dropzone" id="dropzone"><input type="file" id="fileInput" hidden /><div class="hex-upload">+</div><div><strong>INGEST DOCUMENT</strong><span>drop file / click to select</span></div></label>
<div class="memory-meter"><span>VECTOR CHUNKS</span><strong id="chunkCount">0</strong><div><i id="memoryBar"></i></div></div><div id="documentList" class="document-list"></div>
</section>
<section class="panel voice-panel">
<header class="panel-head"><div><span class="micro-label">09 // VOICE MATRIX</span><h2>Wake Protocol</h2></div><button class="module-mic" data-voice-context="Systemsteuerung" title="System per Sprache">◉</button></header>
<div class="wake-row"><div class="wake-word"><span>CODEWORD</span><strong id="wakeWord">JARVIS</strong></div><label class="switch"><input type="checkbox" id="wakeToggle"><span></span></label></div>
<p id="wakeHelp">Wake-Word aus. Jeder Modul-Button und der globale Voice-Core starten Push-to-talk.</p>
<div class="voice-diag"><div><span>MIC</span><b id="micDiag">READY</b></div><div><span>WHISPER</span><b id="whisperDiag">CHECK</b></div><div><span>PIPER</span><b id="piperDiag">CHECK</b></div></div>
</section>
<section class="panel telemetry-panel full-span">
<header class="panel-head"><div><span class="micro-label">10 // SYSTEM TELEMETRY</span><h2>Activity Stream</h2></div><div class="head-actions"><span id="debugTraceStatus" class="live-tag"><i></i> DEBUG TRACE</span><button id="debugClearBtn" class="hud-button ghost" title="Nur Debug-Trace löschen">CLEAR TRACE</button></div></header><div id="activityList" class="activity-list"></div>
</section>
</main>
</div>
<button id="globalMic" class="global-mic" aria-label="Globaler Sprachkanal" title="Push-to-talk"><span class="mic-halo"></span><span class="mic-icon">◉</span><small>VOICE</small></button>
<div id="voiceOverlay" class="voice-overlay" aria-live="polite"><div class="voice-overlay-inner"><span class="micro-label">VOICE CHANNEL <b id="voiceContextLabel">GLOBAL</b></span><strong id="overlayStatus">LISTENING</strong><p id="overlayTranscript">Sprich jetzt …</p></div></div>
<div id="toast" class="toast"></div>
<dialog id="entityDialog" class="entity-dialog"><form method="dialog" id="entityForm"><header><div><span class="micro-label" id="dialogCode">NEW ENTITY</span><h2 id="dialogTitle">Eintrag erstellen</h2></div><button type="button" class="dialog-close" id="dialogClose">×</button></header><div id="dialogFields" class="dialog-fields"></div><footer><button type="button" class="hud-button secondary" id="dialogCancel">CANCEL</button><button type="submit" class="execute">SAVE <span>↗</span></button></footer></form></dialog>
<script src="/app.js"></script>
</body>
</html>
+354
View File
@@ -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}
}
+120
View File
@@ -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()
}
+22
View File
@@ -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