Zwei Stellen, an denen die Oberfläche tot wirkte. Mic und Anhang waren grau: 🎙 und 📎 sind in den Emoji-Fonts fest eingefärbt, `color` greift bei ihnen nicht. Damit liefen auch die vorhandenen Zustandsregeln ins Leere — .btn-mic.recording (rot) und .transcribing (orange) färbten nur den Rahmen, nie das Icon. Jetzt SVG in currentColor, dazu ein akzentfarbener Rahmen, damit Mic, Anhang und "Senden" als eine Gruppe lesbar sind. Der Platzhalter vor dem ersten Token war ein statisches ▍ und sah nach Stillstand aus, obwohl die Antwort lief — bei kaltem Modell dauert das mehrere Sekunden. Blinkt jetzt über das vorhandene blink-Keyframe. Eigene Klasse .stream-caret, weil .caret schon das Aufklapp-Dreieck des Denkprozesses ist und sonst mitgeblinkt hätte. 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
Sprachfähige Chat-Oberfläche für lokale und Cloud-Sprachmodelle. Läuft vollständig auf dem eigenen Rechner: Modell über Ollama, Spracheingabe über whisper.cpp, Sprachausgabe über Piper. Der Weg nach außen ist optional — OpenRouter lässt sich pro Chat zuschalten.
- 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 |
|---|---|
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
- 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) ffmpegundwhisper-cli(whisper.cpp) imPATH, inklusive Whisper-Modell- Piper als Python-Modul — der Server ruft
python3 -m piperauf, nicht das gleichnamige Homebrew-Binary
RAM (Apple Silicon): qwen3.5 belegt ~7 GB — mit weniger als 16 GB RAM wird
es eng. Dann das kleinere qwen3:8b
(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
setup.sh prüft alle Abhängigkeiten und bietet fehlende zur Installation an —
Homebrew, Node, ffmpeg, whisper.cpp, Piper, Ollama, Modell und Sprachdateien:
git clone https://github.com/Jeuners/agenttwo-tools.git
cd agenttwo-tools
./setup.sh # oder: npm run setup
npm run dev # → http://localhost:5174
| 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 |
Manuell
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 (~1,5 GB), 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 (109 MB, wegen GitHubs Dateilimit nicht im Repo — die zugehörige
.onnx.json liegt bereits dort):
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
Konfiguration
.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:
OPENROUTER_API_KEY=sk-or-...
| 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 |
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.
Anhänge
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.
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.
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 extrahiert. Gescannte PDFs
ohne Textebene werden abgewiesen, OCR gibt es nicht.
| 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 |
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:
curl -s http://localhost:11434/api/show -d '{"name":"qwen3.5:latest"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['capabilities'])"
Werkzeuge
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 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
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 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“ — 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:
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 sieht der Nutzer die vollständige URL vor dem Abruf.rememberschreibt 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 — 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.
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:
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:
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 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:
ALLOWED_ORIGINS=http://192.168.1.50:5174
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.
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
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
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
npm start # baut web/dist und startet den Server auf :8788
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.