From ed3c01303ba2e82c410f9226e460e0164e336665 Mon Sep 17 00:00:00 2001 From: Jeuner <62662523+Jeuners@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:50:29 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20README=20=C3=BCberarbeitet,=20Fork-Bezu?= =?UTF-8?q?g=20entfernt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Fork-Hinweis im Kopf ist raus; das Projekt steht für sich. Statt der Herkunft steht jetzt oben, was es tut und was drin ist. Inhaltliche Korrekturen: - Die Konfigurations-Tabelle war zerbrochen: ein Absatz stand mitten drin, PIPER_MODEL und ALLOWED_ORIGINS hingen danach als Fragment. TOOLS_ROOT fehlte ganz. Tabelle repariert, Spalte "Zweck" ergänzt. - Der Abschnitt "Produktion" behauptete, `npm start` liefere die App aus. Der Server hat kein Static-Serving — er stellt nur die API bereit, web/dist bleibt liegen. Steht jetzt so da, mit dem, was es stattdessen braucht. Struktur: - Endpunkte waren über drei Abschnitte verstreut, jetzt eine API-Tabelle - Anhang-Grenzen als Tabelle statt als Fließtext - Werkzeug- und Sicherheitsabschnitte zusammengezogen, Dopplungen raus - Tabellen-Pipes ausgerichtet, Codeblock-Sprachen ergänzt Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015AUP4R3rgq4XwVs4bVf7uh --- README.md | 456 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 234 insertions(+), 222 deletions(-) diff --git a/README.md b/README.md index 827df80..84b2656 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,33 @@ # agenttwo-tools -> Fork von [agenttwo](https://github.com/Jeuners/agenttwo). Die Basis bleibt dort -> unverändert; hier kommen Vision und Werkzeuge (Tool-Calling) dazu. Läuft auf eigenen Ports -> (Backend 8788, Frontend 5174), damit beide Projekte parallel laufen können. -> Fixes aus der Basis lassen sich per `git cherry-pick` aus dem Remote -> `upstream` übernehmen. +Sprachfähige Chat-Oberfläche für lokale und Cloud-Sprachmodelle. Läuft +vollständig auf dem eigenen Rechner: Modell über [Ollama](https://ollama.com), +Spracheingabe über [whisper.cpp](https://github.com/ggml-org/whisper.cpp), +Sprachausgabe über [Piper](https://github.com/rhasspy/piper). Der Weg nach +außen ist optional — [OpenRouter](https://openrouter.ai) lässt sich pro Chat +zuschalten. -Voice-Chat-Oberfläche für lokale und Cloud-LLMs: lokales Qwen3 über -[Ollama](https://ollama.com), optionaler Fallback auf -[OpenRouter](https://openrouter.ai), Spracheingabe via -[whisper.cpp](https://github.com/ggml-org/whisper.cpp) und deutsche -Sprachausgabe via [Piper](https://github.com/rhasspy/piper) (Stimme: Thorsten). +- **Werkzeuge** — das Modell rechnet, liest Projektdateien, ruft Websites ab + und führt ein eigenes Gedächtnis. Werkzeuge mit Außenwirkung fragen vorher + nach. +- **Anhänge** — Bilder, Textdateien und PDFs, per Button, ⌘V oder Drag & Drop. +- **Gedächtnis** — Append-Only-Log plus destillierte Ankerpunkte, deterministisch + aus dem Log rekonstruierbar. +- **Sprache** — Diktat und freihändiger Dialog, deutsche Stimme, alles lokal. +- **Messwerte** — Tokens, Durchsatz, Latenz und Kontext-Füllstand pro Antwort. ## Aufbau npm-Workspace mit zwei Paketen: -| Pfad | Inhalt | -|-----------|------------------------------------------------------------------------| +| Pfad | Inhalt | +|-----------|-------------------------------------------------------------------------| | `server/` | Fastify + WebSocket-Backend, Ollama-/OpenRouter-Bridge, STT/TTS, SQLite | | `web/` | React 18 + Vite Frontend, Markdown-Rendering, Voice-Recording | +Der Chat läuft über einen WebSocket (`/ws`), alles andere über REST. Chats, +Nachrichten, Anhänge und Gedächtnis liegen in `server/data.sqlite`. + ## Voraussetzungen - **macOS** mit [Homebrew](https://brew.sh) @@ -28,22 +35,19 @@ npm-Workspace mit zwei Paketen: das es unter Node 20 noch nicht gibt. Unter Node 22 erscheint beim Start eine `ExperimentalWarning`; ab Node 24 ist das Modul stabil. - [Ollama](https://ollama.com) mit einem Qwen3-Modell (`ollama pull qwen3.5`) -- `ffmpeg` im `PATH` -- `whisper-cli` im `PATH` (whisper.cpp) inklusive Modell +- `ffmpeg` und `whisper-cli` (whisper.cpp) im `PATH`, inklusive Whisper-Modell - Piper als **Python-Modul** — der Server ruft `python3 -m piper` auf, nicht das gleichnamige Homebrew-Binary -**RAM-Hinweis (Apple Silicon):** Qwen3.5 belegt ~7 GB im Speicher — mit -weniger als 16 GB RAM wird es eng. Dann lieber das kleinere -[`qwen3:8b`](https://ollama.com/library/qwen3) (`ollama pull qwen3:8b`, -~5 GB) laden und in den Einstellungen unter „Lokales Modell“ wählen bzw. -`MODEL=qwen3:8b` in die `.env` schreiben. +**RAM (Apple Silicon):** qwen3.5 belegt ~7 GB — mit weniger als 16 GB RAM wird +es eng. Dann das kleinere [`qwen3:8b`](https://ollama.com/library/qwen3) +(`ollama pull qwen3:8b`, ~5 GB) nehmen und in den Einstellungen unter +„Lokales Modell“ wählen oder `MODEL=qwen3:8b` in die `.env` schreiben. -## Schnellstart (Mac) +## Schnellstart -Ein Befehl von null bis zur lauffähigen App — das Skript prüft alle -Abhängigkeiten und bietet fehlende (Homebrew, Node, ffmpeg, whisper, Piper, -Ollama, Modell, Sprachdateien) zur Installation an: +`setup.sh` prüft alle Abhängigkeiten und bietet fehlende zur Installation an — +Homebrew, Node, ffmpeg, whisper.cpp, Piper, Ollama, Modell und Sprachdateien: ```bash git clone https://github.com/Jeuners/agenttwo-tools.git @@ -52,10 +56,13 @@ cd agenttwo-tools npm run dev # → http://localhost:5174 ``` -Nur prüfen ohne etwas zu ändern: `./setup.sh --check` · Ohne Nachfragen: -`./setup.sh --yes` +| Aufruf | Wirkung | +|----------------------|-------------------------------------------| +| `./setup.sh` | Fragt vor jeder Installation nach | +| `./setup.sh --check` | Prüft nur, ändert nichts | +| `./setup.sh --yes` | Installiert alles Fehlende ohne Rückfrage | -Unter macOS: +### Manuell ```bash brew install ffmpeg whisper-cpp @@ -71,9 +78,8 @@ python3 -m piper --help >/dev/null 2>&1 && echo "piper OK" node -e "require('node:sqlite')" 2>/dev/null && echo "node:sqlite OK" ``` -### Whisper-Modell - -Wird per Default unter `~/whisper-models/ggml-large-v3-turbo.bin` erwartet: +Whisper-Modell (~1,5 GB), per Default unter +`~/whisper-models/ggml-large-v3-turbo.bin` erwartet: ```bash mkdir -p ~/whisper-models @@ -81,10 +87,8 @@ curl -L -o ~/whisper-models/ggml-large-v3-turbo.bin \ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin ``` -### Piper-Stimme - -Die Stimmdatei ist **nicht** im Repo (109 MB, über GitHubs Dateilimit). -Einmalig herunterladen: +Piper-Stimme (109 MB, wegen GitHubs Dateilimit nicht im Repo — die zugehörige +`.onnx.json` liegt bereits dort): ```bash mkdir -p server/voices @@ -92,192 +96,114 @@ curl -L -o server/voices/de_DE-thorsten-high.onnx \ https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/high/de_DE-thorsten-high.onnx ``` -Die zugehörige `de_DE-thorsten-high.onnx.json` liegt bereits im Repo. - ## Konfiguration -`.env` im Projekt-Root (wird vom Server eingelesen, ist gitignored): +`.env` im Projekt-Root, wird vom Server eingelesen und ist gitignored. Alle +Werte sind optional außer dem Schlüssel für den Cloud-Fallback: ```bash OPENROUTER_API_KEY=sk-or-... ``` -Weitere optionale Variablen mit ihren Defaults: +| Variable | Default | Zweck | +|-------------------|--------------------------------------------|------------------------------------------------------| +| `PORT` | `8788` | Port des Backends | +| `OLLAMA_URL` | `http://localhost:11434` | Adresse des Ollama-Servers | +| `MODEL` | `qwen3.5:latest` | Lokales Standardmodell | +| `WHISPER_MODEL` | `~/whisper-models/ggml-large-v3-turbo.bin` | Modelldatei für die Spracherkennung | +| `WHISPER_LANG` | `de` | Sprache der Spracherkennung | +| `PIPER_MODEL` | `server/voices/de_DE-thorsten-high.onnx` | Stimme für die Sprachausgabe | +| `TOOLS_ROOT` | Projektverzeichnis | Wurzel der Datei-Sandbox | +| `ALLOWED_ORIGINS` | (leer) | Zusätzliche Origins, siehe [Sicherheit](#sicherheit) | -| Variable | Default | -|----------------------|--------------------------------------------------| -| `PORT` | `8788` | -| `OLLAMA_URL` | `http://localhost:11434` | -| `MODEL` | `qwen3.5:latest` | -| `WHISPER_MODEL` | `~/whisper-models/ggml-large-v3-turbo.bin` | -| `WHISPER_LANG` | `de` | +`MODEL` ist nur der Startwert: In den Einstellungen lässt sich das lokale +Modell pro Chat über das Dropdown „Lokales Modell“ wechseln — die Liste kommt +live von `GET /api/ollama/models`. -`MODEL` ist nur der Default: In den Einstellungen lässt sich das lokale -Ollama-Modell pro Chat über das Dropdown „Lokales Modell“ wechseln (die Liste -kommt live von `GET /api/ollama/models`). -| `PIPER_MODEL` | `server/voices/de_DE-thorsten-high.onnx` | -| `ALLOWED_ORIGINS` | (leer — siehe Sicherheit) | +## Anhänge -## Anhänge (Bilder & Dateien) +Anhängen geht über den Button 📎, aus der Zwischenablage (⌘V) oder per +Drag & Drop auf die Eingabezeile. Eine Nachricht darf auch nur aus einem +Anhang bestehen. -`qwen3.5` bringt die Fähigkeit `vision` mit, deshalb versteht der Chat Bilder. -Anhängen geht auf drei Wegen: Button 📎 im Composer, Einfügen aus der -Zwischenablage (⌘V) oder Drag & Drop auf die Eingabezeile. Eine Nachricht darf -auch nur aus einem Anhang bestehen. +**Bilder** setzen ein Modell mit der Fähigkeit `vision` voraus — qwen3.5 bringt +sie mit. Sie werden zusammen mit der Nachricht gespeichert und bei Folgefragen +erneut mitgeschickt, sodass Rückfragen zum selben Bild funktionieren. Für +Ollama gehen sie als `images: [base64]` raus, für OpenRouter im OpenAI-Format +als `image_url` mit data-URL. -**Bilder** werden zusammen mit der Nachricht in der SQLite-Datei abgelegt und bei -Folgefragen erneut mitgeschickt, sodass Rückfragen zum selben Bild funktionieren. -Für Ollama gehen sie als `images: [base64]` raus, für OpenRouter im -OpenAI-Format als `image_url` mit data-URL — der MIME-Typ wird dabei aus den -Magic Bytes bestimmt. +**Text- und PDF-Dateien** landen als formatierter Block im Kontext und werden +ebenfalls gespeichert. Unterstützt sind `.txt .json .md .csv .yaml .xml .sql` +und gängige Code-Endungen, dazu `.pdf` — der Text wird serverseitig mit +[pdf-parse](https://www.npmjs.com/package/pdf-parse) extrahiert. Gescannte PDFs +ohne Textebene werden abgewiesen, OCR gibt es nicht. -Grenzen (`server/src/images.ts`): maximal 4 Bilder pro Nachricht, je 6 MB, -nur PNG, JPEG, GIF und WebP. Der Typ wird an den Magic Bytes geprüft, nicht am -angegebenen Dateinamen; der WebSocket hat dafür ein Payload-Limit von 32 MB. +| Grenze | Bilder (`images.ts`) | Dateien (`files.ts`) | +|----------------------|----------------------|--------------------------------| +| Anzahl pro Nachricht | 4 | 4 | +| Größe | 6 MB je Bild | 10 MB je PDF | +| Umfang im Kontext | — | 100 kB extrahierter Text | +| Typprüfung | Magic Bytes | Steuerzeichen erkennen Binäres | -**Text- und PDF-Dateien** (`server/src/files.ts`) landen als formatierter Block -im Kontext und werden ebenfalls gespeichert — Rückfragen zur Datei funktionieren -also auch in der nächsten Nachricht. Unterstützt: `.txt .json .md .csv .yaml -.xml .sql` und gängige Code-Endungen, plus `.pdf` (Text wird serverseitig mit -[pdf-parse](https://www.npmjs.com/package/pdf-parse) extrahiert). Grenzen: -maximal 4 Dateien pro Nachricht, je 100 kB extrahierter Text (bei PDFs -gekürzt, nicht abgelehnt), 10 MB PDF-Größe; Binärdateien werden über -Steuerzeichen erkannt und abgelehnt. Gescannte PDFs ohne Textebene werden -abgewiesen (keine OCR). +Der Typ wird an den Magic Bytes geprüft, nicht am Dateinamen; erlaubt sind +PNG, JPEG, GIF und WebP. Der WebSocket hat ein Payload-Limit von 32 MB. Zu +lange PDF-Texte werden gekürzt, nicht abgelehnt. -Ob das eingestellte Modell Bilder kann, verrät: +Ob das eingestellte Modell Bilder kann: ```bash curl -s http://localhost:11434/api/show -d '{"name":"qwen3.5:latest"}' \ | python3 -c "import json,sys; print(json.load(sys.stdin)['capabilities'])" ``` -## Werkzeuge (Tool-Calling) +## Werkzeuge -`qwen3.5` meldet die Fähigkeit `tools`. Der Server schickt bei jeder Anfrage -eine Werkzeugliste mit; will das Modell eines benutzen, wird es ausgeführt und -das Ergebnis zurückgereicht, bis eine Antwort ohne Werkzeugwunsch entsteht -(maximal `MAX_TOOL_ROUNDS` = 5 Runden, je 15 s Zeitlimit). Abschalten lässt -sich das in den Einstellungen. +Der Server schickt bei jeder Anfrage eine Werkzeugliste mit. Will das Modell +eines benutzen, wird es ausgeführt und das Ergebnis zurückgereicht, bis eine +Antwort ohne Werkzeugwunsch entsteht — maximal `MAX_TOOL_ROUNDS` = 5 Runden, +je 15 s Zeitlimit. Abschalten lässt sich das in den Einstellungen. -| Werkzeug | Zweck | -|---|---| -| `get_time` | Datum, Uhrzeit, Wochentag für eine IANA-Zeitzone | -| `calculate` | Arithmetik mit eigenem Parser | -| `read_file` | Textdatei unterhalb des Projektverzeichnisses lesen | -| `list_files` | Verzeichnis auflisten | -| `remember` | Wichtigen Punkt als Ankerpunkt ins Gedächtnis schreiben — **Rückfrage** | -| `recall` | Gedächtnis (Ankerpunkte) durchsuchen | -| `read_webpage` | Öffentliche Website laden, Hauptinhalt als Markdown — **Rückfrage** | +| Werkzeug | Zweck | +|----------------|-------------------------------------------------------------| +| `get_time` | Datum, Uhrzeit, Wochentag für eine IANA-Zeitzone | +| `calculate` | Arithmetik mit eigenem Parser | +| `read_file` | Textdatei unterhalb der Sandbox-Wurzel lesen | +| `list_files` | Verzeichnis auflisten | +| `recall` | Gedächtnis nach Ankerpunkten durchsuchen | +| `remember` | Ankerpunkt ins Gedächtnis schreiben — **mit Rückfrage** | +| `read_webpage` | Website laden, Hauptinhalt als Markdown — **mit Rückfrage** | Aktuelle Liste: `curl -s http://localhost:8788/api/tools` ### Rückfrage vor Werkzeugen mit Außenwirkung Werkzeuge mit `requiresConfirmation` laufen erst nach Freigabe. Der Server -schickt eine Rückfrage über den WebSocket, die Oberfläche zeigt Werkzeugname -und die **vollständigen** Argumente, und erst die Antwort löst die Ausführung -aus: +fragt über den WebSocket an, die Oberfläche zeigt Werkzeugname und die +**vollständigen** Argumente, und erst die Antwort löst die Ausführung aus: -| Antwort | Wirkung | -|---|---| -| Ablehnen | Werkzeug läuft nicht; das Modell bekommt "vom Nutzer abgelehnt" als Ergebnis und macht ohne weiter | -| Einmal zulassen | Nur dieser eine Aufruf | -| Immer zulassen | Dieses Werkzeug bis zum Neuladen der Seite, pro WebSocket-Verbindung | +| Antwort | Wirkung | +|-----------------|--------------------------------------------------------------------| +| Ablehnen | Werkzeug läuft nicht; das Modell erfährt das und macht ohne weiter | +| Einmal zulassen | Nur dieser eine Aufruf | +| Immer zulassen | Dieses Werkzeug bis zum Neuladen der Seite, je Verbindung | -Ohne Antwort gilt nach 2 Minuten „abgelehnt"; dasselbe bei Verbindungsabbruch -und beim Stoppen der Antwort. Läuft ein Werkzeug ohne Rückkanal (Skript, -Test), wird es abgelehnt statt ungefragt ausgeführt — die Bestätigung soll -sich nicht dadurch umgehen lassen, dass niemand zum Fragen da ist. - -## Statistik-Leiste - -Über dem Eingabefeld läuft eine Zeile mit den Messwerten der letzten Antwort: - -``` -3.891 ↑ · 127 ↓ · 34,5 tok/s · TTFT 0,6 s · 5,2 s · 2 Runden ▬▬▬ 3.891 / 4.096 Σ 12.480 ↑ 2.143 ↓ -``` - -| Wert | Bedeutung | -|---|---| -| `↑` / `↓` | Tokens im Prompt / erzeugte Tokens | -| `tok/s` | Erzeugte Tokens durch reine Generierungszeit (`eval_duration`) | -| `TTFT` | Zeit bis zum ersten sichtbaren Token, Denken zählt mit | -| Gesamtzeit | Wanduhr inklusive Werkzeuglaufzeit | -| Runden | Nur ab 2 — jede Werkzeugrunde ist ein eigener Modellaufruf | -| Balken | Prompt gegen das Kontextfenster | -| `Σ` | Summe über den Chat, seit dem letzten Neuladen der Seite | - -Die Zahlen sind **nicht geschätzt**: bei Ollama kommen sie aus dem -Abschluss-Chunk (`prompt_eval_count`, `eval_count`, `eval_duration`), bei -OpenRouter aus dem `usage`-Block (dafür wird `stream_options.include_usage` -gesetzt) — dort zusätzlich die Kosten aus der Preisliste. Während des -Streamens gibt es diese Werte noch nicht; die Leiste zählt so lange die -eingehenden Chunks und markiert das mit `≈`. - -Der Kontextbalken rechnet gegen das **tatsächlich genutzte** Fenster aus -`GET /api/ps`, nicht gegen die im Modell deklarierte Länge. Das ist nicht -dasselbe: qwen3.5 deklariert 262144, geladen läuft es je nach Ollama-Default -mit 4096. Gegen die deklarierte Länge stünde der Balken bei 1 %, während vorne -längst abgeschnitten wird. Ab 90 % färbt er sich orange. - -## Gedächtnis (Chat-Memory) - -Das Gedächtnis hat drei Schichten: - -| Schicht | Speicher | Zweck | -|---|---|---| -| Arbeitsgedächtnis | Nachrichtenfenster | Einstellbar (Default 10 Schritte), geht ans Modell | -| Episodisch | `memory_events` | Append-Only-Log: nur INSERT, nie UPDATE/DELETE | -| Semantisch | `anchors` | Ankerpunkte, destilliert in der Traumphase | - -Das Fenster ist in den Einstellungen per Slider einstellbar (2–100 Schritte). - -### Traumphase - -Die Traumphase konsolidiert neue Log-Einträge in Ankerpunkte — bevorzugt per -LLM-Extraktion (JSON-Format, Temperatur 0.2), mit einer Regex-Heuristik als -Fallback. Auslöser: - -- automatisch nach 3 Minuten Inaktivität oder wenn 10 Schritte unkonsolidiert sind -- manuell über 🧠 **Gedächtnis** → „Traumphase jetzt" - -Ankerpunkte haben eine Wichtigkeit (0–1), eine Art (`fact`, `decision`, -`preference`, `entity`, `open_question`) und einen Ursprung (`dream`, `model`, -`heuristic`, `test`). Ähnliche Anker werden zusammengeführt (Wort-Ähnlichkeit -≥ 0.5, `hits` steigt). Bei jedem Traumlauf verfallen ungepinnte Anker -(Importance × 0.9); unter 0.15 und ohne Treffer werden sie gelöscht. Gepinnte -(★) bleiben dauerhaft. - -Die wichtigsten Anker werden als System-Kontext eingespielt (Budget ~1200 -Zeichen) — das Modell beantwortet dann Fragen aus Inhalten, die nie im -sichtbaren Chatverlauf standen. - -### Rekonstruktion - -Weil das Log append-only ist, lässt sich der Zustand jederzeit deterministisch -aus Seq 0 neu falten: 🧠 **Gedächtnis** → „Aus Log rekonstruieren" (gepinnte -Anker bleiben erhalten). Die Eval der Heuristik und des Dream-Parsers: - -```bash -npm run memory:eval --workspace server -``` - -Endpunkte: `GET /api/sessions/:id/memory`, `POST /api/sessions/:id/dream`, -`POST /api/sessions/:id/memory/rebuild`, `PATCH|DELETE /api/anchors/:id`. +Ohne Antwort gilt nach 2 Minuten „abgelehnt“ — ebenso bei Verbindungsabbruch +und beim Stoppen der Antwort. Läuft ein Werkzeug ohne Rückkanal, etwa in einem +Skript, wird es abgelehnt statt ungefragt ausgeführt: die Bestätigung soll sich +nicht dadurch umgehen lassen, dass niemand zum Fragen da ist. ### Grenzen Kein Werkzeug führt Befehle aus oder verändert Dateien. Zwei haben trotzdem -Außenwirkung und sind deshalb bestätigungspflichtig (siehe oben): +Außenwirkung und sind deshalb bestätigungspflichtig: - **`read_webpage`** verlässt den Rechner. Die URL selbst ist dabei der kritische Teil: Fremdinhalt kann das Modell anweisen, Gesprächsinhalte in - eine Adresse zu packen und so nach außen zu geben. Deshalb bekommt der - Nutzer die vollständige URL vor dem Abruf zu sehen. -- **`remember`** schreibt dauerhaft und sessionübergreifend ins Gedächtnis. - Modell-Anker werden **nicht** gepinnt — sie unterliegen dem normalen Verfall. - Gepinnt wird nur, was der Nutzer im Gedächtnis-Panel selbst mit ★ markiert. + eine Adresse zu packen und so nach außen zu geben. Deshalb sieht der Nutzer + die vollständige URL vor dem Abruf. +- **`remember`** schreibt dauerhaft und sessionübergreifend. Vom Modell + gesetzte Anker werden **nicht** gepinnt und verfallen normal; gepinnt wird + nur, was der Nutzer im Gedächtnis-Panel selbst mit ★ markiert. Der Abruf in `read_webpage` ist mehrfach geguardet: nur http/https, private Adressbereiche abgewiesen (auch über Weiterleitungen), 15 s Zeitlimit, 2 MB @@ -286,80 +212,166 @@ gepinnt** (`lookup`-Hook in `tools/web.ts`) — geprüft wird genau die Adresse, die dann auch verbunden wird. Ein getrennter Vorab-Check, wie ihn `fetch` erzwingt, ließe DNS-Rebinding zu: öffentlich beim Prüfen, `127.0.0.1` beim Verbinden. Der Inhalt wird dem Modell zusätzlich als nicht vertrauenswürdig -markiert (Prompt-Injection aus Webseiten). - -Ein Nutzer-Abbruch („Stop") beendet auch ein laufendes Werkzeug: `ctx.signal` -kombiniert Abbruch und Zeitlimit und geht bis in den offenen Netzwerkabruf -durch. +markiert. Der Dateizugriff liegt in einer Sandbox: Jeder Pfad wird über `realpath` -aufgelöst (löst auch Symlinks auf) und muss danach unterhalb der Wurzel liegen, -sonst wird abgelehnt. Die Wurzel ist standardmäßig das Projektverzeichnis und -über `TOOLS_ROOT` einstellbar. `.env`, `.git/` und Schlüsseldateien sind auch -innerhalb der Wurzel gesperrt. +aufgelöst — das löst auch Symlinks auf — und muss danach unterhalb der Wurzel +liegen. `.env`, `.git/` und Schlüsseldateien sind auch innerhalb der Wurzel +gesperrt. `calculate` benutzt bewusst **kein** `eval` oder `new Function`: der Ausdruck stammt aus einer Modellantwort, die von Nutzereingaben beeinflusst wird. Der Parser in `tools/calculate.ts` kennt nur Zahlen und Grundrechenarten. -Werkzeugaufrufe werden in der Oberfläche über der Antwort angezeigt. Sie leben -nur im Browser-Zustand und sind nach einem Neuladen weg — im Gegensatz zu -Bildern, die in der Datenbank landen. +Ein Abbruch über „Stop“ beendet auch ein laufendes Werkzeug — `ctx.signal` +kombiniert Abbruch und Zeitlimit und wirkt bis in den offenen Netzwerkabruf. + +Werkzeugaufrufe werden über der Antwort angezeigt. Sie leben nur im +Browser-Zustand und sind nach einem Neuladen weg, anders als Anhänge. + +## Statistik-Leiste + +Über dem Eingabefeld stehen die Messwerte der letzten Antwort: + +```text +3.891 ↑ · 127 ↓ · 34,5 tok/s · TTFT 0,6 s · 5,2 s · 2 Runden ▬▬▬ 3.891 / 4.096 Σ 12.480 ↑ 2.143 ↓ +``` + +| Wert | Bedeutung | +|------------|-------------------------------------------------------------| +| `↑` / `↓` | Tokens im Prompt / erzeugte Tokens | +| `tok/s` | Erzeugte Tokens durch reine Generierungszeit | +| `TTFT` | Zeit bis zum ersten sichtbaren Token, Denken zählt mit | +| Gesamtzeit | Wanduhr inklusive Werkzeuglaufzeit | +| Runden | Erst ab 2 — jede Werkzeugrunde ist ein eigener Modellaufruf | +| Balken | Prompt gegen das Kontextfenster, ab 90 % orange | +| `Σ` | Summe über den Chat, seit dem letzten Neuladen der Seite | + +Die Zahlen sind **gemessen, nicht geschätzt**: bei Ollama stammen sie aus dem +Abschluss-Chunk (`prompt_eval_count`, `eval_count`, `eval_duration`), bei +OpenRouter aus dem `usage`-Block, für den `stream_options.include_usage` +gesetzt wird — dort kommen die Kosten aus der Preisliste dazu. Während des +Streamens gibt es diese Werte noch nicht; solange zählt die Leiste die +eingehenden Chunks und markiert das mit `≈`. + +Der Kontextbalken rechnet gegen das **tatsächlich genutzte** Fenster, das +Ollama unter `GET /api/ps` meldet — nicht gegen die im Modell deklarierte +Länge. Das ist nicht +dasselbe: qwen3.5 deklariert 262144, geladen läuft es je nach Ollama-Default +mit 4096. Gegen die deklarierte Länge stünde der Balken bei 1 %, während vorne +längst abgeschnitten wird. + +## Gedächtnis + +Drei Schichten: + +| Schicht | Speicher | Zweck | +|-------------------|--------------------|------------------------------------------------| +| Arbeitsgedächtnis | Nachrichtenfenster | Default 10 Schritte, geht ans Modell | +| Episodisch | `memory_events` | Append-Only-Log: nur INSERT, nie UPDATE/DELETE | +| Semantisch | `anchors` | Ankerpunkte, destilliert in der Traumphase | + +Das Fenster ist in den Einstellungen per Slider einstellbar (2–100 Schritte). + +### Traumphase + +Die Traumphase konsolidiert neue Log-Einträge zu Ankerpunkten — bevorzugt per +LLM-Extraktion (JSON-Format, Temperatur 0.2), mit einer Regex-Heuristik als +Fallback. Sie läuft automatisch nach 3 Minuten Inaktivität oder wenn 10 +Schritte unkonsolidiert sind, manuell über 🧠 **Gedächtnis** → „Traumphase +jetzt“. + +Ankerpunkte haben eine Wichtigkeit (0–1), eine Art (`fact`, `decision`, +`preference`, `entity`, `open_question`) und einen Ursprung (`dream`, `model`, +`heuristic`, `test`). Ähnliche Anker werden zusammengeführt — Wort-Ähnlichkeit +≥ 0.5, `hits` steigt. Bei jedem Traumlauf verfallen ungepinnte Anker +(Wichtigkeit × 0.9); unter 0.15 und ohne Treffer werden sie gelöscht. Gepinnte +(★) bleiben. + +Die wichtigsten Anker gehen als System-Kontext mit (Budget ~1200 Zeichen) — +das Modell beantwortet dann Fragen aus Inhalten, die nie im sichtbaren +Chatverlauf standen. + +### Rekonstruktion + +Weil das Log append-only ist, lässt sich der Zustand jederzeit deterministisch +aus Seq 0 neu falten: 🧠 **Gedächtnis** → „Aus Log rekonstruieren“, gepinnte +Anker bleiben erhalten. Die Eval der Heuristik und des Dream-Parsers: + +```bash +npm run memory:eval --workspace server +``` ## Sicherheit Der Server hat **keine Authentifizierung** und lauscht deshalb bewusst nur auf -`127.0.0.1`. Das allein genügt aber nicht: Eine beliebige Webseite, die im -Browser geöffnet ist, kann `localhost` per `fetch()` oder WebSocket erreichen. -Deshalb prüfen sowohl die HTTP-Endpunkte als auch der WebSocket-Handshake die -`Origin` gegen eine Allowlist (`server/src/security.ts`) — Standard sind -`localhost`/`127.0.0.1` auf Port 5174 und 8788. - -Läuft das Frontend woanders, die Origin ergänzen: +`127.0.0.1`. Das allein genügt nicht: Eine beliebige im Browser geöffnete +Webseite erreicht `localhost` per `fetch()` oder WebSocket. Deshalb prüfen +sowohl die HTTP-Endpunkte als auch der WebSocket-Handshake die `Origin` gegen +eine Allowlist (`server/src/security.ts`) — Standard sind `localhost` und +`127.0.0.1` auf Port 5174 und 8788. Läuft das Frontend woanders: ```bash ALLOWED_ORIGINS=http://192.168.1.50:5174 ``` Weitere Maßnahmen: Rate-Limits auf `/api/stt` (10/min), `/api/tts` (30/min), -`/dream` (4/min) und auf Chats pro WebSocket-Verbindung (30/min), -Format-Whitelist per Magic Bytes vor dem `ffmpeg`-Aufruf, und Fehlerdetails -landen im Server-Log statt in der HTTP-Antwort. +Traumphase (4/min) und Chats je WebSocket-Verbindung (30/min); +Format-Whitelist per Magic Bytes vor dem `ffmpeg`-Aufruf; Fehlerdetails landen +im Server-Log statt in der HTTP-Antwort. Bewusst **nicht** abgesichert: Anfragen ohne `Origin`-Header werden -durchgelassen (`isOriginAllowed`). Für Browser-Clients trägt die Prüfung, denn -`fetch` und Formular-POSTs senden immer eine Origin — jedes lokale Programm -kommt aber ungefragt an die API. Das ist für den Einzelplatzbetrieb so -gewollt; für alles andere gilt der Absatz unten. +durchgelassen. Für Browser-Clients trägt die Prüfung, weil `fetch` und +Formular-POSTs immer eine Origin senden — jedes lokale Programm kommt aber +ungefragt an die API. Für den Einzelplatzbetrieb ist das so gewollt. -Für externen Zugriff reicht ein Reverse-Proxy **nicht** — davor gehört eine +Für externen Zugriff reicht ein Reverse-Proxy **nicht**. Davor gehört eine echte Authentifizierung. +## API + +| Endpunkt | Methode | Zweck | +|------------------------------------|---------------|----------------------------------| +| `/ws` | WebSocket | Chat-Stream, Werkzeug-Rückfragen | +| `/api/health` | GET | Lebenszeichen | +| `/api/tools` | GET | Registrierte Werkzeuge | +| `/api/model?name=` | GET | Fähigkeiten eines Ollama-Modells | +| `/api/ollama/models` | GET | Lokal verfügbare Modelle | +| `/api/openrouter/models` | GET | Cloud-Modelle mit Preisen | +| `/api/sessions` | GET, POST | Chats auflisten, anlegen | +| `/api/sessions/:id` | DELETE | Chat samt Gedächtnis löschen | +| `/api/sessions/:id/messages` | GET | Verlauf eines Chats | +| `/api/sessions/:id/memory` | GET | Zustand und Ankerpunkte | +| `/api/sessions/:id/dream` | POST | Traumphase auslösen | +| `/api/sessions/:id/memory/rebuild` | POST | Aus dem Log rekonstruieren | +| `/api/anchors/:id` | PATCH, DELETE | Anker pinnen, löschen | +| `/api/stt` | POST | Audio zu Text (Whisper) | +| `/api/tts` | POST | Text zu Audio (Piper) | + ## Entwicklung ```bash npm install npm run dev # Server (:8788) und Vite-Dev-Server (:5174) parallel +npm run dev:server # nur Backend +npm run dev:web # nur Frontend +npm run typecheck # beide Workspaces ``` -Einzeln: - -```bash -npm run dev:server -npm run dev:web -``` - -Typecheck über beide Workspaces: - -```bash -npm run typecheck -``` +Der Vite-Dev-Server leitet `/api` und `/ws` an das Backend weiter, deshalb +läuft die App unter `http://localhost:5174` aus einer Herkunft. ## Produktion ```bash -npm start # baut das Frontend und startet den Server +npm start # baut web/dist und startet den Server ``` -Der Server bindet nur an `127.0.0.1`. Zum Aussetzen ins Netz siehe +**Hinweis:** Der Server liefert das gebaute Frontend derzeit *nicht* aus — er +stellt nur die API auf `:8788` bereit, `web/dist` bleibt liegen. Für einen +Produktivbetrieb braucht es einen statischen Server davor, der `web/dist` +ausliefert und `/api` sowie `/ws` an `127.0.0.1:8788` weiterreicht. Für den +Alltagsbetrieb auf dem eigenen Rechner ist `npm run dev` der vorgesehene Weg. + +Der Server bindet ausschließlich an `127.0.0.1`; zum Aussetzen ins Netz siehe [Sicherheit](#sicherheit).