Zeile über dem Composer mit den Messwerten der letzten Antwort plus Session-Summe. Die Zahlen sind gemessen, nicht geschätzt. Quellen: - Ollama: prompt_eval_count, eval_count, eval_duration aus dem Abschluss-Chunk; TTFT wird beim ersten Content- oder Thinking-Chunk gestoppt - OpenRouter: usage-Block, dafür stream_options.include_usage. Der Block kommt erst nach finish_reason, deshalb wird nur noch abgekürzt, wenn er schon da ist — sonst gingen die Tokenzahlen verloren - Kosten aus promptPrice/completionPrice der Modellliste Werkzeugrunden sind mehrere Modellaufrufe für eine sichtbare Antwort: Erzeugtes wird summiert, der Prompt-Stand ist der der letzten Runde, TTFT zählt nur die erste. Kontext-Füllstand gegen GET /api/ps statt gegen die deklarierte Länge des Modells. Das ist nicht dasselbe: qwen3.5 deklariert 262144, geladen läuft es mit 4096. Gegen die deklarierte Länge stünde der Balken bei 1 %, während vorne längst abgeschnitten wird. onStats darf asynchron sein und wird vor onDone abgewartet — der /api/ps-Lookup schob die Stats-Nachricht sonst hinter das done, und der Client verwarf sie. Während des Streamens zählt die Leiste eingehende Chunks als Näherung (mit ≈ markiert); gemessen liegt das bei Prosa innerhalb weniger Prozent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015AUP4R3rgq4XwVs4bVf7uh |
||
|---|---|---|
| server | ||
| web | ||
| .gitignore | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| setup.sh | ||
agenttwo-tools
Fork von 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-pickaus dem Remoteupstreamübernehmen.
Voice-Chat-Oberfläche für lokale und Cloud-LLMs: lokales Qwen3 über Ollama, optionaler Fallback auf OpenRouter, Spracheingabe via whisper.cpp und deutsche Sprachausgabe via Piper (Stimme: Thorsten).
Aufbau
npm-Workspace mit zwei Paketen:
| Pfad | Inhalt |
|---|---|
server/ |
Fastify + WebSocket-Backend, Ollama-/OpenRouter-Bridge, STT/TTS, SQLite |
web/ |
React 18 + Vite Frontend, Markdown-Rendering, Voice-Recording |
Voraussetzungen
- macOS mit Homebrew
- Node.js 22+ —
server/src/db.tsnutzt das eingebautenode:sqlite, das es unter Node 20 noch nicht gibt. Unter Node 22 erscheint beim Start eineExperimentalWarning; ab Node 24 ist das Modul stabil. - Ollama mit einem Qwen3-Modell (
ollama pull qwen3.5) ffmpegimPATHwhisper-cliimPATH(whisper.cpp) inklusive Modell- Piper als Python-Modul — der Server ruft
python3 -m piperauf, 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 (ollama pull qwen3:8b,
~5 GB) laden und in den Einstellungen unter „Lokales Modell“ wählen bzw.
MODEL=qwen3:8b in die .env schreiben.
Schnellstart (Mac)
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:
git clone https://github.com/Jeuners/agenttwo-tools.git
cd agenttwo-tools
./setup.sh # oder: npm run setup
npm run dev # → http://localhost:5174
Nur prüfen ohne etwas zu ändern: ./setup.sh --check · Ohne Nachfragen:
./setup.sh --yes
Unter macOS:
brew install ffmpeg whisper-cpp
pip3 install piper-tts
Prüfen, ob alles bereitsteht:
ffmpeg -version >/dev/null 2>&1 && echo "ffmpeg OK"
whisper-cli --help >/dev/null 2>&1 && echo "whisper-cli OK"
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:
mkdir -p ~/whisper-models
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:
mkdir -p server/voices
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):
OPENROUTER_API_KEY=sk-or-...
Weitere optionale Variablen mit ihren Defaults:
| 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 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 (Bilder & Dateien)
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 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.
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.
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 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).
Ob das eingestellte Modell Bilder kann, verrät:
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)
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.
| 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 |
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:
| 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 |
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:
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.
Grenzen
Kein Werkzeug führt Befehle aus oder verändert Dateien. Zwei haben trotzdem Außenwirkung und sind deshalb bestätigungspflichtig (siehe oben):
read_webpageverlä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.rememberschreibt 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.
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 (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.
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.
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.
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:
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.
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.
Für externen Zugriff reicht ein Reverse-Proxy nicht — davor gehört eine echte Authentifizierung.
Entwicklung
npm install
npm run dev # Server (:8788) und Vite-Dev-Server (:5174) parallel
Einzeln:
npm run dev:server
npm run dev:web
Typecheck über beide Workspaces:
npm run typecheck
Produktion
npm start # baut das Frontend und startet den Server
Der Server bindet nur an 127.0.0.1. Zum Aussetzen ins Netz siehe
Sicherheit.