feat: Werkzeug-Bestätigung, DNS-Pinning, Statistik-Leiste, Produktionsbetrieb (#1)

Ergebnis eines Security-Reviews des Werkzeug-Pfads plus die daraus
hervorgegangenen Verbesserungen.

Sicherheit:
- runTool wertet requiresConfirmation aus; Rückfrage über den WebSocket mit
  vollständigen Argumenten, Antwort allow/always/deny. Ohne Rückkanal gilt
  abgelehnt. read_webpage und remember sind bestätigungspflichtig
- remember pinnt nicht mehr automatisch, Modell-Anker verfallen normal
- SSRF-Guard mit gepinnter DNS-Auflösung (lookup-Hook statt fetch): geprüft
  wird genau die Adresse, die auch verbunden wird — schließt DNS-Rebinding
- ctx.signal kombiniert Abbruch und Zeitlimit bis in den Netzwerkabruf
- activeAborts als Set, Chat-Rate-Limit je Verbindung, Bucket-Cleanup

Statistik-Leiste über dem Composer: Tokens, tok/s, TTFT, Gesamtzeit und
Kontext-Füllstand, dazu die Session-Summe. Zahlen sind gemessen — bei Ollama
aus dem Abschluss-Chunk, bei OpenRouter aus dem usage-Block. Der Füllstand
rechnet gegen das tatsächlich genutzte Fenster aus /api/ps, nicht gegen die
deklarierte Länge des Modells.

Produktionsbetrieb: der Server liefert web/dist jetzt mit aus, npm start
genügt. /api und /ws behalten Vorrang.

README überarbeitet, Fork-Bezug entfernt, zerbrochene Konfigurationstabelle
repariert, API-Referenz ergänzt.

Nicht umgesetzt: Auth-/Origin-Härtung — der Server läuft bewusst lokal.
This commit is contained in:
Jeuner 2026-08-28 15:04:55 +02:00 committed by GitHub
parent 878acd9933
commit 8a489dfc5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1495 additions and 347 deletions

423
README.md
View file

@ -1,26 +1,34 @@
# 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`. Im
Produktionsbetrieb liefert der Server das gebaute Frontend gleich mit aus.
## Voraussetzungen
- **macOS** mit [Homebrew](https://brew.sh)
@ -28,22 +36,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 +57,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 +79,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 +88,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,206 +97,284 @@ 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 gepinnten Ankerpunkt ins Gedächtnis schreiben |
| `recall` | Gedächtnis (Ankerpunkte) durchsuchen |
| `read_webpage` | Öffentliche Website laden, Hauptinhalt als Markdown |
| 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`
## Gedächtnis (Chat-Memory)
### Rückfrage vor Werkzeugen mit Außenwirkung
Das Gedächtnis hat drei Schichten:
Werkzeuge mit `requiresConfirmation` laufen erst nach Freigabe. Der Server
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:
| 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 |
| 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 |
Das Fenster ist in den Einstellungen per Slider einstellbar (2100 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 (01), 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
Alle Werkzeuge sind **ausschließlich lesend**. Es gibt nichts, was schreibt,
löscht oder Befehle ausführt — entsprechend braucht es noch keine
Rückfrage pro Aufruf. Das Feld `requiresConfirmation` in
`tools/types.ts` ist bereits vorgesehen, damit die Bestätigungspflicht nicht
nachträglich eingezogen werden muss, sobald ein schreibendes Werkzeug dazukommt.
Kein Werkzeug führt Befehle aus oder verändert Dateien. Zwei haben trotzdem
Außenwirkung und sind deshalb bestätigungspflichtig:
Ausnahme Netzwerk: `read_webpage` lädt öffentliche Websites. Der Abruf ist
geguardet — nur http/https, private Adressbereiche werden nach DNS-Auflösung
abgewiesen (SSRF-Schutz, auch über Weiterleitungen), 15 s Zeitlimit, 2 MB
Fetch-Limit, 25 kB Output-Cap. Der Inhalt wird dem Modell als nicht
vertrauenswürdig markiert (Prompt-Injection aus Webseiten).
- **`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 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
Fetch-Limit, 25 kB Output-Cap. Die DNS-Auflösung ist **an die Verbindung
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.
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 (2100 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 (01), 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) und `/api/tts` (30/min),
Format-Whitelist per Magic Bytes vor dem `ffmpeg`-Aufruf, und Fehlerdetails
landen im Server-Log statt in der HTTP-Antwort.
Weitere Maßnahmen: Rate-Limits auf `/api/stt` (10/min), `/api/tts` (30/min),
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.
Für externen Zugriff reicht ein Reverse-Proxy **nicht** — davor gehört eine
Bewusst **nicht** abgesichert: Anfragen ohne `Origin`-Header werden
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
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 auf :8788
```
Der Server bindet nur an `127.0.0.1`. Zum Aussetzen ins Netz siehe
Der Server liefert das gebaute Frontend mit aus — die App läuft dann
vollständig unter `http://localhost:8788`, ohne zweiten Prozess. Fehlt
`web/dist`, stellt er nur die API bereit und sagt das beim Start; das ist der
Normalfall im Entwicklungsbetrieb, wo Vite das Frontend übernimmt.
`/api` und `/ws` behalten Vorrang vor den statischen Dateien. Unbekannte
`/api`-Pfade antworten mit JSON-404, alles andere bekommt `index.html`.
Der Server bindet ausschließlich an `127.0.0.1`; zum Aussetzen ins Netz siehe
[Sicherheit](#sicherheit).