agenttwo-tools/README.md

12 KiB
Raw Blame History

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-pick aus dem Remote upstream ü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.ts nutzt das eingebaute node:sqlite, das es unter Node 20 noch nicht gibt. Unter Node 22 erscheint beim Start eine ExperimentalWarning; ab Node 24 ist das Modul stabil.
  • Ollama mit einem Qwen3-Modell (ollama pull qwen3.5)
  • ffmpeg im PATH
  • whisper-cli im PATH (whisper.cpp) inklusive 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 (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 gepinnten Ankerpunkt ins Gedächtnis schreiben
recall Gedächtnis (Ankerpunkte) durchsuchen
read_webpage Öffentliche Website laden, Hauptinhalt als Markdown

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 (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:

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 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.

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).

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.