10 KiB
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 |
PIPER_MODEL |
server/voices/de_DE-thorsten-high.onnx |
ALLOWED_ORIGINS |
(leer — siehe Sicherheit) |
Bilder (Vision)
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 Bild 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.
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 gepinnten Ankerpunkt ins Gedächtnis schreiben |
recall |
Gedächtnis (Ankerpunkte) durchsuchen |
Aktuelle Liste: curl -s http://localhost:8788/api/tools
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
Alle Werkzeuge sind ausschließlich lesend. Es gibt nichts, was schreibt,
löscht, Befehle ausführt oder ins Netz geht — 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.
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) 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.
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.