initial commit
- v 0.1.0 siehe CHANGELOG.md
This commit is contained in:
commit
a97013d876
95 changed files with 12200 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/nub
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
.DS_Store
|
||||||
|
.claude
|
||||||
|
commit.txt
|
||||||
|
.nub/
|
||||||
12
AGENTS.md
Normal file
12
AGENTS.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# nub
|
||||||
|
|
||||||
|
Schlanker CLI-Coding-Agent in Go. Konzept: docs/KONZEPT.md
|
||||||
|
|
||||||
|
- Go 1.24. `iter.Seq2` wird verwendet — keine Channel-Iteratoren.
|
||||||
|
- Definition of Done: `go test ./...` und `go vet ./...` grün.
|
||||||
|
- Kein Paket unterhalb von internal/agent importiert internal/ui (E-04).
|
||||||
|
- Keine Ausgabe auf stdout außerhalb von internal/ui. Logging via slog in
|
||||||
|
eine Datei (Abschnitt 2.3).
|
||||||
|
- Neue Abhängigkeiten nur aus der Allowlist in Abschnitt 2.3. Kein Cobra,
|
||||||
|
kein Viper.
|
||||||
|
- Festlegungen aus Abschnitt 9 sind entschieden — nicht neu verhandeln.
|
||||||
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an `nub` werden in dieser Datei dokumentiert.
|
||||||
|
|
||||||
|
Das Format orientiert sich an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/).
|
||||||
|
Dieses Projekt ist privat und folgt keinem formalen Versionsschema
|
||||||
|
([SemVer](https://semver.org/lang/de/)) — Versionsnummern hier dienen nur
|
||||||
|
der zeitlichen Einordnung.
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-07-25
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- Kern-Loop: OpenAI-kompatibler Provider-Adapter, eingebaute
|
||||||
|
Datei-/Bash-Tools, Agent-Loop, Print-Modus (M1).
|
||||||
|
- Session-Baum: Sessions als persistenter Baum, jederzeit zurückspringen
|
||||||
|
und neu verzweigen (M2).
|
||||||
|
- Geschichtete Config und Context Assembler (`AGENTS.md`, Repomap,
|
||||||
|
Budget) (M3).
|
||||||
|
- Token-Kalibrierung und Auto-Compaction (M4).
|
||||||
|
- Skills mit Progressive Disclosure und MCP-Server-Anbindung mit
|
||||||
|
Allowlist und Profilen (M5).
|
||||||
|
- TUI auf Basis von Bubbletea, architektonisch vom Kern entfernbar (M6).
|
||||||
|
- Prompt-Cache-Beobachtbarkeit über `cache_read_tokens` (M7).
|
||||||
|
- Permissions-System: pro Tool `auto`/`ask`/`deny`, plus `deny_paths`/
|
||||||
|
`deny_bash` als Override.
|
||||||
|
- `question`-Tool: generische Freitext-Rückfrage für Modell und Skills,
|
||||||
|
inline im TUI-Transkript beantwortbar.
|
||||||
|
- `nub init` / `/init`: scaffoldet `.nub/config.toml` und `AGENTS.md`.
|
||||||
|
- Skills direkt als Slash-Commands (`/<name>`) aufrufbar.
|
||||||
|
- `/sessions`: Sessions auflisten, wechseln, löschen.
|
||||||
|
|
||||||
|
[Feature-Übersicht](docs/FEATURES.md) · [Test-Rezepte](docs/TESTING.md)
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Thomas "Tom" Adamek
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
338
README.md
Normal file
338
README.md
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
# nub
|
||||||
|
|
||||||
|
Schlanker, terminal-nativer Coding-Agent als einzelnes Go-Binary. Keine
|
||||||
|
Laufzeit-Abhängigkeiten, interaktive TUI, skriptbarer Print-Modus.
|
||||||
|
|
||||||
|
- Features: [docs/FEATURES.md](docs/FEATURES.md)
|
||||||
|
- Architektur/Design: [docs/KONZEPT.md](docs/KONZEPT.md)
|
||||||
|
- Entwicklung/Tests: [docs/TESTING.md](docs/TESTING.md)
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Go 1.24+
|
||||||
|
- OpenAI-kompatibler Endpoint (OpenAI-API-Key oder lokaler Server: Ollama,
|
||||||
|
LM Studio, vLLM, …)
|
||||||
|
- Linux/macOS (Windows nur über WSL)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> nub && cd nub
|
||||||
|
go build -o nub ./cmd/nub
|
||||||
|
mv nub /usr/local/bin/ # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
cd /pfad/zu/deinem/projekt
|
||||||
|
nub
|
||||||
|
```
|
||||||
|
|
||||||
|
Startet eine interaktive Session im aktuellen Verzeichnis — das wird zur
|
||||||
|
Wurzel für alle Datei-Tools und `bash`-Aufrufe. Ohne Config-Datei laufen
|
||||||
|
sinnvolle Defaults (Modell `gpt-4o` gegen die echte OpenAI-API).
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Geschichtet, spätere Schicht gewinnt: `Defaults` → `~/.config/nub/config.toml`
|
||||||
|
(global) → `<projekt>/.nub/config.toml` (projektlokal) → `NUB_*`-Env-Vars.
|
||||||
|
|
||||||
|
### Nur Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Pflicht | Default | Bedeutung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `NUB_API_KEY` | nein* | — | API-Key, Vorrang vor `OPENAI_API_KEY` |
|
||||||
|
| `OPENAI_API_KEY` | nein* | — | Fallback |
|
||||||
|
| `NUB_BASE_URL` | nein | `https://api.openai.com/v1` | Endpoint-URL |
|
||||||
|
| `NUB_MODEL` | nein | `gpt-4o` | Modellname |
|
||||||
|
|
||||||
|
\* Pflicht, außer der Endpoint beginnt mit `http://` (lokal, kein Key nötig).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
export NUB_MODEL=gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NUB_BASE_URL=http://localhost:11434/v1
|
||||||
|
export NUB_MODEL=qwen2.5-coder:7b
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mit Config-Datei
|
||||||
|
|
||||||
|
`nub init` (oder `/init` in der TUI) legt `.nub/config.toml` (kommentierte
|
||||||
|
Beispiel-Config inkl. Permissions) und ein minimales `AGENTS.md` an — nur
|
||||||
|
falls noch nicht vorhanden.
|
||||||
|
|
||||||
|
Von Hand, `.nub/config.toml` (`version = 1` ist Pflicht):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
version = 1
|
||||||
|
|
||||||
|
[model]
|
||||||
|
default = "gpt-4o-mini"
|
||||||
|
endpoint = "openai"
|
||||||
|
|
||||||
|
[[endpoint]]
|
||||||
|
name = "openai"
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
api_key = "env:OPENAI_API_KEY" # oder direkt der Key, oder "none" für lokal
|
||||||
|
|
||||||
|
[context]
|
||||||
|
files = ["AGENTS.md", "REPOMAP.md"]
|
||||||
|
walk_up = true
|
||||||
|
max_tokens = 20000
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mehrere `[[endpoint]]`-Blöcke möglich; `model.endpoint` wählt aus.
|
||||||
|
- `[endpoint.caps]` überschreibt einzelne Fähigkeiten (z.B.
|
||||||
|
`parallel_tool_calls = false` für Server, die das nicht unterstützen).
|
||||||
|
- Fehlt bei einem Nicht-lokalen Endpoint der Key, bricht `nub` sofort ab.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
TUI standardmäßig im Terminal (stdin+stdout), sonst Print-Modus. `-p`
|
||||||
|
erzwingt Print-Modus.
|
||||||
|
|
||||||
|
### TUI
|
||||||
|
|
||||||
|
| Taste/Kommando | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `Enter` | Nachricht senden (läuft ein Turn: als Steering zwischen zwei Tool-Aufrufen eingeschleust) |
|
||||||
|
| `Alt+Enter` | als Follow-up queuen (erst nach Ende des laufenden Turns) |
|
||||||
|
| `↑`/`↓` | Eingabe-Historie (bei leerer Eingabezeile) |
|
||||||
|
| `PgUp`/`PgDn`, `Ctrl+U`/`Ctrl+D`, Mausrad | Nachrichtenliste scrollen |
|
||||||
|
| `Ctrl+C`, `/exit`, `/quit` | beenden |
|
||||||
|
|
||||||
|
Diffs (`write`/`edit`) farbig im Transkript, fertige Antworten als Markdown.
|
||||||
|
Farben passen sich automatisch an helles/dunkles Terminal-Theme an.
|
||||||
|
|
||||||
|
**Slash-Kommandos:**
|
||||||
|
|
||||||
|
| Kommando | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `/model [name]` | Modell anzeigen oder für den Rest der Session wechseln |
|
||||||
|
| `/tree`, `/branch` | Session-Baum anzeigen, Rewind mit ↑/↓ + Enter |
|
||||||
|
| `/sessions` | Sessions auflisten, wechseln (Enter), löschen (Entf, Rückfrage) |
|
||||||
|
| `/compact` | Session sofort zusammenfassen |
|
||||||
|
| `/context` | geladenen Kontext mit Herkunft/Tokenkosten anzeigen |
|
||||||
|
| `/mcp` | verbundene MCP-Server und ihre Tools |
|
||||||
|
| `/skills` | gefundene Skills |
|
||||||
|
| `/export [pfad]` | Transkript als Markdown exportieren |
|
||||||
|
| `/init` | `.nub/config.toml` + `AGENTS.md` anlegen, falls nicht vorhanden |
|
||||||
|
| `/clear` | Anzeige leeren (Session bleibt erhalten) |
|
||||||
|
| `/exit`, `/quit` | beenden |
|
||||||
|
| `/help` | Kommando-Übersicht |
|
||||||
|
|
||||||
|
Jeder gefundene Skill ist zusätzlich direkt als `/<name> [Zusatzinfo]`
|
||||||
|
aufrufbar (siehe [Skills](#skills)).
|
||||||
|
|
||||||
|
### Print-Modus (`-p`)
|
||||||
|
|
||||||
|
Zeilenweise stdin = eine Nachricht. Antworten auf stdout, Tool-Aktivität
|
||||||
|
(`→ tool_name {...}`, `← tool_name [ok|error]`, `bash`-Output) auf stderr.
|
||||||
|
`Ctrl+D` (EOF) beendet sauber nach dem aktuellen Turn.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./nub -p
|
||||||
|
Lies die Datei go.mod und fasse in einem Satz zusammen, was das Modul ist.
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim Start gibt `nub` (beide Modi) auf stderr die Session-ID aus
|
||||||
|
(`session: 01J...`).
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
Jeder Lauf ohne Subcommand startet eine neue Session, persistiert unter
|
||||||
|
`.nub/sessions/<id>.jsonl` (automatisch von Git ausgeschlossen via
|
||||||
|
`.git/info/exclude`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub sessions # Sessions im aktuellen Verzeichnis auflisten
|
||||||
|
nub resume <session-id> # Session fortsetzen
|
||||||
|
```
|
||||||
|
|
||||||
|
In der TUI: `/sessions` (Liste, wechseln, löschen).
|
||||||
|
|
||||||
|
**Rewind/Branch:** in der TUI über `/tree`/`/branch`. Über CLI/Print-Modus
|
||||||
|
per Node-ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat .nub/sessions/<session-id>.jsonl | jq -r 'select(.node) | .node.id + " " + (.node.message.content[0].text // "" | .[0:60])'
|
||||||
|
nub resume <session-id> --branch <node-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Setzt den Head auf den gewählten Knoten zurück; eine neue Nachricht bildet
|
||||||
|
dort einen zweiten Ast. Der ursprüngliche Ast bleibt erhalten.
|
||||||
|
|
||||||
|
## Kontext (AGENTS.md, REPOMAP.md, nub map)
|
||||||
|
|
||||||
|
System-Prompt = Basis-Prompt (überschreibbar durch `SYSTEM.md` im
|
||||||
|
Projekt-Root) + statische Umgebungsinfo (OS/Shell/Repo-Root/Branch) + Dateien
|
||||||
|
aus `context.files` (Default: `AGENTS.md`, `REPOMAP.md`). Mit
|
||||||
|
`walk_up = true` (Default) werden Treffer zwischen aktuellem Verzeichnis und
|
||||||
|
Projekt-Root eingesammelt.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub context # geladene Kontext-Dateien mit Herkunft und Tokenkosten
|
||||||
|
nub map # REPOMAP.md generieren/aktualisieren
|
||||||
|
```
|
||||||
|
|
||||||
|
`context.max_tokens` (Default 20000) begrenzt Kontext-Dateien, kürzt sichtbar
|
||||||
|
statt still. `nub map` erzeugt Verzeichnisbaum + exportierte Symbole
|
||||||
|
(`.go` über `go/ast`, sonst erste Zeile als Fallback); warnt, wenn die
|
||||||
|
Repomap vom Git-Stand abweicht.
|
||||||
|
|
||||||
|
## Compaction & todo-Tool
|
||||||
|
|
||||||
|
Vor jedem Turn geschätzte Tokenkosten; über Schwelle (Default 75 % des
|
||||||
|
Modell-Fensters) wird automatisch komprimiert: ältere Hälfte der Session
|
||||||
|
wird per separatem Prompt zusammengefasst, letzte Turns bleiben im Original.
|
||||||
|
Original-Nodes bleiben erhalten — Rewind vor die Compaction funktioniert
|
||||||
|
weiterhin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub compact <session-id> # Compaction sofort erzwingen
|
||||||
|
```
|
||||||
|
|
||||||
|
`todo`-Tool: In-Memory-Todo-Liste für lange Aufgaben (nicht persistiert),
|
||||||
|
das Modell entscheidet selbst über die Nutzung.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
`.nub/skills/<name>/SKILL.md` (projektlokal) oder
|
||||||
|
`~/.nub/skills/<name>/SKILL.md` (global, konfigurierbar über
|
||||||
|
`[skills] paths = [...]`). Projektlokal überschreibt global.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: refactoring
|
||||||
|
description: Vorgehen für größere Refactorings in diesem Repo.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactoring
|
||||||
|
...Body...
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim Start landet nur `name`+`description` im System-Prompt (~30 Token pro
|
||||||
|
Skill); den Body lädt das Modell selbst nach (`read_skill`), weitere
|
||||||
|
Dateien im Skill-Verzeichnis über `read`/`glob`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub skills # Index-Vorschau
|
||||||
|
```
|
||||||
|
|
||||||
|
**Als Slash-Command:** `/<name> [Zusatzinfo]`, z.B. `/refactoring fokus auf
|
||||||
|
error handling` — schickt eine kurze Direktive statt Rohdaten, Progressive
|
||||||
|
Disclosure bleibt erhalten. Eingebaute Kommandos haben bei Namenskollision
|
||||||
|
Vorrang.
|
||||||
|
|
||||||
|
## MCP
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[mcp]]
|
||||||
|
name = "github"
|
||||||
|
command = "gh-mcp-server"
|
||||||
|
args = []
|
||||||
|
tools = ["create_issue", "get_pull_request"] # Allowlist; leer = alle
|
||||||
|
|
||||||
|
[[mcp]]
|
||||||
|
name = "linear"
|
||||||
|
url = "https://mcp.linear.app/mcp"
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle Server parallel verbunden (Timeout 5s/Server), Tools mit
|
||||||
|
`<server>__`-Präfix (z.B. `github__create_issue`). Nicht erreichbare Server
|
||||||
|
verhindern den Start nicht (Warnung, `nub` läuft weiter).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub mcp # verbindet und listet Tools
|
||||||
|
```
|
||||||
|
|
||||||
|
**Profile** grenzen ein, welche Server pro Aufruf verbunden werden:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[profiles]
|
||||||
|
review = ["github"]
|
||||||
|
default = []
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub --profile review
|
||||||
|
nub resume <session-id> --profile review
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `[profiles]`-Sektion: immer alle konfigurierten Server.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[permissions]
|
||||||
|
read = "auto"
|
||||||
|
glob = "auto"
|
||||||
|
grep = "auto"
|
||||||
|
write = "ask"
|
||||||
|
edit = "ask"
|
||||||
|
bash = "ask"
|
||||||
|
deny_paths = [".git/**", "**/.env", "**/id_rsa*"]
|
||||||
|
deny_bash = ["rm -rf /", "git push --force*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Modus | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `auto` (Default) | läuft ohne Rückfrage |
|
||||||
|
| `ask` | TUI: Rückfrage-Block im Transkript, `y`/`Y` erlaubt, jede andere Taste lehnt ab. Print-Modus: harter Fehler |
|
||||||
|
| `deny` | läuft nie |
|
||||||
|
|
||||||
|
- `deny_paths`/`deny_bash` überstimmen `auto`/`ask` immer. `deny_paths` gilt
|
||||||
|
für `read`/`write`/`edit`/`grep` (`path`-Feld), `deny_bash` matcht den
|
||||||
|
Befehlstext (Glob/Teilstring).
|
||||||
|
- Tools ohne eigenes Config-Feld (MCP, `todo`, `read_skill`) laufen immer
|
||||||
|
`auto`.
|
||||||
|
- Mehrere gleichzeitige Tool-Aufrufe: Rückfragen laufen nacheinander vor dem
|
||||||
|
parallelen Start der erlaubten Calls — nie zwei Dialoge gleichzeitig.
|
||||||
|
|
||||||
|
### `question`-Tool
|
||||||
|
|
||||||
|
Gleiche Rückfrage-Mechanik als eigenständiges Tool: Modell oder
|
||||||
|
Skill-Anweisung stellt eine freie Frage (optional mit Vorschlägen), Antwort
|
||||||
|
kommt als Tool-Ergebnis zurück. TUI: Block im Transkript, nächste Eingabe
|
||||||
|
zählt als Antwort statt neuer Nachricht/Kommando. Print-Modus: sofortiger
|
||||||
|
Fehler (kein Blockieren, Print läuft headless).
|
||||||
|
|
||||||
|
## Alle CLI-Kommandos
|
||||||
|
|
||||||
|
```
|
||||||
|
nub init .nub/config.toml + AGENTS.md anlegen, falls nicht vorhanden
|
||||||
|
nub neue Session starten (TUI im Terminal)
|
||||||
|
nub -p Print-Modus erzwingen
|
||||||
|
nub resume <session-id> Session fortsetzen
|
||||||
|
nub resume <session-id> --branch <node-id>
|
||||||
|
von einem früheren Knoten fortsetzen (Rewind)
|
||||||
|
nub sessions Sessions auflisten
|
||||||
|
nub map REPOMAP.md erzeugen/aktualisieren
|
||||||
|
nub context geladenen Kontext anzeigen
|
||||||
|
nub compact <session-id> Compaction erzwingen
|
||||||
|
nub skills gefundene Skills auflisten
|
||||||
|
nub mcp MCP-Server verbinden, Tools auflisten
|
||||||
|
nub [resume ...] --profile <p> nur MCP-Server aus profiles.<p> verbinden
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bekannte Grenzen
|
||||||
|
|
||||||
|
- Bilder werden nicht verarbeitet, auch wenn der Endpoint sie unterstützt.
|
||||||
|
- Permissions/`deny_paths` gelten nur für die sechs eingebauten Datei-/
|
||||||
|
Bash-Tools — MCP-Tools laufen immer `auto`.
|
||||||
|
- Compaction nutzt immer dasselbe Modell wie die Session.
|
||||||
|
- MCP-Server reconnecten nicht zur Laufzeit (Neustart nötig); nur Tools
|
||||||
|
angebunden, keine Prompts/Resources.
|
||||||
|
- Skills müssen von Hand angelegt werden.
|
||||||
|
|
||||||
|
Ausführlichere Liste: [docs/TESTING.md](docs/TESTING.md).
|
||||||
|
|
||||||
|
## Mitentwickeln
|
||||||
|
|
||||||
|
Build, Tests, manuelle Testrezepte: [docs/TESTING.md](docs/TESTING.md).
|
||||||
|
Architektur/Design-Entscheidungen: [docs/KONZEPT.md](docs/KONZEPT.md).
|
||||||
566
cmd/nub/main.go
Normal file
566
cmd/nub/main.go
Normal file
|
|
@ -0,0 +1,566 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattn/go-isatty"
|
||||||
|
|
||||||
|
"nub/internal/agent"
|
||||||
|
"nub/internal/config"
|
||||||
|
"nub/internal/ctxasm"
|
||||||
|
"nub/internal/llm/openai"
|
||||||
|
"nub/internal/mcpc"
|
||||||
|
"nub/internal/permission"
|
||||||
|
"nub/internal/scaffold"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/skill"
|
||||||
|
"nub/internal/tokens"
|
||||||
|
"nub/internal/tool"
|
||||||
|
"nub/internal/tool/builtin"
|
||||||
|
"nub/internal/ui/plain"
|
||||||
|
"nub/internal/ui/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exit-Codes (5.9): 0 Erfolg, 1 Agent-/Provider-Fehler, 2 Konfigurationsfehler,
|
||||||
|
// 3 Abbruch durch Nutzer, 4 Limit erreicht.
|
||||||
|
const (
|
||||||
|
exitOK = 0
|
||||||
|
exitAgentError = 1
|
||||||
|
exitConfigError = 2
|
||||||
|
exitUserAbort = 3
|
||||||
|
exitLimitReached = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(run(os.Args[1:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(args []string) int {
|
||||||
|
if len(args) > 0 {
|
||||||
|
switch args[0] {
|
||||||
|
case "init":
|
||||||
|
return runInit()
|
||||||
|
case "sessions":
|
||||||
|
return runSessionsList()
|
||||||
|
case "resume":
|
||||||
|
return runAgent(args[1:], true)
|
||||||
|
case "map":
|
||||||
|
return runMap()
|
||||||
|
case "context":
|
||||||
|
return runContext()
|
||||||
|
case "compact":
|
||||||
|
return runCompact(args[1:])
|
||||||
|
case "skills":
|
||||||
|
return runSkillsList()
|
||||||
|
case "mcp":
|
||||||
|
return runMCPList()
|
||||||
|
case "-h", "--help", "help":
|
||||||
|
printUsage()
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runAgent(args, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Fprintln(os.Stderr, `nub — schlanker Coding-Agent
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
nub init .nub/config.toml (Beispiel-Config) und AGENTS.md anlegen, falls nicht vorhanden
|
||||||
|
nub neue Session starten (TUI, wenn stdin/stdout ein Terminal sind)
|
||||||
|
nub -p Print-Modus erzwingen (stdin/stdout, kein TUI)
|
||||||
|
nub resume <session-id> bestehende Session fortsetzen
|
||||||
|
nub resume <session-id> --branch <node-id>
|
||||||
|
von einem früheren Knoten aus fortsetzen (Rewind)
|
||||||
|
nub sessions Sessions im aktuellen Verzeichnis auflisten
|
||||||
|
nub map REPOMAP.md im aktuellen Verzeichnis erzeugen/aktualisieren
|
||||||
|
nub context geladenen Kontext mit Herkunft und Tokenkosten anzeigen
|
||||||
|
nub compact <session-id> Session sofort kompaktieren, unabhängig vom Schwellwert
|
||||||
|
nub skills gefundene Skills (Name+Description) auflisten
|
||||||
|
nub mcp konfigurierte MCP-Server verbinden und ihre Tools auflisten
|
||||||
|
nub [resume ...] --profile <p> nur die MCP-Server aus profiles.<p> verbinden
|
||||||
|
|
||||||
|
Konfiguration: geschichtete TOML-Config + NUB_*-Umgebungsvariablen, siehe README.md`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLoop kapselt das Setup, das runAgent und runCompact gemeinsam
|
||||||
|
// brauchen: Config laden, Skills/MCP anbinden, Kontext assemblen, Session
|
||||||
|
// öffnen, Loop verdrahten. Der Rückgabewert []*mcpc.Server muss vom Aufrufer
|
||||||
|
// am Ende geschlossen werden (mcpc.CloseAll). cfg/skills werden zusätzlich
|
||||||
|
// zurückgegeben, weil die TUI sie für /context, /skills, /mcp braucht.
|
||||||
|
func buildLoop(repoRoot string, resume bool, sessionID, branch, profile string) (*agent.Loop, *session.Store, []*mcpc.Server, config.Config, []skill.Skill, error) {
|
||||||
|
cfg, err := config.Load(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, config.Config{}, nil, err
|
||||||
|
}
|
||||||
|
resolved, err := config.Resolve(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, config.Config{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
skills, err := skill.Discover(cfg.Skills.Paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, config.Config{}, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
system, ctxMeta, err := ctxasm.Assemble(ctxasm.Options{
|
||||||
|
RepoRoot: repoRoot,
|
||||||
|
Cwd: repoRoot,
|
||||||
|
Files: cfg.Context.Files,
|
||||||
|
WalkUp: cfg.Context.WalkUp,
|
||||||
|
MaxTokens: cfg.Context.MaxTokens,
|
||||||
|
SkillsIndex: skill.RenderIndex(skills),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, config.Config{}, nil, err
|
||||||
|
}
|
||||||
|
for _, w := range ctxMeta.Warnings {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning:", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
var store *session.Store
|
||||||
|
if resume {
|
||||||
|
store, err = session.Load(repoRoot, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, config.Config{}, nil, err
|
||||||
|
}
|
||||||
|
if branch != "" {
|
||||||
|
if err := store.Branch(branch); err != nil {
|
||||||
|
return nil, nil, nil, config.Config{}, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
store, err = session.Create(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, config.Config{}, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "session: %s\n", store.ID)
|
||||||
|
|
||||||
|
provider := openai.New(cfg.Model.Endpoint, resolved.BaseURL, resolved.APIKey, resolved.Caps)
|
||||||
|
|
||||||
|
registry := tool.NewRegistry()
|
||||||
|
registry.Register(builtin.ReadTool{})
|
||||||
|
registry.Register(builtin.WriteTool{})
|
||||||
|
registry.Register(builtin.EditTool{})
|
||||||
|
registry.Register(builtin.GlobTool{})
|
||||||
|
registry.Register(builtin.GrepTool{})
|
||||||
|
registry.Register(builtin.BashTool{})
|
||||||
|
registry.Register(&builtin.TodoTool{})
|
||||||
|
registry.Register(builtin.NewReadSkillTool(skills))
|
||||||
|
registry.Register(builtin.QuestionTool{})
|
||||||
|
|
||||||
|
mcpServers := connectMCP(registry, cfg, profile)
|
||||||
|
|
||||||
|
loop := &agent.Loop{
|
||||||
|
Provider: provider,
|
||||||
|
Tools: registry,
|
||||||
|
Model: resolved.Model,
|
||||||
|
System: system,
|
||||||
|
Env: tool.Env{Cwd: repoRoot, RepoRoot: repoRoot},
|
||||||
|
Session: store,
|
||||||
|
Tokens: tokens.NewCalibrator(),
|
||||||
|
Permissions: buildPermissionPolicy(cfg.Permissions),
|
||||||
|
}
|
||||||
|
return loop, store, mcpServers, cfg, skills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPermissionPolicy übersetzt die Config in eine permission.Policy
|
||||||
|
// (E-11). RequestPermission wird hier bewusst NICHT gesetzt: im Print-Modus
|
||||||
|
// bleibt es nil (macht "ask" zum harten Fehler, wie im Konzept verlangt),
|
||||||
|
// die TUI setzt es selbst auf ihren eigenen Rückfrage-Mechanismus (siehe
|
||||||
|
// internal/ui/tui.New).
|
||||||
|
func buildPermissionPolicy(cfg config.PermissionsConfig) *permission.Policy {
|
||||||
|
return &permission.Policy{
|
||||||
|
Modes: map[string]permission.Mode{
|
||||||
|
"read": permission.Mode(cfg.Read),
|
||||||
|
"glob": permission.Mode(cfg.Glob),
|
||||||
|
"grep": permission.Mode(cfg.Grep),
|
||||||
|
"write": permission.Mode(cfg.Write),
|
||||||
|
"edit": permission.Mode(cfg.Edit),
|
||||||
|
"bash": permission.Mode(cfg.Bash),
|
||||||
|
},
|
||||||
|
DenyPaths: cfg.DenyPaths,
|
||||||
|
DenyBash: cfg.DenyBash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isInteractive entscheidet TUI vs. Print-Modus (5.8): TUI nur, wenn sowohl
|
||||||
|
// stdin als auch stdout ein echtes Terminal sind.
|
||||||
|
func isInteractive() bool {
|
||||||
|
return isatty.IsTerminal(os.Stdout.Fd()) && isatty.IsTerminal(os.Stdin.Fd())
|
||||||
|
}
|
||||||
|
|
||||||
|
// connectMCP verbindet die (per Profil gefilterten) konfigurierten MCP-
|
||||||
|
// Server und registriert ihre Tools. Ein kaputter Server oder ein
|
||||||
|
// unbekanntes Profil verhindert den Start nicht — nur eine Warnung (E-06).
|
||||||
|
func connectMCP(registry *tool.Registry, cfg config.Config, profile string) []*mcpc.Server {
|
||||||
|
selected, err := mcpc.SelectServers(cfg.MCP, cfg.Profiles, profile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning: mcp profile:", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(selected) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
result := mcpc.ConnectAll(ctx, selected)
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning:", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
byName := make(map[string]config.MCPServer, len(selected))
|
||||||
|
for _, s := range selected {
|
||||||
|
byName[s.Name] = s
|
||||||
|
}
|
||||||
|
for _, srv := range result.Servers {
|
||||||
|
warnings, err := mcpc.RegisterTools(ctx, registry, srv, byName[srv.Name])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: mcp server %q: %v\n", srv.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, w := range warnings {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning:", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.Servers
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAgent(args []string, resume bool) int {
|
||||||
|
fsName := "nub"
|
||||||
|
if resume {
|
||||||
|
fsName = "resume"
|
||||||
|
}
|
||||||
|
fs := flag.NewFlagSet(fsName, flag.ContinueOnError)
|
||||||
|
var branch, profile string
|
||||||
|
var printMode bool
|
||||||
|
fs.StringVar(&profile, "profile", "", "MCP-Server-Profil (siehe [profiles] in der Config)")
|
||||||
|
fs.BoolVar(&printMode, "p", false, "Print-Modus statt TUI erzwingen")
|
||||||
|
fs.BoolVar(&printMode, "print", false, "Alias für -p")
|
||||||
|
if resume {
|
||||||
|
fs.StringVar(&branch, "branch", "", "Node-ID, von dem aus fortgesetzt wird (Rewind)")
|
||||||
|
}
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionID string
|
||||||
|
if resume {
|
||||||
|
if fs.NArg() < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: nub resume <session-id> [--branch <node-id>] [--profile <name>]")
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
sessionID = fs.Arg(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
|
||||||
|
loop, store, mcpServers, cfg, skills, err := buildLoop(repoRoot, resume, sessionID, branch, profile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
defer mcpc.CloseAll(mcpServers)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
if !printMode && isInteractive() {
|
||||||
|
err := tui.Run(ctx, loop, store, cfg, skills, mcpServers)
|
||||||
|
// Ein SIGTERM/SIGINT von außen bricht runCtx ab, während die TUI noch
|
||||||
|
// läuft — Program.Run() liefert dafür regulär einen "killed"-Fehler.
|
||||||
|
// Das ist der erwartete Abbruchpfad, kein echter TUI-Fehler.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return exitUserAbort
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "tui error:", err)
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
in := make(chan agent.Input)
|
||||||
|
out := loop.Run(ctx, in)
|
||||||
|
|
||||||
|
go feedStdin(ctx, in)
|
||||||
|
|
||||||
|
renderer := &plain.Renderer{Out: os.Stdout, Err: os.Stderr}
|
||||||
|
lastErr := renderer.Render(out)
|
||||||
|
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "aborted")
|
||||||
|
return exitUserAbort
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
if errors.Is(lastErr, context.DeadlineExceeded) {
|
||||||
|
return exitLimitReached
|
||||||
|
}
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCompact ist das CLI-Äquivalent zum /compact-TUI-Kommando (5.8): stößt
|
||||||
|
// eine einmalige Compaction unabhängig vom Schwellwert an und beendet sich.
|
||||||
|
func runCompact(args []string) int {
|
||||||
|
if len(args) < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: nub compact <session-id>")
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
sessionID := args[0]
|
||||||
|
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
|
||||||
|
loop, store, mcpServers, _, _, err := buildLoop(repoRoot, true, sessionID, "", "")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
defer mcpc.CloseAll(mcpServers)
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
out := make(chan tool.UIEvent, 8)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
if err := loop.Compact(context.Background(), out); err != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: err}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
renderer := &plain.Renderer{Out: os.Stdout, Err: os.Stderr}
|
||||||
|
if err := renderer.Render(out); err != nil {
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSessionsList() int {
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
|
||||||
|
infos, err := session.List(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
if len(infos) == 0 {
|
||||||
|
fmt.Fprintln(os.Stdout, "no sessions in this directory")
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
for _, info := range infos {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s %s %d nodes %s\n",
|
||||||
|
info.ID, info.Created.Format(time.RFC3339), info.NodeCount, info.Summary)
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMap() int {
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := ctxasm.GenerateRepoMap(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
|
||||||
|
path := repoRoot + "/REPOMAP.md"
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "wrote", path)
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInit ist das CLI-Äquivalent zum /init-TUI-Kommando: legt eine
|
||||||
|
// Beispiel-Config (inkl. Permissions) und ein minimalistisches AGENTS.md
|
||||||
|
// an, überschreibt aber nie bestehende Dateien.
|
||||||
|
func runInit() int {
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := scaffold.Init(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.ConfigCreated {
|
||||||
|
fmt.Fprintln(os.Stdout, "wrote", res.ConfigPath)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stdout, "skipped (already exists):", res.ConfigPath)
|
||||||
|
}
|
||||||
|
if res.AgentsCreated {
|
||||||
|
fmt.Fprintln(os.Stdout, "wrote", res.AgentsPath)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stdout, "skipped (already exists):", res.AgentsPath)
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// runContext ist das CLI-Äquivalent zum /context-TUI-Kommando (5.8, folgt
|
||||||
|
// dem --branch-Muster aus M2): zeigt jede geladene Kontext-Datei mit
|
||||||
|
// Herkunft und geschätzten Tokenkosten.
|
||||||
|
func runContext() int {
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
|
||||||
|
_, meta, err := ctxasm.Assemble(ctxasm.Options{
|
||||||
|
RepoRoot: repoRoot,
|
||||||
|
Cwd: repoRoot,
|
||||||
|
Files: cfg.Context.Files,
|
||||||
|
WalkUp: cfg.Context.WalkUp,
|
||||||
|
MaxTokens: cfg.Context.MaxTokens,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(meta.Files) == 0 {
|
||||||
|
fmt.Fprintln(os.Stdout, "no context files loaded")
|
||||||
|
}
|
||||||
|
for _, f := range meta.Files {
|
||||||
|
trunc := ""
|
||||||
|
if f.Truncated {
|
||||||
|
trunc = " [truncated]"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "%-30s %6d tokens %s%s\n", f.Path, f.Tokens, f.Source, trunc)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "total: %d tokens (budget %d)\n", meta.TotalTokens, cfg.Context.MaxTokens)
|
||||||
|
for _, w := range meta.Warnings {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning:", w)
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSkillsList ist das CLI-Äquivalent zum /skills-TUI-Kommando: zeigt, was
|
||||||
|
// beim nächsten Start ohne Zusatzkosten (nur Index) verfügbar wäre.
|
||||||
|
func runSkillsList() int {
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
skills, err := skill.Discover(cfg.Skills.Paths)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
|
return exitAgentError
|
||||||
|
}
|
||||||
|
if len(skills) == 0 {
|
||||||
|
fmt.Fprintln(os.Stdout, "no skills found")
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
for _, s := range skills {
|
||||||
|
fmt.Fprintf(os.Stdout, "%-20s %s\n", s.Name, s.Dir)
|
||||||
|
if s.Description != "" {
|
||||||
|
fmt.Fprintf(os.Stdout, " %s\n", s.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMCPList ist das CLI-Äquivalent zum /mcp-TUI-Kommando: verbindet alle
|
||||||
|
// konfigurierten Server (ohne Profilfilter, um wirklich alles zu zeigen)
|
||||||
|
// und listet ihre Tools.
|
||||||
|
func runMCPList() int {
|
||||||
|
repoRoot, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||||
|
return exitConfigError
|
||||||
|
}
|
||||||
|
if len(cfg.MCP) == 0 {
|
||||||
|
fmt.Fprintln(os.Stdout, "no mcp servers configured")
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
result := mcpc.ConnectAll(ctx, cfg.MCP)
|
||||||
|
defer mcpc.CloseAll(result.Servers)
|
||||||
|
for _, w := range result.Warnings {
|
||||||
|
fmt.Fprintln(os.Stderr, "warning:", w)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, srv := range result.Servers {
|
||||||
|
var names []string
|
||||||
|
for t, err := range srv.Session.Tools(ctx, nil) {
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: mcp server %q: %v\n", srv.Name, err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
names = append(names, t.Name)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "%s: %d tools\n", srv.Name, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
fmt.Fprintf(os.Stdout, " %s__%s\n", srv.Name, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exitOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedStdin liest zeilenweise von stdin und speist sie als Input in den Loop.
|
||||||
|
// Schließt in, wenn stdin endet oder der Kontext abbricht.
|
||||||
|
func feedStdin(ctx context.Context, in chan<- agent.Input) {
|
||||||
|
defer close(in)
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case in <- agent.Input{Text: line}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
docs/FEATURES.md
Normal file
206
docs/FEATURES.md
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
# nub — Feature-Übersicht
|
||||||
|
|
||||||
|
Diese Seite beschreibt, was `nub` kann, aus Anwendersicht. Für die
|
||||||
|
Architektur dahinter siehe [KONZEPT.md](KONZEPT.md), für
|
||||||
|
Bau-/Test-Anleitungen siehe die [README](../README.md) und
|
||||||
|
[TESTING.md](TESTING.md).
|
||||||
|
|
||||||
|
## Grundprinzip
|
||||||
|
|
||||||
|
`nub` ist ein terminal-nativer Coding-Agent: ein einzelnes Go-Binary ohne
|
||||||
|
Laufzeit-Abhängigkeiten, das mit einem LLM über Tool-Calling zusammenarbeitet,
|
||||||
|
um Aufgaben in einem Code-Repository zu erledigen — Dateien lesen und
|
||||||
|
ändern, Befehle ausführen, Tests laufen lassen, und das Ganze über mehrere
|
||||||
|
Schritte hinweg, bis die Aufgabe erledigt ist.
|
||||||
|
|
||||||
|
Zwei Bedienmodi:
|
||||||
|
|
||||||
|
- **TUI** (Standard, wenn stdin/stdout ein Terminal sind) — interaktive
|
||||||
|
Oberfläche mit Streaming-Anzeige, Farb-Hervorhebung, Diff-Overlay und
|
||||||
|
Slash-Kommandos.
|
||||||
|
- **Print-Modus** (`-p`) — zeilenbasiert über stdin/stdout, für Skripte,
|
||||||
|
Pipes und Automatisierung.
|
||||||
|
|
||||||
|
Beide Modi sind dünne Consumer desselben Event-Streams; der Kern-Loop kennt
|
||||||
|
keinen von beiden und verhält sich in beiden identisch.
|
||||||
|
|
||||||
|
## Agent-Loop
|
||||||
|
|
||||||
|
- **Streaming-Antworten** — Text erscheint, während das Modell es erzeugt,
|
||||||
|
nicht erst am Ende.
|
||||||
|
- **Paralleles Tool-Calling** — fordert das Modell mehrere Tool-Aufrufe in
|
||||||
|
einem Schritt an, laufen sie gleichzeitig statt nacheinander.
|
||||||
|
- **Steering** — während der Agent noch arbeitet, eine neue Nachricht
|
||||||
|
schicken (TUI: Enter); sie wird eingeschleust, sobald der aktuelle
|
||||||
|
Tool-Aufruf fertig ist, ohne auf das Ende des ganzen Turns zu warten.
|
||||||
|
- **Follow-up-Queue** — alternativ eine Nachricht für *nach* dem aktuellen
|
||||||
|
Turn vormerken (TUI: Alt+Enter), statt den laufenden Gedankengang zu
|
||||||
|
unterbrechen.
|
||||||
|
- **Sicherheitsgrenzen** — Obergrenze für Turns pro Anfrage, Timeout pro
|
||||||
|
Tool-Aufruf, automatische Erkennung, wenn das Modell denselben Tool-Aufruf
|
||||||
|
wiederholt in eine Schleife läuft.
|
||||||
|
- **Sauberer Abbruch** — Ctrl+C (TUI) bzw. Ctrl+C im Print-Modus bricht
|
||||||
|
einen laufenden `bash`-Aufruf inklusive aller Kindprozesse ab, statt sie
|
||||||
|
als Zombies zurückzulassen.
|
||||||
|
|
||||||
|
## Eingebaute Tools
|
||||||
|
|
||||||
|
| Tool | Was es tut |
|
||||||
|
|---|---|
|
||||||
|
| `read` | Datei mit Zeilennummern lesen, mit Offset/Limit für große Dateien |
|
||||||
|
| `write` | Datei vollständig (neu) schreiben |
|
||||||
|
| `edit` | Gezielter String-Ersatz; bei mehrdeutigem Treffer Fehler statt Raten |
|
||||||
|
| `bash` | Shell-Befehl ausführen, mit Timeout und sauberem Abbruch |
|
||||||
|
| `glob` | Dateien per Muster finden, `.gitignore` wird respektiert |
|
||||||
|
| `grep` | Volltextsuche mit Kontextzeilen |
|
||||||
|
| `todo` | Fortschritts-Liste für lange Aufgaben (nur In-Memory) |
|
||||||
|
| `read_skill` | Vollständigen Inhalt eines Skills nachladen (siehe unten) |
|
||||||
|
| `question` | Freitext-Rückfrage an den Nutzer stellen (in der TUI als Block im Transkript, Antwort kommt als normales Tool-Ergebnis zurück; im Print-Modus ein harter Fehler, siehe unten) |
|
||||||
|
|
||||||
|
Dazu kommen automatisch alle Tools, die über angebundene MCP-Server
|
||||||
|
verfügbar sind (siehe „MCP" unten).
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
- Jede Konversation ist ein **Baum**, kein linearer Verlauf — jederzeit
|
||||||
|
lässt sich zu einem früheren Punkt zurückspringen und von dort aus neu
|
||||||
|
weiterarbeiten, ohne den ursprünglichen Verlauf zu verlieren.
|
||||||
|
- **Persistenz**: Jede Session wird fortlaufend auf die Platte geschrieben
|
||||||
|
(`.nub/sessions/`), nicht erst beim Beenden — ein Absturz verliert keine
|
||||||
|
Historie.
|
||||||
|
- **Fortsetzen**: `nub resume <session-id>` nimmt eine frühere Session genau
|
||||||
|
dort wieder auf, wo sie aufgehört hat.
|
||||||
|
- **Rewind/Branch**: Auf einen früheren Punkt zurückspringen und neu
|
||||||
|
weiterarbeiten erzeugt einen zweiten Ast — beide bleiben erhalten und
|
||||||
|
einzeln aufrufbar (TUI: `/tree` oder `/branch`; CLI: `nub resume --branch`).
|
||||||
|
- **Sessions auflisten**: `nub sessions` zeigt alle Sessions im aktuellen
|
||||||
|
Verzeichnis mit Kurzbeschreibung.
|
||||||
|
- **Session-Wechsel ohne Neustart**: In der TUI listet `/sessions` alle
|
||||||
|
Sessions des Projekts; Enter wechselt live zur gewählten Session (der
|
||||||
|
bisherige Verlauf wird aus der Historie neu aufgebaut), Entf löscht eine
|
||||||
|
Session unwiderruflich (mit Rückfrage). Die gerade aktive Session lässt
|
||||||
|
sich nicht löschen.
|
||||||
|
|
||||||
|
## Kontext
|
||||||
|
|
||||||
|
- **Automatischer System-Prompt** aus Basis-Prompt, Umgebungsinfo
|
||||||
|
(OS/Shell/Repo-Root/Branch) und konfigurierten Kontext-Dateien
|
||||||
|
(Default: `AGENTS.md`, `REPOMAP.md`).
|
||||||
|
- **`walk_up`**: Kontext-Dateien werden nicht nur im Repo-Root gesucht,
|
||||||
|
sondern auf dem Weg vom aktuellen Verzeichnis dorthin eingesammelt —
|
||||||
|
näher am Arbeitsverzeichnis gewinnt inhaltlich.
|
||||||
|
- **Budget-Kontrolle**: Kontext-Dateien werden auf ein Token-Budget
|
||||||
|
begrenzt; bei Überschreitung wird sichtbar gekürzt statt still
|
||||||
|
abgeschnitten.
|
||||||
|
- **`nub map`**: generiert eine `REPOMAP.md` mit Verzeichnisbaum und
|
||||||
|
exportierten Symbolen (Go: über `go/ast`, andere Sprachen: Dateikopf als
|
||||||
|
Fallback). Warnt, wenn die Repomap gegenüber dem aktuellen Git-Stand
|
||||||
|
veraltet ist.
|
||||||
|
- **`SYSTEM.md`**: eine Datei im Repo-Root überschreibt den eingebauten
|
||||||
|
Basis-System-Prompt vollständig, für projektspezifisches Verhalten.
|
||||||
|
|
||||||
|
## Auto-Compaction & Tokens
|
||||||
|
|
||||||
|
- Sobald der geschätzte Kontextverbrauch eine Schwelle (Default 75 % des
|
||||||
|
Modell-Fensters) überschreitet, fasst `nub` die ältere Hälfte der Session
|
||||||
|
automatisch zusammen (bearbeitete Dateien, Entscheidungen, offene
|
||||||
|
Aufgaben) und ersetzt sie durch eine kompakte Zusammenfassung — die
|
||||||
|
letzten Turns bleiben immer im Original erhalten.
|
||||||
|
- Die Zusammenfassung ist **nicht destruktiv**: der Originalverlauf bleibt
|
||||||
|
im Session-Baum erhalten, ein Rewind auf einen Punkt davor funktioniert
|
||||||
|
weiterhin.
|
||||||
|
- **Manuell erzwingen**: `/compact` (TUI) oder `nub compact <session-id>`.
|
||||||
|
- Die Token-Schätzung kalibriert sich während des Laufs gegen die
|
||||||
|
tatsächlich vom Provider gemeldete Token-Nutzung.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
- Wiederverwendbare Anleitungen für wiederkehrende Aufgaben (z.B.
|
||||||
|
„Vorgehen bei Refactorings"), als Markdown-Datei mit kurzem
|
||||||
|
Frontmatter (Name + Beschreibung) unter `.nub/skills/<name>/SKILL.md`
|
||||||
|
oder global unter `~/.nub/skills/`.
|
||||||
|
- **Progressive Disclosure**: Nur Name und Beschreibung stehen im
|
||||||
|
System-Prompt (~30 Token pro Skill). Der volle Inhalt wird erst geladen,
|
||||||
|
wenn das Modell ihn über `read_skill` tatsächlich braucht — spart Kontext
|
||||||
|
bei Skills, die in der aktuellen Aufgabe gar nicht relevant sind.
|
||||||
|
- `nub skills` (CLI) bzw. `/skills` (TUI) zeigen, was gefunden wurde.
|
||||||
|
- **Als Slash-Command**: Jeder Skill ist in der TUI direkt per `/<name>
|
||||||
|
[Zusatzinfo]` aufrufbar — schickt eine kurze Direktive statt den Body
|
||||||
|
einzufügen, das Modell lädt ihn weiterhin selbst nach. Eingebaute
|
||||||
|
Kommandos gewinnen bei Namenskollision.
|
||||||
|
|
||||||
|
## MCP (Model Context Protocol)
|
||||||
|
|
||||||
|
- Externe Tool-Server lassen sich per Config anbinden (`[[mcp]]`-Einträge:
|
||||||
|
lokaler Prozess über stdio oder ein HTTP-Endpoint).
|
||||||
|
- Alle konfigurierten Server werden **parallel** verbunden, mit Timeout —
|
||||||
|
ein nicht erreichbarer oder kaputter Server verhindert den Start nicht,
|
||||||
|
sondern fällt mit einer Warnung raus.
|
||||||
|
- Tools erscheinen mit `<server>__`-Präfix, um Namenskollisionen zwischen
|
||||||
|
Servern zu vermeiden.
|
||||||
|
- **Allowlist**: pro Server lässt sich einschränken, welche Tools geladen
|
||||||
|
werden (`tools = [...]`); ohne Allowlist wird ab 15 Tools gewarnt, weil
|
||||||
|
das spürbar Kontext kostet.
|
||||||
|
- **Profile**: `[profiles]` in der Config gruppiert Server, sodass sich
|
||||||
|
z.B. nur die für eine Code-Review relevanten Server verbinden lassen
|
||||||
|
(`--profile review`), statt immer alle.
|
||||||
|
- `nub mcp` (CLI) bzw. `/mcp` (TUI) zeigen verbundene Server und ihre Tools.
|
||||||
|
|
||||||
|
## TUI-Details
|
||||||
|
|
||||||
|
- **Streaming mit Markdown-Rendering**: laufender Text erscheint sofort,
|
||||||
|
fertige Antworten werden nachträglich als Markdown formatiert.
|
||||||
|
- **Diff-Overlay**: Datei-Änderungen (`write`/`edit`) erscheinen farbig
|
||||||
|
(+/-) direkt im Transkript.
|
||||||
|
- **Farbliche Unterscheidung** zwischen eigenen Nachrichten, Assistant-Text,
|
||||||
|
Tool-Aufrufen/-Ergebnissen, Slash-Kommando-Ausgaben, Hintergrund-
|
||||||
|
Ereignissen (z.B. Auto-Compaction) und echten Fehlern — alle Farben
|
||||||
|
passen sich automatisch an helle oder dunkle Terminals an.
|
||||||
|
- **Eingabe-Historie** (↑/↓), Scrollen der Nachrichtenliste (PgUp/PgDn,
|
||||||
|
Ctrl+U/D, Mausrad).
|
||||||
|
- **Interaktives Baum-Modal** (`/tree`, `/branch`) zum Durchsuchen und
|
||||||
|
Auswählen früherer Punkte der Session.
|
||||||
|
- **Export**: `/export [pfad]` schreibt das komplette Transkript als
|
||||||
|
Markdown-Datei.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
- **Geschichtet**: eingebaute Defaults → `~/.config/nub/config.toml`
|
||||||
|
(global) → `.nub/config.toml` (projektlokal) → `NUB_*`-Umgebungs-
|
||||||
|
variablen — spätere Schicht gewinnt.
|
||||||
|
- **Mehrere Endpoints**: beliebig viele `[[endpoint]]`-Einträge (OpenAI,
|
||||||
|
lokale Server wie Ollama/LM Studio/vLLM, …), einzeln in ihren
|
||||||
|
Fähigkeiten konfigurierbar (z.B. `parallel_tool_calls = false` für
|
||||||
|
Server, die das nicht unterstützen).
|
||||||
|
- Ohne jede Config-Datei funktioniert `nub` bereits mit sinnvollen
|
||||||
|
Defaults gegen die echte OpenAI-API.
|
||||||
|
- **`nub init`** (CLI) bzw. **`/init`** (TUI) legen `.nub/config.toml` mit
|
||||||
|
einer vollständig kommentierten Beispiel-Config (inkl. Permissions-
|
||||||
|
Beispiel) sowie ein minimalistisches `AGENTS.md` an — beides nur, falls
|
||||||
|
noch nicht vorhanden, nichts wird überschrieben.
|
||||||
|
|
||||||
|
## Sicherheit & Robustheit
|
||||||
|
|
||||||
|
- **Permissions pro Tool**: `read`/`glob`/`grep`/`write`/`edit`/`bash`
|
||||||
|
lassen sich einzeln auf `auto`, `ask` (Rückfrage in der TUI, im
|
||||||
|
Print-Modus ein harter Fehler) oder `deny` setzen. `deny_paths`/
|
||||||
|
`deny_bash` sperren zusätzlich bestimmte Pfade bzw. Befehlsmuster,
|
||||||
|
unabhängig vom sonst konfigurierten Modus.
|
||||||
|
- **`question`-Tool**: generische Rückfrage-Fähigkeit, die sowohl das
|
||||||
|
Modell selbst als auch Skills (per Anweisung) nutzen können, um eine
|
||||||
|
Freitext-Frage (optional mit Vorschlägen) an den Nutzer zu stellen und
|
||||||
|
die Antwort direkt als Tool-Ergebnis zurückzubekommen — dieselbe
|
||||||
|
Mechanik wie bei einer `ask`-Permission-Rückfrage, nur nicht an ein
|
||||||
|
bestimmtes Tool gebunden. In der TUI erscheint sie als Block im
|
||||||
|
Transkript (kein Overlay); die nächste Eingabe wird als Antwort
|
||||||
|
gewertet statt als neue Nachricht oder Slash-Kommando. Im Print-Modus
|
||||||
|
liefert sie einen klaren Fehler statt zu blockieren, da Print bewusst
|
||||||
|
headless laufen soll.
|
||||||
|
- Datei-Tools sind auf das Repository beschränkt (inkl. Auflösen von
|
||||||
|
Symlinks, um ein Verlassen des Repo-Roots zu verhindern).
|
||||||
|
- Sessiondateien haben restriktive Dateirechte (`0600`) und werden
|
||||||
|
automatisch von Git ausgeschlossen (`.git/info/exclude`, nicht
|
||||||
|
`.gitignore` — das bleibt Sache des Projekts).
|
||||||
|
- Kein Tool-Aufruf endet ohne Ergebnis — auch bei Abbruch oder Fehler
|
||||||
|
bekommt das Modell eine verwertbare Antwort statt eines hängenden
|
||||||
|
Zustands.
|
||||||
857
docs/KONZEPT.md
Normal file
857
docs/KONZEPT.md
Normal file
|
|
@ -0,0 +1,857 @@
|
||||||
|
# Coding-Agent CLI in Go — Konzept & Umsetzungsplan
|
||||||
|
|
||||||
|
> Projektname: **nub** — der Kern einer Sache, „the nub of it".
|
||||||
|
>
|
||||||
|
> Dieses Dokument ist als Arbeitsgrundlage für die Umsetzung mit einem Coding-Agenten
|
||||||
|
> gedacht. Abschnitt 10 beschreibt, wie es dafür eingesetzt wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ziel & Abgrenzung
|
||||||
|
|
||||||
|
### Ziel
|
||||||
|
|
||||||
|
Ein schlanker, terminal-basierter Coding-Agent als **einzelnes statisches Go-Binary**,
|
||||||
|
ohne Runtime-Abhängigkeit beim Anwender.
|
||||||
|
|
||||||
|
**Im Umfang:**
|
||||||
|
|
||||||
|
- Agent-Loop mit Tool-Calling und Streaming
|
||||||
|
- Provider: OpenAI-kompatibel (Start), Architektur offen für Anthropic/Gemini
|
||||||
|
- Eingebaute Tools: `read`, `write`, `edit`, `bash`, `glob`, `grep`, `todo`
|
||||||
|
- MCP-Client (stdio + Streamable HTTP)
|
||||||
|
- Skills mit Progressive Disclosure
|
||||||
|
- Kontext-Dateien: `AGENTS.md`, `REPOMAP.md`, frei konfigurierbar
|
||||||
|
- Session-Persistenz als **Baum** inkl. Rewind/Branch
|
||||||
|
- Auto-Compaction
|
||||||
|
- Modi: interaktiv (TUI) und Print/JSON (`-p`)
|
||||||
|
|
||||||
|
**Nicht im Umfang (v1):**
|
||||||
|
|
||||||
|
- Desktop-/Web-UI
|
||||||
|
- Sub-Agents
|
||||||
|
- LSP-Integration
|
||||||
|
- Plugin-System für Fremdcode (MCP + Skills decken den Bedarf ab)
|
||||||
|
- Eigenes Model-Hosting/Routing
|
||||||
|
|
||||||
|
### Leitprinzipien
|
||||||
|
|
||||||
|
1. **Der Kern kennt kein Terminal.** Jede Ausgabe läuft über einen Event-Channel.
|
||||||
|
2. **Das interne Datenmodell ist reicher als jeder Provider.** Adapter mappen herunter,
|
||||||
|
nie herauf.
|
||||||
|
3. **Kontext ist ein Budget.** Jede Komponente, die den Prompt befüllt, deklariert ihre
|
||||||
|
Kosten und respektiert Obergrenzen.
|
||||||
|
4. **Kein Zustand außerhalb des Session-Baums.** Alles, was einen Turn beeinflusst hat,
|
||||||
|
ist rekonstruierbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architektur
|
||||||
|
|
||||||
|
### 2.1 Paketstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/nub/ Einstiegspunkt, Flag-Parsing, Modus-Auswahl
|
||||||
|
internal/config/ Layered Config, Auflösung, Validierung
|
||||||
|
internal/llm/ Message, Block, Event, Provider-Interface, Caps
|
||||||
|
internal/llm/openai/ OpenAI-kompatibler Adapter
|
||||||
|
internal/llm/registry/ Modell-Metadaten, Endpoint-Auflösung
|
||||||
|
internal/tool/ Tool-Interface, Registry, Permissions
|
||||||
|
internal/tool/builtin/ read, write, edit, bash, glob, grep, todo, read_skill
|
||||||
|
internal/mcpc/ MCP-Client, Bridge MCP-Tool -> tool.Tool
|
||||||
|
internal/skill/ Skill-Discovery, Frontmatter-Parsing, Index
|
||||||
|
internal/ctxasm/ Context Assembler (System-Prompt-Aufbau)
|
||||||
|
internal/session/ Baum-Modell, Store, Compaction
|
||||||
|
internal/agent/ Loop, Turn, Steering, Cancellation
|
||||||
|
internal/tokens/ Schätzung + Kalibrierung gegen echte Usage
|
||||||
|
internal/ui/plain/ Renderer für Print-Modus
|
||||||
|
internal/ui/tui/ Bubbletea-TUI (Meilenstein 6)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Abhängigkeitsrichtung:** `ui` → `agent` → {`llm`, `tool`, `session`, `ctxasm`} →
|
||||||
|
`config`. Kein Paket unterhalb von `agent` importiert `ui`. Diese Regel ist die
|
||||||
|
wichtigste Strukturvorgabe des Projekts; sie wird per Test abgesichert (Abschnitt 7).
|
||||||
|
|
||||||
|
### 2.2 Datenfluss eines Turns
|
||||||
|
|
||||||
|
```
|
||||||
|
User-Input
|
||||||
|
└─> agent.Loop
|
||||||
|
├─> ctxasm.Assemble() → System-Blöcke + Tool-Definitionen
|
||||||
|
├─> session.PathToHead() → Message-Historie
|
||||||
|
├─> llm.Provider.Stream() → iter.Seq2[Event, error]
|
||||||
|
│ └─> UIEvent-Channel (Text-Deltas, Tool-Start, ...)
|
||||||
|
├─> tool.Registry.Run() → parallel, ctx-gebunden
|
||||||
|
│ └─> UIEvent-Channel (Tool-Output, Diffs)
|
||||||
|
└─> session.Append() → neue Nodes, Head verschieben
|
||||||
|
(Schleife bis StopReason != tool_use)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Rahmenbedingungen
|
||||||
|
|
||||||
|
**Go 1.24.** Untere Grenze ist 1.23 wegen `iter.Seq2` im Provider-Interface (4.3).
|
||||||
|
In der `go.mod` fixieren und in der `AGENTS.md` erwähnen — sonst werden
|
||||||
|
Channel-basierte Iteratoren generiert.
|
||||||
|
|
||||||
|
**Dependency-Allowlist.** Ein schlankes Tool mit 180 Modulen in der `go.sum` ist ein
|
||||||
|
Widerspruch in sich. Erlaubt sind:
|
||||||
|
|
||||||
|
| Zweck | Modul |
|
||||||
|
|---|---|
|
||||||
|
| TUI | `charmbracelet/bubbletea`, `lipgloss`, `bubbles`, `glamour`, `chroma` |
|
||||||
|
| MCP | `modelcontextprotocol/go-sdk` |
|
||||||
|
| Config | ein TOML-Parser (`BurntSushi/toml`) |
|
||||||
|
| Globbing | `bmatcuk/doublestar` |
|
||||||
|
| IDs | `oklog/ulid` |
|
||||||
|
|
||||||
|
Alles andere: stdlib. Insbesondere **kein** Viper und **kein** Cobra — `flag` aus der
|
||||||
|
stdlib deckt das Kommandoset ab. Neue Abhängigkeiten sind eine bewusste Entscheidung,
|
||||||
|
keine Nebenwirkung.
|
||||||
|
|
||||||
|
**Logging.** `log/slog` als JSON in eine Datei unter `~/.local/state/nub/log`
|
||||||
|
(bzw. `$XDG_STATE_HOME`), Level über `NUB_LOG`. **Niemals auf stdout** — das zerstört
|
||||||
|
den JSON-Modus und die TUI. `log.Printf` und `fmt.Println` sind unterhalb von
|
||||||
|
`internal/ui` verboten; derselbe Architektur-Test wie für E-04 prüft das mit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Entscheidungen
|
||||||
|
|
||||||
|
Format: Entscheidung → Begründung → Konsequenz.
|
||||||
|
|
||||||
|
### E-01 — Internes Message-Modell ist blockbasiert
|
||||||
|
|
||||||
|
Nicht OpenAIs flaches Schema (`tool_calls` am Assistant-Objekt, `role:"tool"` als
|
||||||
|
eigene Message), sondern Content-Blöcke.
|
||||||
|
|
||||||
|
*Begründung:* Anthropic und Gemini brauchen Blöcke. Blöcke → flach ist mechanisch,
|
||||||
|
flach → Blöcke ist verlustbehaftet. Thinking-Blöcke mit Signaturen haben im flachen
|
||||||
|
Modell keinen Platz.
|
||||||
|
|
||||||
|
*Konsequenz:* Der OpenAI-Adapter ist am Anfang aufwendiger als nötig. Akzeptiert.
|
||||||
|
|
||||||
|
### E-02 — Provider-SDK-Typen erreichen den Kern nicht
|
||||||
|
|
||||||
|
`openai-go` (oder direkt `net/http`) nur innerhalb von `internal/llm/openai`.
|
||||||
|
|
||||||
|
*Begründung:* Sonst zementiert man OpenAIs Modell im ganzen Projekt.
|
||||||
|
|
||||||
|
*Konsequenz:* ~400 Zeilen eigenes SSE-Parsing und Mapping. Empfehlung: direkt
|
||||||
|
`net/http` + `bufio.Scanner`, das SDK bringt bei diesem Zuschnitt wenig.
|
||||||
|
|
||||||
|
### E-03 — Session ist ein Baum, ab Tag 1
|
||||||
|
|
||||||
|
Nicht als Liste mit später aufgesetztem Branching.
|
||||||
|
|
||||||
|
*Begründung:* Rewind/Branch kostet initial fast nichts und ist nachträglich kaum
|
||||||
|
einzuziehen. Compaction wird dadurch ebenfalls sauber modellierbar (E-08).
|
||||||
|
|
||||||
|
*Konsequenz:* Jeder Zugriff auf „die Historie" geht über `PathToHead()`, nie über
|
||||||
|
einen Slice.
|
||||||
|
|
||||||
|
### E-04 — Alle Kernausgabe läuft über einen Event-Channel
|
||||||
|
|
||||||
|
`agent.Run` gibt `<-chan UIEvent` zurück. Kein `fmt.Println` unterhalb von `internal/ui`.
|
||||||
|
|
||||||
|
*Begründung:* Print-Modus, JSON-Modus und TUI fallen dadurch als drei dünne Consumer ab.
|
||||||
|
Das ist die Voraussetzung dafür, die TUI zuletzt bauen zu können.
|
||||||
|
|
||||||
|
*Konsequenz:* Disziplin nötig. Wird per Test abgesichert (9.4).
|
||||||
|
|
||||||
|
### E-05 — Tool-Ergebnisse sind zweigeteilt
|
||||||
|
|
||||||
|
`Result{ForModel string, ForUI any}`.
|
||||||
|
|
||||||
|
*Begründung:* Das Modell braucht bei `edit` ein knappes „ok, 3 Zeilen geändert", die UI
|
||||||
|
einen gerenderten Diff. Ein gemeinsamer String zwingt zu einem schlechten Kompromiss und
|
||||||
|
verbrennt Tokens.
|
||||||
|
|
||||||
|
*Konsequenz:* Jedes Tool definiert einen UI-Typ. Der Print-Renderer fällt bei unbekannten
|
||||||
|
Typen auf `ForModel` zurück.
|
||||||
|
|
||||||
|
### E-06 — MCP-Tools werden beim Start geladen, Liste dann eingefroren
|
||||||
|
|
||||||
|
Alle konfigurierten Server werden parallel beim Start verbunden.
|
||||||
|
|
||||||
|
*Begründung:* Tool-Definitionen sind Teil des gecachten Prompt-Prefix. Eine mitten in der
|
||||||
|
Session wachsende Tool-Liste zerstört den Cache in jeder Runde.
|
||||||
|
|
||||||
|
*Konsequenz:* Startup-Latenz. Gegenmaßnahme: Verbindungsaufbau läuft parallel, während
|
||||||
|
der Nutzer den ersten Prompt tippt. Connect-Timeout (Default 5s) → Server fällt raus,
|
||||||
|
Warnung in die Statuszeile, Loop startet trotzdem.
|
||||||
|
|
||||||
|
### E-07 — MCP-Allowlist pro Server ist Pflicht
|
||||||
|
|
||||||
|
Ohne `tools = [...]` werden alle Tools geladen, aber es wird gewarnt, sobald ein Server
|
||||||
|
mehr als 15 Tools beisteuert.
|
||||||
|
|
||||||
|
*Begründung:* Ein GitHub-MCP-Server kostet 20–40k Tokens permanent.
|
||||||
|
|
||||||
|
*Konsequenz:* Zusätzlich Server-Profile pro Projekt (`profiles.review = ["github"]`),
|
||||||
|
damit die Last nur dort anfällt, wo sie gebraucht wird.
|
||||||
|
|
||||||
|
### E-08 — Compaction ist ein Knoten im Baum
|
||||||
|
|
||||||
|
Ein `Summary`-Node mit `Replaces []NodeID`. `PathToHead()` läuft von Head Richtung Wurzel
|
||||||
|
und stoppt am ersten Summary-Node.
|
||||||
|
|
||||||
|
*Begründung:* Kein destruktives Löschen, das Original bleibt für Rewind erhalten, und die
|
||||||
|
Compaction selbst ist branchbar.
|
||||||
|
|
||||||
|
*Konsequenz:* Der Pfad-Walk braucht diese Sonderbehandlung. Trivial, aber muss getestet
|
||||||
|
sein.
|
||||||
|
|
||||||
|
### E-09 — Kein echter Tokenizer in v1
|
||||||
|
|
||||||
|
Schätzung über `len(text)/4` (bzw. `/3` für CJK), kalibriert gegen die tatsächliche
|
||||||
|
`usage` aus jeder API-Antwort.
|
||||||
|
|
||||||
|
*Begründung:* Tokenizer sind pro Modell verschieden und die exakte Zahl ist nur für den
|
||||||
|
Compaction-Trigger relevant. Ein 15%-Sicherheitsabstand ist billiger als die Abhängigkeit.
|
||||||
|
|
||||||
|
*Konsequenz:* Compaction-Schwelle konservativ (Default: 75% des Fensters).
|
||||||
|
|
||||||
|
### E-10 — Capabilities pro Endpoint, nicht pro Protokoll
|
||||||
|
|
||||||
|
„OpenAI-kompatibel" ist ein Sammelbegriff. Groq, Cerebras, vLLM, Ollama, LM Studio und
|
||||||
|
OpenRouter verhalten sich unterschiedlich.
|
||||||
|
|
||||||
|
*Konsequenz:* `Caps`-Struct (4.4), befüllt aus Modell-Registry + Config-Overrides.
|
||||||
|
|
||||||
|
### E-11 — Permissions: statisch konfiguriert, keine interaktiven Popups in v1
|
||||||
|
|
||||||
|
Modus pro Tool (`ask`/`auto`/`deny`) plus Pfad-Regeln. `ask` ist im Print-Modus ein
|
||||||
|
harter Fehler.
|
||||||
|
|
||||||
|
*Begründung:* Interaktive Rückfragen erzwingen eine UI-Abhängigkeit im Kern (verletzt
|
||||||
|
E-04) und sind im Print-Modus sinnlos.
|
||||||
|
|
||||||
|
*Konsequenz:* v1 setzt darauf, dass sicherheitskritische Nutzung im Container läuft.
|
||||||
|
Interaktive Bestätigung kann in Meilenstein 6 als UI-Event nachgerüstet werden.
|
||||||
|
|
||||||
|
### E-12 — Retry gehört in den Provider-Layer, nicht in den Loop
|
||||||
|
|
||||||
|
Wiederholungsversuche passieren unterhalb von `Provider.Stream`. Der Agent-Loop sieht
|
||||||
|
entweder einen erfolgreichen Stream oder einen endgültigen Fehler.
|
||||||
|
|
||||||
|
*Begründung:* Der Loop kennt keine HTTP-Semantik, und Retry-Logik an zwei Stellen führt
|
||||||
|
zu multiplikativen Wartezeiten.
|
||||||
|
|
||||||
|
*Konsequenz:* Ein Retry ist nur zulässig, solange noch keine Blöcke an den Consumer
|
||||||
|
emittiert wurden. Reißt der Stream mitten in einer Antwort ab, wird der Turn als Fehler
|
||||||
|
beendet — ein halb ausgegebener Text darf nicht doppelt erscheinen. Details in 5.9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Datenmodelle
|
||||||
|
|
||||||
|
### 4.1 Messages & Blöcke (`internal/llm`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Role string
|
||||||
|
const (
|
||||||
|
RoleUser Role = "user"
|
||||||
|
RoleAssistant Role = "assistant"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BlockKind string
|
||||||
|
const (
|
||||||
|
KindText BlockKind = "text"
|
||||||
|
KindThinking BlockKind = "thinking"
|
||||||
|
KindToolUse BlockKind = "tool_use"
|
||||||
|
KindToolResult BlockKind = "tool_result"
|
||||||
|
KindImage BlockKind = "image"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Block struct {
|
||||||
|
Kind BlockKind
|
||||||
|
|
||||||
|
// text / thinking
|
||||||
|
Text string
|
||||||
|
|
||||||
|
// tool_use
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Input json.RawMessage
|
||||||
|
|
||||||
|
// tool_result
|
||||||
|
ToolUseID string
|
||||||
|
Result []Block
|
||||||
|
IsError bool
|
||||||
|
|
||||||
|
// image
|
||||||
|
MediaType string
|
||||||
|
Data []byte
|
||||||
|
|
||||||
|
// Opaker Provider-Ballast (Anthropic-Signatures, reasoning-IDs).
|
||||||
|
// Muss unverändert zurückgesendet werden können.
|
||||||
|
Raw json.RawMessage
|
||||||
|
|
||||||
|
// Cache-Breakpoint. Adapter ohne Cache-Steuerung ignorieren das Feld.
|
||||||
|
CacheMark bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Role Role
|
||||||
|
Content []Block
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Model string
|
||||||
|
System []Block // eigenes Feld, NICHT als Message in der Historie
|
||||||
|
Messages []Message
|
||||||
|
Tools []ToolDef
|
||||||
|
MaxTokens int
|
||||||
|
Temp *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolDef struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Schema json.RawMessage // kanonisch: JSON Schema
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Wichtig:** `System` ist ein Request-Feld, keine Message. Sonst landet es im
|
||||||
|
Session-Baum und wird bei jedem Rewind mitgeschleppt.
|
||||||
|
|
||||||
|
### 4.2 Stream-Events (`internal/llm`)
|
||||||
|
|
||||||
|
Modelliert nach dem expliziteren Anthropic-Schema; der OpenAI-Adapter synthetisiert.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Event interface{ isEvent() }
|
||||||
|
|
||||||
|
type BlockStart struct { Index int; Block Block } // Block ohne Inhalt, nur Kind/ID/Name
|
||||||
|
type BlockDelta struct { Index int; Text string; PartialJSON string }
|
||||||
|
type BlockStop struct { Index int }
|
||||||
|
type Done struct { Stop StopReason; Usage Usage }
|
||||||
|
|
||||||
|
type StopReason string
|
||||||
|
const (
|
||||||
|
StopEnd StopReason = "end_turn"
|
||||||
|
StopToolUse StopReason = "tool_use"
|
||||||
|
StopMaxTokens StopReason = "max_tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Usage struct{ InputTokens, OutputTokens, CacheReadTokens, CacheWriteTokens int }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Provider-Interface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Provider interface {
|
||||||
|
Stream(ctx context.Context, req Request) (iter.Seq2[Event, error], error)
|
||||||
|
Caps() Caps
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Capabilities
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Caps struct {
|
||||||
|
ParallelToolCalls bool
|
||||||
|
UsageInStream bool
|
||||||
|
SystemRole string // "system" | "developer" | "" = eigenes Request-Feld
|
||||||
|
ExplicitCache bool
|
||||||
|
Reasoning bool
|
||||||
|
StrictSchemas bool
|
||||||
|
MaxContext int
|
||||||
|
SupportsImages bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Session-Baum (`internal/session`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type NodeKind string
|
||||||
|
const (
|
||||||
|
NodeMessage NodeKind = "message"
|
||||||
|
NodeSummary NodeKind = "summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Node struct {
|
||||||
|
ID string // ULID
|
||||||
|
ParentID string // "" = Wurzel
|
||||||
|
Kind NodeKind
|
||||||
|
Message llm.Message
|
||||||
|
Replaces []string // nur bei NodeSummary
|
||||||
|
Meta NodeMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeMeta struct {
|
||||||
|
Model string
|
||||||
|
Usage llm.Usage
|
||||||
|
CreatedAt time.Time
|
||||||
|
Label string
|
||||||
|
Bookmark bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
Nodes map[string]*Node
|
||||||
|
Head string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) PathToHead() []llm.Message // Wurzel→Head, stoppt am Summary
|
||||||
|
func (s *Session) Branch(from string) error // setzt Head auf einen früheren Knoten
|
||||||
|
func (s *Session) Append(n *Node) error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Persistenz:** Append-only JSONL unter `.nub/sessions/<id>.jsonl`, eine Zeile pro Node.
|
||||||
|
Der Head wird als eigener Record-Typ (`{"head":"..."}`) angehängt; beim Laden gewinnt der
|
||||||
|
letzte. Crash-sicher, branchbar, ohne DB-Abhängigkeit. SQLite bleibt eine spätere Option,
|
||||||
|
falls Suche über Sessions gebraucht wird.
|
||||||
|
|
||||||
|
**Jeder Record beginnt mit `"v": 1`.** Ein Feld, kostet jetzt nichts, und das Format wird
|
||||||
|
sich ändern. Beim Laden: unbekannte höhere Version → Fehler mit klarer Meldung, nicht
|
||||||
|
stillschweigend halb parsen. Dasselbe gilt für die Config (`version = 1`).
|
||||||
|
|
||||||
|
**Dateirechte und Git.** Sessiondateien enthalten Tool-Ausgaben und damit potenziell
|
||||||
|
Umgebungsvariablen, Tokens und Dateiinhalte. Deshalb:
|
||||||
|
|
||||||
|
- `.nub/` mit `0700`, Sessiondateien mit `0600`
|
||||||
|
- beim ersten Start `.nub/` nach `.git/info/exclude` schreiben, **nicht** in die
|
||||||
|
`.gitignore` — die wird committet und gehört dem Repo, nicht dem Tool
|
||||||
|
|
||||||
|
### 4.6 Tool-Interface (`internal/tool`)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Tool interface {
|
||||||
|
Name() string
|
||||||
|
Description() string
|
||||||
|
Schema() json.RawMessage
|
||||||
|
Run(ctx context.Context, input json.RawMessage, env Env) (Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
ForModel string // was in den tool_result-Block geht
|
||||||
|
ForUI any // strukturiert; z.B. DiffResult, FileResult
|
||||||
|
IsError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Env struct {
|
||||||
|
Cwd string
|
||||||
|
RepoRoot string
|
||||||
|
Emit func(UIEvent) // für streamende Tools (bash)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 Config (`internal/config`)
|
||||||
|
|
||||||
|
Schichtung, spätere gewinnt: Defaults → `~/.config/nub/config.toml` →
|
||||||
|
`<repo>/.nub/config.toml` → Umgebungsvariablen (`NUB_*`) → Flags.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[model]
|
||||||
|
default = "gpt-4o"
|
||||||
|
endpoint = "openai" # Verweis auf [[endpoint]]
|
||||||
|
|
||||||
|
[[endpoint]]
|
||||||
|
name = "openai"
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
api_key = "env:OPENAI_API_KEY"
|
||||||
|
|
||||||
|
[[endpoint]]
|
||||||
|
name = "local"
|
||||||
|
base_url = "http://localhost:11434/v1"
|
||||||
|
api_key = "none"
|
||||||
|
[endpoint.caps] # Overrides gegen die Registry
|
||||||
|
parallel_tool_calls = false
|
||||||
|
usage_in_stream = false
|
||||||
|
|
||||||
|
[context]
|
||||||
|
files = ["AGENTS.md", "REPOMAP.md"]
|
||||||
|
walk_up = true
|
||||||
|
max_tokens = 20000
|
||||||
|
|
||||||
|
[skills]
|
||||||
|
paths = ["~/.nub/skills", ".nub/skills"]
|
||||||
|
|
||||||
|
[[mcp]]
|
||||||
|
name = "github"
|
||||||
|
command = "gh-mcp-server"
|
||||||
|
args = []
|
||||||
|
tools = ["create_issue", "get_pull_request"]
|
||||||
|
|
||||||
|
[[mcp]]
|
||||||
|
name = "linear"
|
||||||
|
url = "https://mcp.linear.app/mcp"
|
||||||
|
|
||||||
|
[profiles]
|
||||||
|
review = ["github"]
|
||||||
|
default = []
|
||||||
|
|
||||||
|
[permissions]
|
||||||
|
read = "auto"
|
||||||
|
glob = "auto"
|
||||||
|
grep = "auto"
|
||||||
|
write = "auto"
|
||||||
|
edit = "auto"
|
||||||
|
bash = "auto"
|
||||||
|
deny_paths = [".git/**", "**/.env", "**/id_rsa*"]
|
||||||
|
deny_bash = ["rm -rf /", "git push --force*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Komponenten im Detail
|
||||||
|
|
||||||
|
### 5.1 OpenAI-Adapter
|
||||||
|
|
||||||
|
**Aufgaben:** Request-Mapping, SSE-Parsing, Tool-Call-Akkumulation, Event-Synthese.
|
||||||
|
|
||||||
|
Kritische Punkte:
|
||||||
|
|
||||||
|
1. **Tool-Call-Akkumulation.** Argumente kommen als String-Fragmente über
|
||||||
|
`choices[0].delta.tool_calls[i]`. `id` und `name` kommen typischerweise nur im ersten
|
||||||
|
Chunk; `index` ist die einzige verlässliche Korrelation. Zustand in
|
||||||
|
`map[int]*partialCall` halten. Tolerant sein: manche Server senden den kompletten
|
||||||
|
Call in einem Chunk, manche zählen `index` falsch, manche liefern `name` nachträglich.
|
||||||
|
2. **`tools` weglassen statt leer senden.** `"tools": []` führt bei mehreren
|
||||||
|
Implementierungen zu Fehlern.
|
||||||
|
3. **Usage.** Nur mit `stream_options: {include_usage: true}` und nur bei Endpoints mit
|
||||||
|
`UsageInStream`. Sonst über die Schätzung aus `internal/tokens`.
|
||||||
|
4. **System-Rolle.** Je nach `Caps.SystemRole` als `system`, `developer` oder gar nicht
|
||||||
|
(dann als erste User-Message mit Trennmarker).
|
||||||
|
5. **`reasoning_content`.** Nicht-standardisiertes Feld (DeepSeek u.a.). Wenn vorhanden
|
||||||
|
und `Caps.Reasoning`, als `KindThinking`-Block mappen.
|
||||||
|
6. **`[DONE]`-Sentinel** ist kein JSON — vor dem Unmarshal abfangen.
|
||||||
|
7. **Fehler mitten im Stream.** Manche Endpoints senden ein `error`-Objekt als
|
||||||
|
SSE-Event statt eines HTTP-Fehlers. Behandeln.
|
||||||
|
|
||||||
|
### 5.2 Agent-Loop
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (l *Loop) Run(ctx context.Context, in <-chan Input) <-chan UIEvent
|
||||||
|
```
|
||||||
|
|
||||||
|
Ablauf pro Turn:
|
||||||
|
|
||||||
|
1. Kontext zusammenbauen, Historie holen, Provider streamen.
|
||||||
|
2. Deltas als UIEvents emittieren, parallel Blöcke akkumulieren.
|
||||||
|
3. Bei `StopToolUse`: alle Tool-Calls parallel starten (Errgroup, abgeleiteter ctx).
|
||||||
|
4. Ergebnisse einsammeln, als `tool_result`-Blöcke in **eine** User-Message packen.
|
||||||
|
5. Zurück zu 1, bis `StopEnd`.
|
||||||
|
|
||||||
|
**Steering:** Der `in`-Channel wird auch während eines laufenden Turns gelesen.
|
||||||
|
Eine Steering-Nachricht wird nach Abschluss des *aktuell laufenden* Tools eingeschleust;
|
||||||
|
noch nicht gestartete Tools werden abgebrochen und bekommen einen `tool_result` mit
|
||||||
|
`IsError` und Hinweistext (das Protokoll verlangt für jeden `tool_use` ein Ergebnis —
|
||||||
|
das Auslassen ist ein häufiger Bug). Follow-up-Nachrichten werden gequeued.
|
||||||
|
|
||||||
|
**Cancellation:** Zwei Ebenen. Abbruch des Turns (Stream + Tools beenden, Session
|
||||||
|
konsistent lassen) und Programmende. Ein abgebrochener Turn muss einen gültigen
|
||||||
|
Baumzustand hinterlassen — d.h. entweder alle `tool_use`-Blöcke bekommen ein Ergebnis,
|
||||||
|
oder der unvollständige Assistant-Node wird nicht committet.
|
||||||
|
|
||||||
|
**Safety-Limits:** `max_turns` pro Anfrage (Default 50), `max_tool_time` pro Tool
|
||||||
|
(Default 120s), Wiederholungserkennung (identischer Tool-Call 3× hintereinander → Abbruch
|
||||||
|
mit Hinweis).
|
||||||
|
|
||||||
|
### 5.3 Eingebaute Tools
|
||||||
|
|
||||||
|
| Tool | Anmerkungen |
|
||||||
|
|---|---|
|
||||||
|
| `read` | Zeilennummern voranstellen, Offset/Limit-Parameter, Default-Limit ~2000 Zeilen, Binärerkennung |
|
||||||
|
| `write` | Vollständiger Dateiinhalt, Parent-Dirs anlegen, `ForUI` = Diff gegen alt |
|
||||||
|
| `edit` | Exakter String-Replace. Bei mehreren Treffern Fehler mit Trefferzahl, nicht raten. `replace_all`-Flag |
|
||||||
|
| `bash` | Timeout, **Prozessgruppen-Kill** (`Setpgid`, `syscall.Kill(-pgid)`), Output-Truncation bei ~30k Zeichen (Mitte kürzen, Anfang+Ende behalten), streamt über `Env.Emit` |
|
||||||
|
| `glob` | Doublestar-Pattern, `.gitignore` respektieren, nach mtime sortiert |
|
||||||
|
| `grep` | Regex, in-process (`regexp` + Walker) oder `rg` falls vorhanden. Kontext-Zeilen-Parameter |
|
||||||
|
| `todo` | In-Memory-Liste im Session-State. Messbarer Nutzen bei langen Tasks |
|
||||||
|
| `read_skill` | Lädt Skill-Body nach Name (5.5) |
|
||||||
|
|
||||||
|
Pfad-Regel für alle Datei-Tools: nur unterhalb von `RepoRoot`, Symlinks auflösen und
|
||||||
|
erneut prüfen, `deny_paths` anwenden.
|
||||||
|
|
||||||
|
### 5.4 Context Assembler (`internal/ctxasm`)
|
||||||
|
|
||||||
|
Baut `Request.System` in dieser Reihenfolge — von stabil nach volatil:
|
||||||
|
|
||||||
|
1. Basis-System-Prompt (eingebettet via `go:embed`, überschreibbar durch `SYSTEM.md`)
|
||||||
|
2. Umgebung: OS, Shell, Repo-Root, Branch — **statisch für die Session**, keine
|
||||||
|
Zeitstempel, kein Git-Status
|
||||||
|
3. Konfigurierte Kontext-Dateien (`AGENTS.md`, `REPOMAP.md`, …)
|
||||||
|
4. Skills-Index (nur Name + Description)
|
||||||
|
5. → `CacheMark = true` auf dem letzten Block ←
|
||||||
|
|
||||||
|
Alles Dynamische (Git-Status, Datum, Tool-Ausgaben) gehört in Messages, nie in `System`.
|
||||||
|
|
||||||
|
**Datei-Auflösung bei `walk_up = true`:** Von `cwd` bis `RepoRoot` hochlaufen, alle
|
||||||
|
Treffer sammeln, in der Reihenfolge Repo-Root → cwd einfügen (näher am
|
||||||
|
Arbeitsverzeichnis gewinnt inhaltlich, weil später gelesen). Herkunft jeder Datei
|
||||||
|
protokollieren und im `/context`-Kommando anzeigen.
|
||||||
|
|
||||||
|
**Budget:** `context.max_tokens` wird geprüft. Bei Überschreitung wird die *letzte* Datei
|
||||||
|
gekürzt und eine Warnung emittiert — nicht stillschweigend abgeschnitten.
|
||||||
|
|
||||||
|
**REPOMAP.md:** Es gibt ein `nub map`-Kommando, das eine Repomap generiert
|
||||||
|
(Verzeichnisbaum + exportierte Symbole; für Go über `go/ast`, sprachagnostisch als
|
||||||
|
Fallback der Dateikopf). Erzeugt wird ein Header mit dem Git-Commit, gegen den die Map
|
||||||
|
gebaut wurde. Beim Start wird gewarnt, wenn der aktuelle HEAD abweicht — eine falsche
|
||||||
|
Repomap ist schlechter als keine.
|
||||||
|
|
||||||
|
### 5.5 Skills (`internal/skill`)
|
||||||
|
|
||||||
|
Struktur:
|
||||||
|
|
||||||
|
```
|
||||||
|
.nub/skills/
|
||||||
|
refactoring/
|
||||||
|
SKILL.md
|
||||||
|
checklist.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`SKILL.md` mit YAML-Frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: refactoring
|
||||||
|
description: Vorgehen für größere Refactorings in diesem Repo. Nutzen, wenn mehr als 3 Dateien betroffen sind.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactoring
|
||||||
|
|
||||||
|
...Body...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanik:** Beim Start werden alle Skills gefunden, aber nur `name` + `description` in
|
||||||
|
den Prompt geschrieben (~30 Tokens pro Skill). Das Tool `read_skill(name)` liefert den
|
||||||
|
Body. Weitere Dateien im Skill-Verzeichnis findet das Modell selbst über `read`.
|
||||||
|
|
||||||
|
Das ist der Kostenunterschied zu MCP: ein Skill kostet 30 Tokens bis zur Nutzung, ein
|
||||||
|
MCP-Tool 300–800 permanent.
|
||||||
|
|
||||||
|
### 5.6 MCP-Client (`internal/mcpc`)
|
||||||
|
|
||||||
|
Basis: `github.com/modelcontextprotocol/go-sdk` (v1.5+, stabil, stdio + Streamable HTTP,
|
||||||
|
Client-OAuth).
|
||||||
|
|
||||||
|
- Verbindungsaufbau parallel beim Start, Timeout 5s pro Server (E-06)
|
||||||
|
- Tool-Namen werden mit `<server>__` prefixt
|
||||||
|
- Allowlist aus der Config filtert vor der Registrierung
|
||||||
|
- Bridge: MCP-Tool → `tool.Tool`. `ForModel` = textuelle Content-Blöcke,
|
||||||
|
`ForUI` = strukturierter Content
|
||||||
|
- Server-Ausfall zur Laufzeit: Tool-Aufruf liefert `IsError`, kein Panic, kein
|
||||||
|
Loop-Abbruch
|
||||||
|
- Reconnect wird in v1 **nicht** versucht (würde die Tool-Liste ändern, siehe E-06)
|
||||||
|
|
||||||
|
### 5.7 Compaction
|
||||||
|
|
||||||
|
Trigger: geschätzte Prompt-Tokens > `compact_at` (Default 75%) von `Caps.MaxContext`.
|
||||||
|
|
||||||
|
Ablauf:
|
||||||
|
|
||||||
|
1. Ältere Hälfte des Pfads auswählen; die letzten N Turns (Default 4) bleiben immer roh.
|
||||||
|
2. Zusammenfassung mit separatem Prompt erzeugen (gleiches Modell, ggf. konfigurierbar
|
||||||
|
günstigeres).
|
||||||
|
3. `NodeSummary` mit `Replaces` = IDs der zusammengefassten Knoten anhängen.
|
||||||
|
4. Head verschieben.
|
||||||
|
|
||||||
|
Die Zusammenfassung muss enthalten: bearbeitete Dateien, getroffene Entscheidungen,
|
||||||
|
offene Aufgaben, wiederherstellbarer Zustand. Nicht: Prosa über den Gesprächsverlauf.
|
||||||
|
|
||||||
|
### 5.8 UI
|
||||||
|
|
||||||
|
**Print-Modus (`-p`):** Textdeltas nach stdout, Tool-Aktivität nach stderr, Exit-Code
|
||||||
|
≠ 0 bei Fehler. `--mode json` gibt stattdessen einen NDJSON-Event-Stream aus — das ist
|
||||||
|
die Integrationsschnittstelle.
|
||||||
|
|
||||||
|
**TUI (Meilenstein 6):** Bubbletea + Lipgloss + Bubbles, `glamour` für Markdown,
|
||||||
|
`chroma` für Syntax-Highlighting. Komponenten: Message-Liste (virtualisiert), Editor mit
|
||||||
|
Historie, Statuszeile (Modell, Tokens, Kosten), Modal-Stack für `/model` und `/tree`,
|
||||||
|
Diff-Overlay.
|
||||||
|
|
||||||
|
Kommandos: `/model`, `/tree`, `/branch`, `/compact`, `/context`, `/mcp`, `/skills`,
|
||||||
|
`/export`, `/clear`.
|
||||||
|
|
||||||
|
### 5.9 Fehlerbehandlung & Retry
|
||||||
|
|
||||||
|
Angesiedelt im Provider-Layer (E-12). Die Klassifikation ist wichtiger als der Backoff:
|
||||||
|
|
||||||
|
| Situation | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| 429 mit `Retry-After` | Header respektieren, bis 3 Versuche |
|
||||||
|
| 429 ohne Header | Exponentiell 1s/2s/4s + Jitter |
|
||||||
|
| 500, 502, 503, 504 | Exponentiell, bis 3 Versuche |
|
||||||
|
| Netzwerk-/Timeout-Fehler vor dem ersten Byte | Retry |
|
||||||
|
| Stream reißt nach emittierten Blöcken ab | **Kein Retry** — Turn als Fehler beenden |
|
||||||
|
| Context-Length überschritten | **Kein Retry** — Compaction auslösen, dann einmalig neu |
|
||||||
|
| 400 (ungültiges Schema o.ä.) | Kein Retry, Fehler durchreichen |
|
||||||
|
| 401/403 | Kein Retry, Hinweis auf Konfiguration |
|
||||||
|
|
||||||
|
Context-Length-Fehler zuverlässig zu erkennen ist unschön, weil jeder Endpoint sie anders
|
||||||
|
formuliert. Pragmatisch: HTTP 400 plus Substring-Match auf `context length`,
|
||||||
|
`context_length_exceeded`, `too many tokens`, `maximum context`. Bei Unsicherheit lieber
|
||||||
|
als normalen Fehler behandeln, als in eine Compaction-Schleife zu laufen — deshalb nach
|
||||||
|
einer ausgelösten Compaction **genau ein** weiterer Versuch, danach Abbruch.
|
||||||
|
|
||||||
|
Alle Retries werden über `slog` protokolliert (2.3) und als UIEvent gemeldet, damit im
|
||||||
|
interaktiven Betrieb sichtbar ist, warum es hängt.
|
||||||
|
|
||||||
|
**Exit-Codes im Print-Modus:** 0 Erfolg, 1 Agent-/Provider-Fehler, 2 Konfigurationsfehler,
|
||||||
|
3 Abbruch durch Nutzer, 4 Limit erreicht (`max_turns`, Budget).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ablaufplan
|
||||||
|
|
||||||
|
Jeder Meilenstein ist eigenständig lauffähig und testbar.
|
||||||
|
|
||||||
|
### M1 — Kern-Loop (Grundlage)
|
||||||
|
|
||||||
|
Umfang: `internal/llm` (Typen + Interface), OpenAI-Adapter mit SSE und
|
||||||
|
Tool-Call-Akkumulation, `internal/tool` mit `read`/`write`/`edit`/`bash`/`glob`/`grep`,
|
||||||
|
minimaler Loop, Ausgabe über simplen stdout-Renderer, Konfiguration nur über
|
||||||
|
Umgebungsvariablen.
|
||||||
|
|
||||||
|
*Fertig, wenn:* Ein mehrstufiger Task („lies X, ändere Y, führe die Tests aus") läuft
|
||||||
|
gegen einen echten Endpoint durch. `Ctrl+C` bricht mitten in einem `bash`-Aufruf sauber
|
||||||
|
ab und tötet die Prozessgruppe. Golden-File-Tests für den Adapter grün.
|
||||||
|
|
||||||
|
### M2 — Session-Baum & Persistenz
|
||||||
|
|
||||||
|
Umfang: Baum-Modell, JSONL-Store, `PathToHead`, `nub resume`, `nub sessions`,
|
||||||
|
`/branch`-Äquivalent als CLI-Flag.
|
||||||
|
|
||||||
|
*Fertig, wenn:* Eine Session lässt sich fortsetzen; ein Rewind auf einen früheren Knoten
|
||||||
|
und Weiterarbeit erzeugen einen zweiten Ast, beide bleiben ladbar.
|
||||||
|
|
||||||
|
### M3 — Config & Kontext
|
||||||
|
|
||||||
|
Umfang: Layered Config (TOML), Endpoint-/Caps-Auflösung, Context Assembler mit
|
||||||
|
`AGENTS.md`/`REPOMAP.md`/frei konfigurierten Dateien, `walk_up`, Budget-Prüfung,
|
||||||
|
`nub map`.
|
||||||
|
|
||||||
|
*Fertig, wenn:* `/context` zeigt jede geladene Datei mit Herkunft und Token-Kosten.
|
||||||
|
Budget-Überschreitung erzeugt eine sichtbare Warnung.
|
||||||
|
|
||||||
|
### M4 — Tokens & Compaction
|
||||||
|
|
||||||
|
Umfang: Schätzung + Kalibrierung, `todo`-Tool, Auto-Compaction als Summary-Node,
|
||||||
|
`/compact` manuell.
|
||||||
|
|
||||||
|
*Fertig, wenn:* Eine künstlich verlängerte Session compactet automatisch, läuft weiter,
|
||||||
|
und ein Rewind auf einen Knoten *vor* der Compaction funktioniert weiterhin.
|
||||||
|
|
||||||
|
### M5 — Skills & MCP
|
||||||
|
|
||||||
|
Umfang: Skill-Discovery + Index + `read_skill`, MCP-Client mit Allowlist, Profile,
|
||||||
|
`/skills` und `/mcp`.
|
||||||
|
|
||||||
|
*Fertig, wenn:* Ein Skill wird nachweislich erst bei Bedarf geladen (Token-Delta
|
||||||
|
messbar). Ein absichtlich kaputter MCP-Server verhindert den Start nicht.
|
||||||
|
|
||||||
|
### M6 — TUI
|
||||||
|
|
||||||
|
Umfang: Bubbletea-Oberfläche, Streaming-Rendering, Modals, Diff-Overlay, Steering
|
||||||
|
(`Enter`) und Follow-up (`Alt+Enter`), Kommandos.
|
||||||
|
|
||||||
|
*Fertig, wenn:* Die TUI ist ein reiner Consumer des Event-Channels; das Entfernen von
|
||||||
|
`internal/ui/tui` lässt den Rest kompilieren und `-p` weiterhin funktionieren.
|
||||||
|
|
||||||
|
### M7 — Politur
|
||||||
|
|
||||||
|
Prompt-Cache gegen echte `usage`-Zahlen verifizieren (Cache-Read-Tokens müssen ab dem
|
||||||
|
zweiten Turn deutlich steigen), Token-Anzeige in der Statuszeile, `/export`, README.
|
||||||
|
Distribution bleibt `go install` (Abschnitt 9).
|
||||||
|
|
||||||
|
**Reihenfolge-Begründung:** Die TUI ist der teuerste und der einzige vollständig
|
||||||
|
wegwerfbare Teil. Sie kommt zuletzt, damit die Kern-Schnittstellen vorher unter echter
|
||||||
|
Nutzung validiert werden — M1–M5 sind über `-p` bereits produktiv nutzbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Teststrategie
|
||||||
|
|
||||||
|
**Golden Files für Provider.** Echte SSE-Streams aufzeichnen (inklusive der kaputten von
|
||||||
|
lokalen Servern) und als Fixtures unter `internal/llm/openai/testdata/` ablegen. Der
|
||||||
|
Adapter-Test spielt sie ab und vergleicht die erzeugte Event-Folge. Beim Hinzufügen eines
|
||||||
|
zweiten Providers läuft dieselbe Suite gegen den neuen Adapter — dort zeigt sich sofort,
|
||||||
|
wo das interne Modell leckt. **Das ist der Mechanismus, der „später erweiterbar"
|
||||||
|
tatsächlich einlöst, nicht das Interface allein.**
|
||||||
|
|
||||||
|
**Fake-Provider für den Loop.** Ein `Provider`, der skriptierte Event-Folgen abspielt.
|
||||||
|
Damit sind Multi-Turn-Verhalten, Steering, Cancellation und Fehlerpfade ohne Netzwerk
|
||||||
|
testbar.
|
||||||
|
|
||||||
|
**Tools.** In `t.TempDir()` mit einem Fixture-Repo. Besonders: `edit` mit mehrdeutigem
|
||||||
|
Treffer, `bash` mit Timeout und Kindprozessen, Pfad-Escape-Versuche (`../`, Symlinks).
|
||||||
|
|
||||||
|
**Architektur-Test.** Ein Test, der die Import-Graphen prüft: kein Paket unterhalb von
|
||||||
|
`internal/agent` darf `internal/ui` importieren. Sichert E-04 mechanisch ab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Bekannte Fallen
|
||||||
|
|
||||||
|
- **Fehlende `tool_result`-Blöcke.** Jeder `tool_use` braucht ein Ergebnis, auch bei
|
||||||
|
Abbruch. Sonst lehnt die API den nächsten Request ab.
|
||||||
|
- **Cache-Busting durch Dynamik im System-Prompt.** Zeitstempel, Git-Status,
|
||||||
|
Sessionlänge — nichts davon gehört nach oben.
|
||||||
|
- **Wachsende Tool-Liste.** Siehe E-06.
|
||||||
|
- **Verwaiste Kindprozesse.** `bash` ohne Prozessgruppen-Kill hinterlässt Zombies, die den
|
||||||
|
Terminal-Zustand ruinieren.
|
||||||
|
- **Provider-SDK-Typen im Kern.** Der schleichendste Fehler; einmal drin, überall drin.
|
||||||
|
- **Zu frühe TUI.** Bindet Wochen und zementiert Annahmen über die Ausgabe.
|
||||||
|
- **Session als Slice.** Nachträgliches Branching ist ein Rewrite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Festlegungen
|
||||||
|
|
||||||
|
Die zuvor offenen Punkte sind für den aktuellen Zuschnitt (persönliches Werkzeug, kleiner
|
||||||
|
Nutzerkreis) entschieden. Kein Punkt davon ist irreversibel, aber ein Agent soll sie nicht
|
||||||
|
je Session neu beantworten.
|
||||||
|
|
||||||
|
| Punkt | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| Session-Ablage | Repo-lokal unter `.nub/sessions`. Portabel, kein globaler Index nötig |
|
||||||
|
| Auth | Datei mit `0600` unter `$XDG_CONFIG_HOME/nub`. Kein Keyring |
|
||||||
|
| Kosten-Tracking | Entfällt. Nur Token-Zahlen in der Statuszeile |
|
||||||
|
| Permissions | Container-Haltung aus E-11. Keine interaktive Bestätigung, auch nicht in M6 |
|
||||||
|
| Plattformen | Linux und macOS. Windows nur über WSL, kein PowerShell-Pfad |
|
||||||
|
| Telemetrie | Keine |
|
||||||
|
| Distribution | `go install`. Kein Goreleaser, kein Self-Update |
|
||||||
|
|
||||||
|
**Bewusst zurückgestellt** (nicht vergessen, nur nicht jetzt): Eval-Suite gegen ein
|
||||||
|
Fixture-Repo, `nub init`, System-Prompt-Hash in `NodeMeta`, Lizenz- und
|
||||||
|
Contribution-Fragen. Der erste Punkt wird relevant, sobald du merkst, dass du viel am
|
||||||
|
System-Prompt oder an Tool-Beschreibungen änderst und nicht mehr beurteilen kannst, ob es
|
||||||
|
besser wird.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Umsetzung mit einem Coding-Agenten
|
||||||
|
|
||||||
|
1. **Dieses Dokument als `docs/KONZEPT.md` ins Repo.** Dazu eine kurze `AGENTS.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# nub
|
||||||
|
Schlanker CLI-Coding-Agent in Go. Konzept: docs/KONZEPT.md
|
||||||
|
|
||||||
|
- Go 1.24. `iter.Seq2` wird verwendet — keine Channel-Iteratoren.
|
||||||
|
- Definition of Done: `go test ./...` und `go vet ./...` grün.
|
||||||
|
- Kein Paket unterhalb von internal/agent importiert internal/ui (E-04).
|
||||||
|
- Keine Ausgabe auf stdout außerhalb von internal/ui. Logging via slog in
|
||||||
|
eine Datei (Abschnitt 2.3).
|
||||||
|
- Neue Abhängigkeiten nur aus der Allowlist in Abschnitt 2.3. Kein Cobra,
|
||||||
|
kein Viper.
|
||||||
|
- Festlegungen aus Abschnitt 9 sind entschieden — nicht neu verhandeln.
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Ein Meilenstein pro Session.** Nicht mehr. Am Anfang jeder Session: Abschnitt 3
|
||||||
|
(Entscheidungen), 4 (Datenmodelle) und den jeweiligen Meilenstein aus Abschnitt 6
|
||||||
|
in den Kontext geben.
|
||||||
|
3. **Abschnitt 4 wörtlich übernehmen lassen.** Die Typen sind die Vertragsgrundlage
|
||||||
|
zwischen den Meilensteinen; Abweichungen dort kosten später am meisten.
|
||||||
|
4. **Golden Files zuerst.** In M1 vor dem Adapter die Fixtures aufzeichnen lassen (ein
|
||||||
|
kleines Skript, das einen echten Stream mitschneidet). Ohne sie ist jede spätere
|
||||||
|
Änderung am Adapter Blindflug.
|
||||||
|
5. **Nach jedem Meilenstein die Akzeptanzkriterien manuell prüfen**, nicht nur die Tests.
|
||||||
|
Besonders bei M1 (Abbruchverhalten) und M4 (Compaction + Rewind).
|
||||||
|
6. **Wenn eine Frage auftaucht, die Abschnitt 3 oder 9 nicht beantwortet**, selbst
|
||||||
|
entscheiden und dort ergänzen — nicht den Agenten implizit entscheiden lassen. Sonst
|
||||||
|
stehen dieselbe Frage drei Mal unterschiedlich beantwortet im Code.
|
||||||
321
docs/TESTING.md
Normal file
321
docs/TESTING.md
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
Für Endnutzer-Doku siehe die [README](../README.md) und
|
||||||
|
[FEATURES.md](FEATURES.md). Diese Seite ist für die Weiterentwicklung von
|
||||||
|
`nub` selbst: automatisierte Tests, manuelle Testrezepte pro Feature, und
|
||||||
|
was aktuell (noch) nicht abgedeckt ist.
|
||||||
|
|
||||||
|
## Automatisierte Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Deckt ab: SSE-Fragmentierung/Tool-Call-Akkumulation (Golden File), Agent-Loop
|
||||||
|
gegen einen Fake-Provider (Multi-Turn, Tool-Roundtrip, Stream-Fehler),
|
||||||
|
Datei-Tools inkl. Pfad-Escape via `../` und Symlink, `bash`-Timeout mit
|
||||||
|
Prozessgruppen-Kill, Architektur-Test für E-04 (kein Kern-Paket importiert
|
||||||
|
`internal/ui`), Session-Baum (Resume-Roundtrip, Rewind+Branch, Dateirechte,
|
||||||
|
Versions-Fehlerfall), Config-Layering-Präzedenz, Context Assembler
|
||||||
|
(walk_up-Reihenfolge, Budget-Truncation+Warnung, Repomap-Staleness),
|
||||||
|
Auto-Compaction (Trigger, Turn-Grenzen-Logik, Rewind auf einen Knoten vor
|
||||||
|
der Compaction), Token-Kalibrierung, todo-Tool, Skill-Discovery (Index ohne
|
||||||
|
Body, Präzedenz projekt-vor-global), MCP-Bridge (echter In-Process-Server-
|
||||||
|
Roundtrip über `mcp.NewInMemoryTransports`, Allowlist, Laufzeitfehler ohne
|
||||||
|
Panic), Profile-Filterung, TUI (Slash-Command-Dispatch ohne Backend-Kontakt,
|
||||||
|
Steering- vs. Follow-up-Queue-Semantik, Tree-/Sessions-Modal-Auswahl inkl.
|
||||||
|
Branch/Wechsel/Löschen mit Rückfrage, Diff-/Fehler-Rendering, /help-
|
||||||
|
Formatierung, Skills-als-Slash-Commands, Zeilenumbruch, historische
|
||||||
|
Token-Wiederherstellung), Permission-Policy (Modus-Auflösung, deny_paths/
|
||||||
|
deny_bash überstimmen auto/ask), Agent-Loop-Permission-Integration
|
||||||
|
(deny/ask blockieren die Tool-Ausführung nachweislich, nil-Hook -> harter
|
||||||
|
Fehler), TUI-Rückfrage-Modal (blockiert bis zur Antwort, Kontextabbruch
|
||||||
|
löst sauber auf), Scaffold/`nub init` (legt an, überschreibt bestehende
|
||||||
|
Dateien nie, generierte Config parst und rundtripped korrekt, Git-Exclude
|
||||||
|
wird gesetzt), `question`-Tool (nil-Hook -> harter Fehler, leere Frage
|
||||||
|
abgelehnt, Antwort-Roundtrip inkl. Options, Ask-Fehler landet als
|
||||||
|
`IsError`), TUI-Rückfrage-Block für `question` (blockiert bis zur Antwort,
|
||||||
|
Kontextabbruch löst sauber auf, Eingabe während offener Frage wird als
|
||||||
|
Antwort statt als Chat-Nachricht/Slash-Command behandelt, kein Overlay).
|
||||||
|
|
||||||
|
**Architektur-Nachweis M6** (TUI ist ein reiner Consumer, entfernbar):
|
||||||
|
`TestArchitecture_NoUIImportBelowAgent` (`internal/agent/arch_test.go`)
|
||||||
|
verbietet jedem Kern-Paket den Import von `internal/ui/*`, also auch
|
||||||
|
`internal/ui/tui` — automatisiert bei jedem Testlauf geprüft. Zusätzlich
|
||||||
|
manuell verifiziert: `internal/ui/tui` entfernt, `go build ./internal/...`
|
||||||
|
bleibt grün, `cmd/nub` baut nach Entfernen der TUI-Verzweigung ebenfalls,
|
||||||
|
`-p` verhält sich danach identisch.
|
||||||
|
|
||||||
|
## Manuelle Testrezepte
|
||||||
|
|
||||||
|
Die Tests unten gehen von `nub -p` aus (die beschriebenen `→`/`←`-Zeilen auf
|
||||||
|
stderr sind Print-Modus-Ausgabe). Dieselben Szenarien lassen sich genauso in
|
||||||
|
der TUI (`nub`, ohne `-p`) durchspielen — dort erscheinen Tool-Aufrufe direkt
|
||||||
|
im Transkript statt auf stderr, und Ctrl+C/Rewind laufen über die im README
|
||||||
|
beschriebenen Tastenkombinationen/Kommandos.
|
||||||
|
|
||||||
|
Aus dem Konzept, Abschnitt 6:
|
||||||
|
|
||||||
|
> Ein mehrstufiger Task ("lies X, ändere Y, führe die Tests aus") läuft gegen
|
||||||
|
> einen echten Endpoint durch. `Ctrl+C` bricht mitten in einem `bash`-Aufruf
|
||||||
|
> sauber ab und tötet die Prozessgruppe.
|
||||||
|
|
||||||
|
### Test 1 — Mehrstufiger Task
|
||||||
|
|
||||||
|
In einem kleinen Testrepo (z.B. mit ein paar `.go`-Dateien und `go test`
|
||||||
|
lauffähig):
|
||||||
|
|
||||||
|
```
|
||||||
|
Lies main.go, füge einen Kommentar über der main-Funktion hinzu der "entry point" sagt, und führe danach go build ./... aus um zu prüfen dass es noch kompiliert.
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: Das Modell ruft nacheinander `read`, `edit` und `bash` auf (sichtbar
|
||||||
|
an den `→`/`←`-Zeilen auf stderr), am Ende eine Textantwort mit dem Ergebnis.
|
||||||
|
Danach `Ctrl+D`, dann `nub sessions` — die eben gelaufene Session sollte mit
|
||||||
|
korrekter Node-Anzahl auftauchen. `nub resume <id>` und eine weitere Nachricht
|
||||||
|
schicken sollte den Kontext (z.B. den Dateinamen) noch kennen, ohne dass du
|
||||||
|
ihn wiederholst.
|
||||||
|
|
||||||
|
### Test 1b — Rewind/Branch
|
||||||
|
|
||||||
|
Nach Test 1: die Node-ID kurz vor der letzten Antwort ermitteln (siehe
|
||||||
|
README, Abschnitt „Sessions") und damit fortsetzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub resume <session-id> --branch <node-id>
|
||||||
|
Mach stattdessen etwas anderes: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: `nub` schreibt ab hier einen zweiten Ast. `cat
|
||||||
|
.nub/sessions/<id>.jsonl | wc -l` zeigt, dass die alten Zeilen erhalten
|
||||||
|
bleiben (nur neue kommen dazu). Mit dem `--branch`-Trick lässt sich sowohl
|
||||||
|
der alte als auch der neue Ast wieder aufrufen.
|
||||||
|
|
||||||
|
### Test 1c — Auto-Compaction
|
||||||
|
|
||||||
|
Eine echte Session lang genug laufen zu lassen, um 75% des Modell-Fensters
|
||||||
|
zu füllen, dauert. Stattdessen `max_context` künstlich klein setzen, um den
|
||||||
|
Trigger nach wenigen Nachrichten auszulösen — in `.nub/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
version = 1
|
||||||
|
[[endpoint]]
|
||||||
|
name = "openai"
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
api_key = "env:OPENAI_API_KEY"
|
||||||
|
[endpoint.caps]
|
||||||
|
max_context = 500
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann `nub` starten und 4-5 kurze Nachrichten hintereinander schicken.
|
||||||
|
Erwartet: Nach ein paar Nachrichten erscheint auf stderr
|
||||||
|
`compacted N nodes into a M-token summary`. `nub` läuft danach normal
|
||||||
|
weiter (das Modell kennt den zusammengefassten Kontext noch grob).
|
||||||
|
|
||||||
|
Dann `Ctrl+D`, Node-ID der allerersten Nachricht ermitteln (siehe
|
||||||
|
README, „Sessions") und:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub resume <session-id> --branch <node-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: Das funktioniert weiterhin, obwohl dieser Knoten längst
|
||||||
|
wegkompaktiert wurde — die Original-Nodes bleiben im Baum erhalten.
|
||||||
|
`.nub/config.toml` danach wieder auf einen realistischen `max_context`
|
||||||
|
zurücksetzen (oder den Override entfernen).
|
||||||
|
|
||||||
|
### Test 2 — Ctrl+C mitten in bash
|
||||||
|
|
||||||
|
```
|
||||||
|
Führe "sleep 30" aus.
|
||||||
|
```
|
||||||
|
|
||||||
|
Während `→ bash {...}` läuft: `Ctrl+C` drücken. Erwartet:
|
||||||
|
|
||||||
|
- `nub` beendet sich zügig (nicht erst nach 30s)
|
||||||
|
- stderr zeigt `aborted`, Exit-Code ist `3`
|
||||||
|
- Kein Zombie-Prozess bleibt übrig — prüfen mit `ps aux | grep sleep`
|
||||||
|
direkt danach, sollte leer sein
|
||||||
|
|
||||||
|
### Test 3 — Mehrdeutiger edit-Treffer
|
||||||
|
|
||||||
|
```
|
||||||
|
Ersetze in main.go den Text "return" durch "return " (falls "return" mehrfach vorkommt)
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: Falls `return` mehrfach in der Datei steht, meldet das `edit`-Tool
|
||||||
|
einen Fehler mit Trefferzahl statt zu raten — sichtbar als
|
||||||
|
`← edit [error]` auf stderr, das Modell sollte daraufhin entweder
|
||||||
|
`replace_all` nutzen oder den `old_string` präzisieren.
|
||||||
|
|
||||||
|
### Test 4 — Unbekanntes Tool / Fehlerpfad
|
||||||
|
|
||||||
|
Kein manueller Test nötig, ist per Fake-Provider automatisiert abgedeckt
|
||||||
|
(`go test ./...`).
|
||||||
|
|
||||||
|
### Test 5 — Kaputter MCP-Server verhindert den Start nicht
|
||||||
|
|
||||||
|
```toml
|
||||||
|
version = 1
|
||||||
|
[[mcp]]
|
||||||
|
name = "broken"
|
||||||
|
command = "this-binary-does-not-exist"
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: `warning: mcp server "broken": ...` auf stderr, Exit-Code 0, kein
|
||||||
|
Absturz. Danach echtes `nub` starten (mit derselben Config) — muss trotz des
|
||||||
|
kaputten Servers normal hochkommen.
|
||||||
|
|
||||||
|
### Test 6 — Skill wird erst bei Bedarf geladen
|
||||||
|
|
||||||
|
Ein Skill wie im README (Abschnitt „Skills") anlegen, dann im laufenden
|
||||||
|
`nub` fragen: `Welche Skills hast du verfügbar, und was steht in
|
||||||
|
"refactoring"?` — erwartet: Das Modell nennt den Skill aus dem Index, ruft
|
||||||
|
danach sichtbar `→ read_skill {"name":"refactoring"}` auf, um den Body zu
|
||||||
|
laden — nicht schon beim Start.
|
||||||
|
|
||||||
|
### Test 7 — Steering vs. Follow-up, Rewind über die TUI
|
||||||
|
|
||||||
|
Nur in der TUI (`nub`, ohne `-p`):
|
||||||
|
|
||||||
|
1. Einen Task starten, der mehrere Tool-Aufrufe braucht (wie Test 1).
|
||||||
|
Während `running: ...` in der Statuszeile steht, eine neue Nachricht
|
||||||
|
eingeben und **Enter** drücken (Steering). Erwartet: Sie wird eingeschleust,
|
||||||
|
sobald der laufende Tool-Aufruf fertig ist, ohne dass du warten musst.
|
||||||
|
2. Denselben Versuch mit **Alt+Enter**. Erwartet: Statuszeile zeigt
|
||||||
|
`queued: 1`, die Nachricht erscheint im Transkript mit `⏳`-Präfix und
|
||||||
|
wird erst gesendet, wenn der Turn ganz fertig ist (kein `tool_use` mehr
|
||||||
|
offen).
|
||||||
|
3. `/tree` (oder `/branch`) öffnen — Liste aller Nodes erscheint, aktueller
|
||||||
|
Head markiert. Mit ↑/↓ einen früheren Knoten wählen, Enter drücken.
|
||||||
|
Erwartet: Head springt zurück, eine neue Nachricht hängt sich dort an
|
||||||
|
(zweiter Ast, wie in Test 1b, nur ohne CLI-Flag).
|
||||||
|
4. `/export` ausführen, geschriebene Datei öffnen — Transkript sollte
|
||||||
|
vollständig als Markdown lesbar sein, inklusive Diffs.
|
||||||
|
5. `/help` ausführen — jedes Kommando sollte in einer eigenen Zeile mit
|
||||||
|
kurzer Beschreibung erscheinen, nicht als ein langer Fließtext.
|
||||||
|
|
||||||
|
### Test 8 — Prompt-Cache-Verifikation (M7)
|
||||||
|
|
||||||
|
Nur mit echtem OpenAI-Endpoint sinnvoll (lokale Server cachen meist nicht).
|
||||||
|
OpenAIs automatisches Prompt-Caching greift erst ab **>1024 Tokens** stabilem
|
||||||
|
Prefix — ein `AGENTS.md`/`REPOMAP.md` im Kontext hilft, diese Schwelle
|
||||||
|
zuverlässig zu überschreiten.
|
||||||
|
|
||||||
|
1. In einem Repo mit ein paar hundert Zeilen Kontext (`nub map` vorher
|
||||||
|
laufen lassen, damit `REPOMAP.md` existiert) `nub -p` starten.
|
||||||
|
2. Zwei, drei kurze Nachrichten nacheinander schicken (z.B. "Was macht
|
||||||
|
go.mod?", dann "Und main.go?").
|
||||||
|
3. Auf stderr nach jedem Turn erscheint jetzt
|
||||||
|
`usage: input=X output=Y cache_read=Z`.
|
||||||
|
|
||||||
|
Erwartet: Beim **ersten** Turn ist `cache_read` meist `0` (nichts zum
|
||||||
|
Cachen vorhanden). Ab dem **zweiten** Turn sollte `cache_read` deutlich
|
||||||
|
über 0 liegen und nah an `input` herankommen, weil System-Prompt + bisherige
|
||||||
|
Historie als stabiler Prefix erneut gesendet werden. In der TUI ist dasselbe
|
||||||
|
in der Statuszeile sichtbar: `tokens: N (cached: M)`.
|
||||||
|
|
||||||
|
Falls `cache_read` dauerhaft bei 0 bleibt, obwohl der Kontext >1024 Tokens
|
||||||
|
hat: prüfen, ob `Caps.UsageInStream` für den aktiven Endpoint tatsächlich
|
||||||
|
`true` ist (`nub context` bzw. die Modell-Registry in
|
||||||
|
`internal/llm/registry`) — ohne `stream_options.include_usage` liefert
|
||||||
|
OpenAI gar keine Usage-Daten, dann bleibt der Wert immer 0, unabhängig
|
||||||
|
davon ob gecacht wurde.
|
||||||
|
|
||||||
|
### Test 9 — Permissions: ask, deny, deny_paths
|
||||||
|
|
||||||
|
In `.nub/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[permissions]
|
||||||
|
edit = "ask"
|
||||||
|
bash = "deny"
|
||||||
|
deny_paths = ["**/secrets.txt"]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **TUI**, eine Nachricht schicken, die `edit` auf einer normalen Datei
|
||||||
|
auslöst. Erwartet: ein umrandeter `⚠ Rückfrage: edit`-Block erscheint
|
||||||
|
im Transkript (kein Vollbild — Nachrichtenliste, Eingabefeld und
|
||||||
|
Statuszeile bleiben sichtbar), Statuszeile zeigt zusätzlich `⚠
|
||||||
|
permission required`. Mit `n`/Esc ablehnen — Modell bekommt einen
|
||||||
|
Fehler-`tool_result` und macht sinnvoll weiter (fragt nach, versucht
|
||||||
|
etwas anderes). Erneut versuchen, mit `y` bestätigen — Edit läuft
|
||||||
|
normal.
|
||||||
|
2. Eine Nachricht schicken, die `bash` auslösen würde. Erwartet: sofortiger
|
||||||
|
Fehler `permission denied: bash is not allowed by policy`, kein Modal
|
||||||
|
(deny fragt nie).
|
||||||
|
3. Eine Datei `secrets.txt` anlegen, Modell bitten sie zu lesen/editieren.
|
||||||
|
Erwartet: Fehler durch `deny_paths`, unabhängig davon dass `read`/`edit`
|
||||||
|
sonst auf `auto`/`ask` stehen — Deny-Pfade überstimmen immer.
|
||||||
|
4. Dasselbe **im Print-Modus** (`nub -p`) mit `edit = "ask"`: Erwartet
|
||||||
|
sofortiger Fehler `requires confirmation ('ask'), which this mode does
|
||||||
|
not support` statt eines hängenden Prozesses — `ask` ist dort laut
|
||||||
|
Konzept (E-11) ein harter Fehler, keine Rückfrage über stdin.
|
||||||
|
|
||||||
|
### Test 10 — nub init / /init
|
||||||
|
|
||||||
|
In einem leeren Testverzeichnis (kein `.nub/`, kein `AGENTS.md`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nub init
|
||||||
|
```
|
||||||
|
|
||||||
|
Erwartet: `.nub/config.toml` (mit `[permissions]`-Beispiel) und `AGENTS.md`
|
||||||
|
werden angelegt, beide Pfade auf stdout gemeldet. `.git/info/exclude`
|
||||||
|
enthält danach `.nub/` (git-Repo vorausgesetzt). Erneut `nub init`
|
||||||
|
ausführen — erwartet: beide Dateien werden als `skipped (already exists)`
|
||||||
|
gemeldet, Inhalt bleibt unverändert (vorher manuell etwas in `AGENTS.md`
|
||||||
|
ändern und danach verifizieren, dass es erhalten bleibt).
|
||||||
|
|
||||||
|
Dasselbe in der TUI mit `/init` — meldet dieselben zwei Pfade als
|
||||||
|
Command-Output im Transkript statt auf stdout.
|
||||||
|
|
||||||
|
### Test 11 — question-Tool
|
||||||
|
|
||||||
|
1. **TUI**: Modell bitten, vor einer mehrdeutigen Aktion nachzufragen (z.B.
|
||||||
|
"frag mich per question-Tool, welches Ausgabeformat ich will, mit den
|
||||||
|
Optionen json und yaml"). Erwartet: ein umrandeter `❓ Rückfrage:`-Block
|
||||||
|
erscheint im Transkript (kein Vollbild), Statuszeile zeigt `❓ question
|
||||||
|
pending`, Eingabefeld-Placeholder wechselt auf den Antwort-Hinweis. Text
|
||||||
|
eintippen und Enter drücken — landet **nicht** als neue Chat-Nachricht
|
||||||
|
oder Slash-Kommando, sondern als Antwort des Tools; Transkript zeigt
|
||||||
|
danach `Frage: … / Antwort: …`, Placeholder wechselt zurück, Modell
|
||||||
|
macht mit der Antwort sinnvoll weiter.
|
||||||
|
2. **Print-Modus** (`nub -p`): Modell dasselbe fragen lassen. Erwartet
|
||||||
|
sofortiger Fehler ("interactive questions are not supported in this
|
||||||
|
mode…") statt eines hängenden Prozesses — analog zu `ask`-Permissions
|
||||||
|
ist auch das `question`-Tool im Print-Modus bewusst kein Rückfrage-
|
||||||
|
Mechanismus über stdin, da Print headless laufen soll (E-11).
|
||||||
|
3. Skill mit einer Anweisung testen, die das Modell explizit zum
|
||||||
|
`question`-Tool greifen lässt (z.B. "frage vor dem Löschen von Dateien
|
||||||
|
immer erst per question-Tool nach") — Verhalten identisch zu 1., da das
|
||||||
|
Tool unabhängig davon nutzbar ist, ob Modell oder Skill-Anweisung den
|
||||||
|
Aufruf veranlasst.
|
||||||
|
|
||||||
|
## Bekannte Grenzen (Entwicklungsstand)
|
||||||
|
|
||||||
|
- Bilder werden nicht gemappt, auch wenn der Endpoint sie unterstützt
|
||||||
|
- Permissions/`deny_paths`/`deny_bash` gelten nur für die sechs
|
||||||
|
eingebauten Tools (`read`/`glob`/`grep`/`write`/`edit`/`bash`) — MCP-
|
||||||
|
Tools, `todo` und `read_skill` laufen immer als `auto` und lassen sich
|
||||||
|
aktuell nicht einzeln einschränken
|
||||||
|
- Compaction nutzt immer dasselbe Modell für die Zusammenfassung — keine
|
||||||
|
Möglichkeit, dafür ein günstigeres Modell zu konfigurieren
|
||||||
|
- Token-Kalibrierung ist rein In-Process — kein persistenter Zustand über
|
||||||
|
Sessions hinweg
|
||||||
|
- MCP: kein Reconnect zur Laufzeit (E-06 — würde die Tool-Liste ändern und
|
||||||
|
den Prompt-Cache brechen), kein OAuth-Login-Flow getestet, MCP-Prompts/
|
||||||
|
-Resources werden nicht angebunden, nur Tools
|
||||||
|
- Skills: kein `nub skill init` o.ä., Skills müssen von Hand angelegt werden
|
||||||
|
- TUI: kein Model-Auswahl-Picker (`/model <name>` setzt nur direkt, kein
|
||||||
|
Modal mit Liste), `/tree` zeigt Nodes flach sortiert nach Erstellzeit statt
|
||||||
|
als echten Baum mit Einrückung, `bash`-Streaming-Output läuft nur in die
|
||||||
|
Statuszeile statt live ins Transkript (erst das fertige Ergebnis landet
|
||||||
|
dort), Bildschirm-Resize während eines Modals kann die Breite nicht neu
|
||||||
|
berechnen bis zum nächsten Tastendruck
|
||||||
57
go.mod
Normal file
57
go.mod
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
module nub
|
||||||
|
|
||||||
|
go 1.24.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/glamour v1.0.0
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
|
github.com/mattn/go-isatty v0.0.20
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.13 // indirect
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
|
golang.org/x/net v0.38.0 // indirect
|
||||||
|
golang.org/x/term v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.30.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.4.0
|
||||||
|
github.com/segmentio/asm v1.1.3 // indirect
|
||||||
|
github.com/segmentio/encoding v0.5.3 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
)
|
||||||
116
go.sum
Normal file
116
go.sum
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||||
|
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||||
|
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
|
||||||
|
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
|
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||||
|
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
|
||||||
|
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
|
||||||
|
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
|
||||||
|
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||||
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||||
|
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||||
|
github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w=
|
||||||
|
github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||||
|
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||||
|
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
39
internal/agent/arch_test.go
Normal file
39
internal/agent/arch_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestArchitecture_NoUIImportBelowAgent sichert E-04 mechanisch ab: Jede
|
||||||
|
// Ausgabe läuft über einen Event-Channel, kein Kern-Paket importiert
|
||||||
|
// internal/ui.
|
||||||
|
func TestArchitecture_NoUIImportBelowAgent(t *testing.T) {
|
||||||
|
pkgs := []string{
|
||||||
|
"nub/internal/agent",
|
||||||
|
"nub/internal/llm/...",
|
||||||
|
"nub/internal/tool/...",
|
||||||
|
"nub/internal/config",
|
||||||
|
"nub/internal/session/...",
|
||||||
|
"nub/internal/ctxasm/...",
|
||||||
|
"nub/internal/mcpc/...",
|
||||||
|
"nub/internal/skill/...",
|
||||||
|
"nub/internal/tokens/...",
|
||||||
|
}
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
out, err := exec.Command("go", "list", "-deps", "-f", "{{.ImportPath}}", pkg).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// Pakete wie internal/session existieren in M1 noch nicht -> überspringen.
|
||||||
|
if strings.Contains(string(out), "matched no packages") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Fatalf("go list %s: %v\n%s", pkg, err, out)
|
||||||
|
}
|
||||||
|
for _, dep := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
if dep == "nub/internal/ui" || strings.HasPrefix(dep, "nub/internal/ui/") {
|
||||||
|
t.Errorf("%s transitively imports %s, which violates E-04", pkg, dep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
internal/agent/ask_test.go
Normal file
77
internal/agent/ask_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// askRecordingTool prüft, dass env.Ask beim Tool ankommt und tatsächlich
|
||||||
|
// l.AskUser ist, nicht irgendein Default.
|
||||||
|
type askRecordingTool struct{}
|
||||||
|
|
||||||
|
func (askRecordingTool) Name() string { return "echo" }
|
||||||
|
func (askRecordingTool) Description() string { return "echoes input" }
|
||||||
|
func (askRecordingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (askRecordingTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
if env.Ask == nil {
|
||||||
|
return tool.Result{ForModel: "no ask hook", IsError: true}, nil
|
||||||
|
}
|
||||||
|
answer, err := env.Ask(ctx, "which format?", []string{"json", "yaml"})
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
return tool.Result{ForModel: "got: " + answer}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTools_EnvAskIsWiredToLoopAskUser(t *testing.T) {
|
||||||
|
provider := &fakeProvider{batches: [][]llm.Event{
|
||||||
|
toolCallEvents("call_1", "echo", `{}`),
|
||||||
|
textEvents("done"),
|
||||||
|
}}
|
||||||
|
loop, reg := newTestLoop(provider)
|
||||||
|
reg.Register(askRecordingTool{})
|
||||||
|
|
||||||
|
var gotQuestion string
|
||||||
|
loop.AskUser = func(ctx context.Context, question string, options []string) (string, error) {
|
||||||
|
gotQuestion = question
|
||||||
|
return "json please", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
events := runOneRound(t, loop)
|
||||||
|
out := findToolCallOutput(events)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatal("expected a ToolCallOutput event")
|
||||||
|
}
|
||||||
|
if out.Result.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", out.Result.ForModel)
|
||||||
|
}
|
||||||
|
if out.Result.ForModel != "got: json please" {
|
||||||
|
t.Errorf("ForModel = %q", out.Result.ForModel)
|
||||||
|
}
|
||||||
|
if gotQuestion != "which format?" {
|
||||||
|
t.Errorf("question passed through = %q", gotQuestion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTools_EnvAskNilWithoutLoopAskUser(t *testing.T) {
|
||||||
|
provider := &fakeProvider{batches: [][]llm.Event{
|
||||||
|
toolCallEvents("call_1", "echo", `{}`),
|
||||||
|
textEvents("done"),
|
||||||
|
}}
|
||||||
|
loop, reg := newTestLoop(provider)
|
||||||
|
reg.Register(askRecordingTool{})
|
||||||
|
// loop.AskUser bleibt nil (Print-Modus-Fall).
|
||||||
|
|
||||||
|
events := runOneRound(t, loop)
|
||||||
|
out := findToolCallOutput(events)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatal("expected a ToolCallOutput event")
|
||||||
|
}
|
||||||
|
if !out.Result.IsError || out.Result.ForModel != "no ask hook" {
|
||||||
|
t.Errorf("expected the tool to observe env.Ask == nil, got: %+v", out.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
187
internal/agent/compact.go
Normal file
187
internal/agent/compact.go
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/tokens"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const compactionSystemPrompt = `Du fasst einen Ausschnitt einer Coding-Agent-Session zusammen, der aus dem
|
||||||
|
aktiven Kontext entfernt wird, damit die Konversation fortgesetzt werden kann.
|
||||||
|
|
||||||
|
Die Zusammenfassung muss enthalten: bearbeitete Dateien, getroffene
|
||||||
|
Entscheidungen, offene Aufgaben, wiederherstellbarer Zustand. Keine Prosa
|
||||||
|
über den Gesprächsverlauf. Sei präzise und knapp.`
|
||||||
|
|
||||||
|
// Compact stößt eine einmalige Compaction unabhängig vom Schwellwert an
|
||||||
|
// (CLI-Äquivalent zum /compact-TUI-Kommando, 5.8).
|
||||||
|
func (l *Loop) Compact(ctx context.Context, out chan<- tool.UIEvent) error {
|
||||||
|
return l.maybeCompact(ctx, out, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeCompact prüft, ob der aktuelle Pfad das Token-Budget überschreitet
|
||||||
|
// (5.7), und komprimiert bei Bedarf die ältere Hälfte in einen Summary-Node.
|
||||||
|
// force=true umgeht die Schwellwertprüfung (manuelles `nub compact`).
|
||||||
|
func (l *Loop) maybeCompact(ctx context.Context, out chan<- tool.UIEvent, force bool) error {
|
||||||
|
if l.Session == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := l.Session.PathNodes()
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
maxContext := l.Provider.Caps().MaxContext
|
||||||
|
if maxContext <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
estimate := tokens.EstimateMessages(l.System, nodesToMessages(nodes))
|
||||||
|
if l.Tokens != nil {
|
||||||
|
estimate = l.Tokens.Adjust(estimate)
|
||||||
|
}
|
||||||
|
if float64(estimate) < l.compactAt()*float64(maxContext) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cut := compactionCutIndex(nodes, l.keepTurns())
|
||||||
|
if cut <= 0 {
|
||||||
|
return nil // Session ist bereits kürzer als keepTurns -> nichts zu tun
|
||||||
|
}
|
||||||
|
older, tail := nodes[:cut], nodes[cut:]
|
||||||
|
|
||||||
|
summaryText, err := l.generateSummary(ctx, older)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentID := older[0].ParentID
|
||||||
|
replaces := make([]string, len(older))
|
||||||
|
for i, n := range older {
|
||||||
|
replaces[i] = n.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryNode := session.NewSummaryNode(parentID, summaryText, replaces, session.NodeMeta{Model: l.Model})
|
||||||
|
if err := l.Session.Append(summaryNode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roher Tail wird als neue Nodes an den Summary-Node umgehängt. Die
|
||||||
|
// Original-Nodes bleiben im Baum — nicht destruktiv, Rewind auf sie
|
||||||
|
// funktioniert weiterhin (E-08).
|
||||||
|
parent := summaryNode.ID
|
||||||
|
for _, n := range tail {
|
||||||
|
next := session.NewNode(parent, n.Message, n.Meta)
|
||||||
|
if err := l.Session.Append(next); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
parent = next.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
out <- tool.CompactionEvent{ReplacedNodes: len(older), SummaryTokens: tokens.Estimate(summaryText)}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodesToMessages(nodes []*session.Node) []llm.Message {
|
||||||
|
out := make([]llm.Message, len(nodes))
|
||||||
|
for i, n := range nodes {
|
||||||
|
out[i] = n.Message
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// compactionCutIndex findet den Index, ab dem die letzten keepTurns Turns
|
||||||
|
// beginnen (roh belassen). Eine Turn-Grenze ist eine User-Message mit
|
||||||
|
// echtem Text — reine Tool-Result-Bündel (auch Role User) zählen nicht,
|
||||||
|
// sonst würde fast jeder Schritt als eigener Turn gezählt.
|
||||||
|
func compactionCutIndex(nodes []*session.Node, keepTurns int) int {
|
||||||
|
boundaries := 0
|
||||||
|
for i := len(nodes) - 1; i >= 0; i-- {
|
||||||
|
if isUserTurnBoundary(nodes[i]) {
|
||||||
|
boundaries++
|
||||||
|
if boundaries == keepTurns {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUserTurnBoundary(n *session.Node) bool {
|
||||||
|
if n.Message.Role != llm.RoleUser {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, b := range n.Message.Content {
|
||||||
|
if b.Kind == llm.KindText {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSummary fragt das Modell separat nach einer Zusammenfassung
|
||||||
|
// (5.7). Der Ausschnitt wird als reiner Text-Transkript gerendert statt als
|
||||||
|
// strukturierter Request — sonst würden tool_result-Blöcke auf tool_use-IDs
|
||||||
|
// verweisen, die außerhalb des Ausschnitts liegen, was manche Provider
|
||||||
|
// ablehnen.
|
||||||
|
func (l *Loop) generateSummary(ctx context.Context, nodes []*session.Node) (string, error) {
|
||||||
|
transcript := renderTranscript(nodes)
|
||||||
|
|
||||||
|
req := llm.Request{
|
||||||
|
Model: l.Model,
|
||||||
|
System: []llm.Block{{Kind: llm.KindText, Text: compactionSystemPrompt}},
|
||||||
|
Messages: []llm.Message{{
|
||||||
|
Role: llm.RoleUser,
|
||||||
|
Content: []llm.Block{{Kind: llm.KindText, Text: transcript}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
events, err := l.Provider.Stream(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var text strings.Builder
|
||||||
|
var streamErr error
|
||||||
|
events(func(ev llm.Event, err error) bool {
|
||||||
|
if err != nil {
|
||||||
|
streamErr = err
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if d, ok := ev.(llm.BlockDelta); ok {
|
||||||
|
text.WriteString(d.Text)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if streamErr != nil {
|
||||||
|
return "", streamErr
|
||||||
|
}
|
||||||
|
if text.Len() == 0 {
|
||||||
|
return "", fmt.Errorf("empty summary returned")
|
||||||
|
}
|
||||||
|
return text.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTranscript(nodes []*session.Node) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, n := range nodes {
|
||||||
|
role := string(n.Message.Role)
|
||||||
|
for _, blk := range n.Message.Content {
|
||||||
|
switch blk.Kind {
|
||||||
|
case llm.KindText, llm.KindThinking:
|
||||||
|
fmt.Fprintf(&b, "[%s]\n%s\n\n", role, blk.Text)
|
||||||
|
case llm.KindToolUse:
|
||||||
|
fmt.Fprintf(&b, "[%s] tool_call %s(%s)\n\n", role, blk.Name, string(blk.Input))
|
||||||
|
case llm.KindToolResult:
|
||||||
|
for _, r := range blk.Result {
|
||||||
|
fmt.Fprintf(&b, "[tool_result]\n%s\n\n", r.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
162
internal/agent/compact_test.go
Normal file
162
internal/agent/compact_test.go
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCompactionCutIndex_KeepsLastNTurnsRaw(t *testing.T) {
|
||||||
|
nodes := []*session.Node{
|
||||||
|
{ID: "u1", Message: textMsgNode(llm.RoleUser, "u1")},
|
||||||
|
{ID: "a1", Message: textMsgNode(llm.RoleAssistant, "a1")},
|
||||||
|
{ID: "u2", Message: textMsgNode(llm.RoleUser, "u2")},
|
||||||
|
{ID: "a2", Message: textMsgNode(llm.RoleAssistant, "a2")},
|
||||||
|
{ID: "u3", Message: textMsgNode(llm.RoleUser, "u3")},
|
||||||
|
}
|
||||||
|
if cut := compactionCutIndex(nodes, 2); cut != 2 {
|
||||||
|
t.Errorf("cut = %d, want 2 (start of u2)", cut)
|
||||||
|
}
|
||||||
|
if cut := compactionCutIndex(nodes, 10); cut != 0 {
|
||||||
|
t.Errorf("cut = %d, want 0 when keepTurns exceeds available turns", cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsUserTurnBoundary_IgnoresToolResultOnlyMessages(t *testing.T) {
|
||||||
|
toolResultMsg := llm.Message{
|
||||||
|
Role: llm.RoleUser,
|
||||||
|
Content: []llm.Block{
|
||||||
|
{Kind: llm.KindToolResult, ToolUseID: "x", Result: []llm.Block{{Kind: llm.KindText, Text: "ok"}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if isUserTurnBoundary(&session.Node{Message: toolResultMsg}) {
|
||||||
|
t.Error("tool-result-only message should not count as a turn boundary")
|
||||||
|
}
|
||||||
|
|
||||||
|
realUserMsg := textMsgNode(llm.RoleUser, "hello")
|
||||||
|
if !isUserTurnBoundary(&session.Node{Message: realUserMsg}) {
|
||||||
|
t.Error("a real user text message should count as a turn boundary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textMsgNode(role llm.Role, text string) llm.Message {
|
||||||
|
return llm.Message{Role: role, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptedProvider unterscheidet Compaction-Zusammenfassungs-Requests
|
||||||
|
// (erkennbar am System-Prompt) von normalen Turn-Requests, damit die
|
||||||
|
// Aufrufreihenfolge im Test nicht von der genauen Compaction-Logik abhängt.
|
||||||
|
type scriptedProvider struct {
|
||||||
|
caps llm.Caps
|
||||||
|
batches [][]llm.Event
|
||||||
|
idx int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *scriptedProvider) Name() string { return "scripted" }
|
||||||
|
func (p *scriptedProvider) Caps() llm.Caps { return p.caps }
|
||||||
|
|
||||||
|
func (p *scriptedProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
|
||||||
|
if len(req.System) > 0 && strings.Contains(req.System[0].Text, "Zusammenfassung") {
|
||||||
|
return func(yield func(llm.Event, error) bool) {
|
||||||
|
yield(llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindText}}, nil)
|
||||||
|
yield(llm.BlockDelta{Index: 0, Text: "SUMMARY: files touched, decisions made, todo remains"}, nil)
|
||||||
|
yield(llm.BlockStop{Index: 0}, nil)
|
||||||
|
yield(llm.Done{Stop: llm.StopEnd}, nil)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := p.batches[p.idx]
|
||||||
|
p.idx++
|
||||||
|
return func(yield func(llm.Event, error) bool) {
|
||||||
|
for _, ev := range batch {
|
||||||
|
if !yield(ev, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCompaction_AutoTriggersAndPreservesRewind ist das M4-Fertig-Kriterium
|
||||||
|
// aus Abschnitt 6: eine künstlich verlängerte Session compactet automatisch,
|
||||||
|
// läuft weiter, und ein Rewind auf einen Knoten *vor* der Compaction
|
||||||
|
// funktioniert weiterhin.
|
||||||
|
func TestCompaction_AutoTriggersAndPreservesRewind(t *testing.T) {
|
||||||
|
provider := &scriptedProvider{
|
||||||
|
caps: llm.Caps{MaxContext: 10}, // winzig -> Budget nach wenigen Turns überschritten
|
||||||
|
batches: [][]llm.Event{
|
||||||
|
textEvents("resp-1"),
|
||||||
|
textEvents("resp-2"),
|
||||||
|
textEvents("resp-3"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sess := session.New("test-compaction")
|
||||||
|
loop := &Loop{
|
||||||
|
Provider: provider,
|
||||||
|
Tools: tool.NewRegistry(),
|
||||||
|
Model: "test-model",
|
||||||
|
Env: tool.Env{Cwd: ".", RepoRoot: "."},
|
||||||
|
Session: sess,
|
||||||
|
KeepTurns: 2,
|
||||||
|
CompactAt: 0.75,
|
||||||
|
}
|
||||||
|
|
||||||
|
in := make(chan Input)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out := loop.Run(ctx, in)
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for range out {
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for _, text := range []string{"user one", "user two", "user three"} {
|
||||||
|
select {
|
||||||
|
case in <- Input{Text: text}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("timed out sending input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(in)
|
||||||
|
<-done
|
||||||
|
|
||||||
|
var summary *session.Node
|
||||||
|
var rootID string
|
||||||
|
for id, n := range sess.Nodes {
|
||||||
|
if n.Kind == session.NodeSummary {
|
||||||
|
summary = n
|
||||||
|
}
|
||||||
|
if n.ParentID == "" && n.Kind == session.NodeMessage {
|
||||||
|
rootID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if summary == nil {
|
||||||
|
t.Fatal("expected a summary node after auto-compaction")
|
||||||
|
}
|
||||||
|
if rootID == "" {
|
||||||
|
t.Fatal("could not find original root node")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := sess.PathToHead()
|
||||||
|
if len(path) == 0 || path[0].Content[0].Text != summary.Message.Content[0].Text {
|
||||||
|
t.Errorf("expected current path to start at the summary, got: %+v", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewind auf den allerersten (nie kompaktierten) Node muss weiterhin
|
||||||
|
// funktionieren.
|
||||||
|
if err := sess.Branch(rootID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rewound := sess.PathToHead()
|
||||||
|
if len(rewound) != 1 || rewound[0].Content[0].Text != "user one" {
|
||||||
|
t.Errorf("rewind to pre-compaction root failed: %+v", rewound)
|
||||||
|
}
|
||||||
|
}
|
||||||
86
internal/agent/consume.go
Normal file
86
internal/agent/consume.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"iter"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type blockAcc struct {
|
||||||
|
kind llm.BlockKind
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
text strings.Builder
|
||||||
|
args strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
// consume liest den Event-Stream eines Turns, emittiert Deltas als UIEvents
|
||||||
|
// und akkumuliert parallel die vollständigen Blöcke der Assistant-Message
|
||||||
|
// (Ablauf pro Turn, Schritt 2).
|
||||||
|
func (l *Loop) consume(events iter.Seq2[llm.Event, error], out chan<- tool.UIEvent) (llm.Message, llm.StopReason, llm.Usage, error) {
|
||||||
|
blocks := map[int]*blockAcc{}
|
||||||
|
var order []int
|
||||||
|
stop := llm.StopEnd
|
||||||
|
var usage llm.Usage
|
||||||
|
var streamErr error
|
||||||
|
|
||||||
|
events(func(ev llm.Event, err error) bool {
|
||||||
|
if err != nil {
|
||||||
|
streamErr = err
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch e := ev.(type) {
|
||||||
|
case llm.BlockStart:
|
||||||
|
b := &blockAcc{kind: e.Block.Kind, id: e.Block.ID, name: e.Block.Name}
|
||||||
|
blocks[e.Index] = b
|
||||||
|
order = append(order, e.Index)
|
||||||
|
if e.Block.Kind == llm.KindToolUse {
|
||||||
|
out <- tool.ToolCallStart{ID: e.Block.ID, Name: e.Block.Name}
|
||||||
|
}
|
||||||
|
case llm.BlockDelta:
|
||||||
|
b := blocks[e.Index]
|
||||||
|
if b == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch b.kind {
|
||||||
|
case llm.KindText:
|
||||||
|
b.text.WriteString(e.Text)
|
||||||
|
out <- tool.TextDelta{Text: e.Text}
|
||||||
|
case llm.KindThinking:
|
||||||
|
b.text.WriteString(e.Text)
|
||||||
|
out <- tool.ThinkingDelta{Text: e.Text}
|
||||||
|
case llm.KindToolUse:
|
||||||
|
b.args.WriteString(e.PartialJSON)
|
||||||
|
}
|
||||||
|
case llm.Done:
|
||||||
|
stop = e.Stop
|
||||||
|
usage = e.Usage
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if streamErr != nil {
|
||||||
|
return llm.Message{}, "", llm.Usage{}, streamErr
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := llm.Message{Role: llm.RoleAssistant}
|
||||||
|
for _, idx := range order {
|
||||||
|
b := blocks[idx]
|
||||||
|
switch b.kind {
|
||||||
|
case llm.KindText, llm.KindThinking:
|
||||||
|
if b.text.Len() > 0 {
|
||||||
|
msg.Content = append(msg.Content, llm.Block{Kind: b.kind, Text: b.text.String()})
|
||||||
|
}
|
||||||
|
case llm.KindToolUse:
|
||||||
|
input := json.RawMessage(b.args.String())
|
||||||
|
if len(input) == 0 {
|
||||||
|
input = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
msg.Content = append(msg.Content, llm.Block{Kind: llm.KindToolUse, ID: b.id, Name: b.name, Input: input})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg, stop, usage, nil
|
||||||
|
}
|
||||||
234
internal/agent/loop.go
Normal file
234
internal/agent/loop.go
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
// Package agent enthält den Turn-Loop: Provider streamen, Tools ausführen,
|
||||||
|
// Historie fortschreiben, bis StopEnd. Kein Paket unterhalb von internal/agent
|
||||||
|
// importiert internal/ui (E-04) — jede Ausgabe läuft über tool.UIEvent.
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/permission"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/tokens"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Input ist eine Nutzer- oder Steering-Nachricht in den Loop hinein.
|
||||||
|
type Input struct {
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessioner ist das schmale Interface, das der Loop von der Historie
|
||||||
|
// braucht. *session.Session (reines In-Memory, für Tests) und
|
||||||
|
// *session.Store (mit JSONL-Persistenz) erfüllen es beide.
|
||||||
|
type Sessioner interface {
|
||||||
|
HeadID() string
|
||||||
|
PathToHead() []llm.Message
|
||||||
|
PathNodes() []*session.Node
|
||||||
|
Append(n *session.Node) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Loop struct {
|
||||||
|
Provider llm.Provider
|
||||||
|
Tools *tool.Registry
|
||||||
|
System []llm.Block
|
||||||
|
Env tool.Env
|
||||||
|
Model string
|
||||||
|
Session Sessioner
|
||||||
|
Tokens *tokens.Calibrator // optional; nil = keine Kalibrierung
|
||||||
|
|
||||||
|
// Permissions steuert pro Tool auto/ask/deny (E-11). nil = alles auto.
|
||||||
|
Permissions *permission.Policy
|
||||||
|
// RequestPermission wird für Tools mit Modus "ask" aufgerufen und muss
|
||||||
|
// true (erlauben) oder false (ablehnen) liefern. nil = "ask" ist in
|
||||||
|
// diesem Modus nicht unterstützt und wird als harter Fehler behandelt
|
||||||
|
// (deckt E-11 "ask ist im Print-Modus ein harter Fehler" automatisch ab,
|
||||||
|
// ohne dass der Loop wissen muss, in welchem UI-Modus er läuft).
|
||||||
|
RequestPermission func(ctx context.Context, toolName string, input json.RawMessage) bool
|
||||||
|
|
||||||
|
// AskUser beantwortet das question-Tool: das Modell entscheidet selbst,
|
||||||
|
// dass es etwas klären will (anders als RequestPermission, das
|
||||||
|
// system-/config-entschieden ist). nil (z.B. Print-Modus) lässt das
|
||||||
|
// question-Tool von sich aus einen klaren Fehler statt einer Blockade
|
||||||
|
// liefern — kein Sonderfall hier im Loop nötig.
|
||||||
|
AskUser func(ctx context.Context, question string, options []string) (string, error)
|
||||||
|
|
||||||
|
MaxTurns int // Default 50
|
||||||
|
MaxToolTime time.Duration // Default 120s
|
||||||
|
CompactAt float64 // Anteil von Caps.MaxContext, Default 0.75
|
||||||
|
KeepTurns int // Default 4
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loop) maxTurns() int {
|
||||||
|
if l.MaxTurns > 0 {
|
||||||
|
return l.MaxTurns
|
||||||
|
}
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loop) maxToolTime() time.Duration {
|
||||||
|
if l.MaxToolTime > 0 {
|
||||||
|
return l.MaxToolTime
|
||||||
|
}
|
||||||
|
return 120 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loop) compactAt() float64 {
|
||||||
|
if l.CompactAt > 0 {
|
||||||
|
return l.CompactAt
|
||||||
|
}
|
||||||
|
return 0.75
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loop) keepTurns() int {
|
||||||
|
if l.KeepTurns > 0 {
|
||||||
|
return l.KeepTurns
|
||||||
|
}
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run liest Input, bis der Channel schließt oder der Kontext endet, und
|
||||||
|
// gibt jede Ausgabe über den zurückgegebenen UIEvent-Channel aus (E-04).
|
||||||
|
func (l *Loop) Run(ctx context.Context, in <-chan Input) <-chan tool.UIEvent {
|
||||||
|
out := make(chan tool.UIEvent)
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case msg, ok := <-in:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := l.appendMessage(userMessage(msg.Text), llm.Usage{}); err != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: err}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l.runTurn(ctx, out, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loop) appendMessage(msg llm.Message, usage llm.Usage) error {
|
||||||
|
node := session.NewNode(l.Session.HeadID(), msg, session.NodeMeta{Model: l.Model, Usage: usage})
|
||||||
|
return l.Session.Append(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTurn treibt die Turn-Schleife (Ablauf pro Turn, 5.2) bis StopEnd, ein
|
||||||
|
// Sicherheitslimit greift, oder der Kontext abbricht. Ein abgebrochener Turn
|
||||||
|
// hinterlässt einen gültigen Baumzustand: entweder bekommen alle tool_use-
|
||||||
|
// Blöcke ein Ergebnis, oder der unvollständige Assistant-Node wird nicht
|
||||||
|
// committet (siehe consume/runTools). Die Historie selbst lebt im Session-
|
||||||
|
// Baum (PathToHead), nicht mehr in einem lokalen Slice.
|
||||||
|
func (l *Loop) runTurn(ctx context.Context, out chan<- tool.UIEvent, in <-chan Input) {
|
||||||
|
var lastSignatures []string
|
||||||
|
|
||||||
|
for turnN := 0; turnN < l.maxTurns(); turnN++ {
|
||||||
|
if err := l.maybeCompact(ctx, out, false); err != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: fmt.Errorf("compaction: %w", err)}
|
||||||
|
// Weiterlaufen mit unkomprimiertem Kontext ist besser als der Turn
|
||||||
|
// abzubrechen — Compaction ist eine Optimierung, kein Muss.
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := l.Session.PathToHead()
|
||||||
|
req := llm.Request{
|
||||||
|
Model: l.Model,
|
||||||
|
System: l.System,
|
||||||
|
Messages: messages,
|
||||||
|
Tools: l.toolDefs(),
|
||||||
|
}
|
||||||
|
promptEstimate := tokens.EstimateMessages(l.System, messages)
|
||||||
|
|
||||||
|
events, err := l.Provider.Stream(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assistantMsg, stop, usage, streamErr := l.consume(events, out)
|
||||||
|
if streamErr != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: streamErr}
|
||||||
|
return // kein unvollständiger Assistant-Node committet
|
||||||
|
}
|
||||||
|
if len(assistantMsg.Content) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if l.Tokens != nil {
|
||||||
|
l.Tokens.Observe(promptEstimate, usage.InputTokens)
|
||||||
|
}
|
||||||
|
if err := l.appendMessage(assistantMsg, usage); err != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out <- tool.TurnDone{Stop: stop, Usage: usage}
|
||||||
|
|
||||||
|
if stop != llm.StopToolUse {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toolUses := extractToolUse(assistantMsg)
|
||||||
|
if len(toolUses) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sig := turnSignature(toolUses)
|
||||||
|
lastSignatures = append(lastSignatures, sig)
|
||||||
|
if repeatedThrice(lastSignatures) {
|
||||||
|
cancelled := resultsMessage(cancelledResults(toolUses, "repeated identical tool call 3x in a row, aborting"))
|
||||||
|
if err := l.appendMessage(cancelled, llm.Usage{}); err != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out <- tool.ErrorEvent{Err: fmt.Errorf("repeated identical tool call detected, turn aborted")}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results := l.runTools(ctx, toolUses, out)
|
||||||
|
if err := l.appendMessage(resultsMessage(results), llm.Usage{}); err != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steering: nach Abschluss des aktuellen Tool-Batches nicht-blockierend
|
||||||
|
// prüfen und ggf. als zusätzliche User-Message einschleusen.
|
||||||
|
select {
|
||||||
|
case steer, ok := <-in:
|
||||||
|
if ok {
|
||||||
|
if err := l.appendMessage(userMessage(steer.Text), llm.Usage{}); err != nil {
|
||||||
|
out <- tool.ErrorEvent{Err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out <- tool.ErrorEvent{Err: fmt.Errorf("max_turns (%d) exceeded", l.maxTurns())}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Loop) toolDefs() []llm.ToolDef {
|
||||||
|
defs := l.Tools.Defs()
|
||||||
|
out := make([]llm.ToolDef, len(defs))
|
||||||
|
for i, d := range defs {
|
||||||
|
out[i] = llm.ToolDef{Name: d.Name, Description: d.Description, Schema: d.Schema}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func userMessage(text string) llm.Message {
|
||||||
|
return llm.Message{Role: llm.RoleUser, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func repeatedThrice(sigs []string) bool {
|
||||||
|
n := len(sigs)
|
||||||
|
if n < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return sigs[n-1] == sigs[n-2] && sigs[n-2] == sigs[n-3]
|
||||||
|
}
|
||||||
232
internal/agent/loop_test.go
Normal file
232
internal/agent/loop_test.go
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"iter"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeProvider spielt eine vordefinierte Folge von Event-Batches ab, eine
|
||||||
|
// pro Stream()-Aufruf. Damit lässt sich Multi-Turn-Verhalten ohne Netzwerk
|
||||||
|
// testen (Teststrategie, Abschnitt 7).
|
||||||
|
type fakeProvider struct {
|
||||||
|
batches [][]llm.Event
|
||||||
|
call int
|
||||||
|
caps llm.Caps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *fakeProvider) Name() string { return "fake" }
|
||||||
|
func (p *fakeProvider) Caps() llm.Caps { return p.caps }
|
||||||
|
|
||||||
|
func (p *fakeProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
|
||||||
|
if p.call >= len(p.batches) {
|
||||||
|
return func(yield func(llm.Event, error) bool) {
|
||||||
|
yield(llm.Done{Stop: llm.StopEnd}, nil)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
batch := p.batches[p.call]
|
||||||
|
p.call++
|
||||||
|
return func(yield func(llm.Event, error) bool) {
|
||||||
|
for _, ev := range batch {
|
||||||
|
if !yield(ev, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type echoTool struct{}
|
||||||
|
|
||||||
|
func (echoTool) Name() string { return "echo" }
|
||||||
|
func (echoTool) Description() string { return "echoes input" }
|
||||||
|
func (echoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (echoTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
return tool.Result{ForModel: "ok:" + string(input)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textEvents(s string) []llm.Event {
|
||||||
|
return []llm.Event{
|
||||||
|
llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindText}},
|
||||||
|
llm.BlockDelta{Index: 0, Text: s},
|
||||||
|
llm.BlockStop{Index: 0},
|
||||||
|
llm.Done{Stop: llm.StopEnd, Usage: llm.Usage{OutputTokens: 1}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolCallEvents(id, name, args string) []llm.Event {
|
||||||
|
return []llm.Event{
|
||||||
|
llm.BlockStart{Index: 0, Block: llm.Block{Kind: llm.KindToolUse, ID: id, Name: name}},
|
||||||
|
llm.BlockDelta{Index: 0, PartialJSON: args},
|
||||||
|
llm.BlockStop{Index: 0},
|
||||||
|
llm.Done{Stop: llm.StopToolUse, Usage: llm.Usage{OutputTokens: 1}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestLoop(provider llm.Provider) (*Loop, *tool.Registry) {
|
||||||
|
reg := tool.NewRegistry()
|
||||||
|
reg.Register(echoTool{})
|
||||||
|
return &Loop{
|
||||||
|
Provider: provider,
|
||||||
|
Tools: reg,
|
||||||
|
Model: "test-model",
|
||||||
|
Env: tool.Env{Cwd: ".", RepoRoot: "."},
|
||||||
|
Session: session.New("test-session"),
|
||||||
|
}, reg
|
||||||
|
}
|
||||||
|
|
||||||
|
func drain(t *testing.T, out <-chan tool.UIEvent, timeout time.Duration) []tool.UIEvent {
|
||||||
|
t.Helper()
|
||||||
|
var events []tool.UIEvent
|
||||||
|
deadline := time.After(timeout)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case ev, ok := <-out:
|
||||||
|
if !ok {
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
events = append(events, ev)
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("timed out waiting for events")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoop_SimpleTextTurn(t *testing.T) {
|
||||||
|
provider := &fakeProvider{batches: [][]llm.Event{textEvents("hello")}}
|
||||||
|
loop, _ := newTestLoop(provider)
|
||||||
|
|
||||||
|
in := make(chan Input, 1)
|
||||||
|
in <- Input{Text: "hi"}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out := loop.Run(ctx, in)
|
||||||
|
close(in)
|
||||||
|
events := drain(t, out, 3*time.Second)
|
||||||
|
|
||||||
|
var gotText string
|
||||||
|
var gotDone bool
|
||||||
|
for _, ev := range events {
|
||||||
|
switch e := ev.(type) {
|
||||||
|
case tool.TextDelta:
|
||||||
|
gotText += e.Text
|
||||||
|
case tool.TurnDone:
|
||||||
|
gotDone = true
|
||||||
|
if e.Stop != llm.StopEnd {
|
||||||
|
t.Errorf("stop = %q, want end_turn", e.Stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotText != "hello" {
|
||||||
|
t.Errorf("text = %q", gotText)
|
||||||
|
}
|
||||||
|
if !gotDone {
|
||||||
|
t.Error("expected TurnDone event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoop_ToolCallRoundTrip(t *testing.T) {
|
||||||
|
provider := &fakeProvider{batches: [][]llm.Event{
|
||||||
|
toolCallEvents("call_1", "echo", `{"x":1}`),
|
||||||
|
textEvents("done"),
|
||||||
|
}}
|
||||||
|
loop, _ := newTestLoop(provider)
|
||||||
|
|
||||||
|
in := make(chan Input, 1)
|
||||||
|
in <- Input{Text: "run echo"}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out := loop.Run(ctx, in)
|
||||||
|
close(in)
|
||||||
|
events := drain(t, out, 3*time.Second)
|
||||||
|
|
||||||
|
var gotOutput *tool.ToolCallOutput
|
||||||
|
for _, ev := range events {
|
||||||
|
if o, ok := ev.(tool.ToolCallOutput); ok {
|
||||||
|
cp := o
|
||||||
|
gotOutput = &cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotOutput == nil {
|
||||||
|
t.Fatal("expected a ToolCallOutput event")
|
||||||
|
}
|
||||||
|
if gotOutput.Result.ForModel != `ok:{"x":1}` {
|
||||||
|
t.Errorf("tool result = %q", gotOutput.Result.ForModel)
|
||||||
|
}
|
||||||
|
if provider.call != 2 {
|
||||||
|
t.Errorf("expected 2 provider calls (tool_use + follow-up), got %d", provider.call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoop_UnknownToolProducesErrorResult(t *testing.T) {
|
||||||
|
provider := &fakeProvider{batches: [][]llm.Event{
|
||||||
|
toolCallEvents("call_1", "does_not_exist", `{}`),
|
||||||
|
textEvents("done"),
|
||||||
|
}}
|
||||||
|
loop, _ := newTestLoop(provider)
|
||||||
|
|
||||||
|
in := make(chan Input, 1)
|
||||||
|
in <- Input{Text: "go"}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out := loop.Run(ctx, in)
|
||||||
|
close(in)
|
||||||
|
events := drain(t, out, 3*time.Second)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, ev := range events {
|
||||||
|
if o, ok := ev.(tool.ToolCallOutput); ok && o.Name == "does_not_exist" {
|
||||||
|
found = true
|
||||||
|
if !o.Result.IsError {
|
||||||
|
t.Error("expected IsError for unknown tool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected ToolCallOutput for unknown tool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoop_StreamErrorAbortsWithoutIncompleteMessage(t *testing.T) {
|
||||||
|
provider := &fakeProvider{}
|
||||||
|
loop, _ := newTestLoop(provider)
|
||||||
|
loop.Provider = errorProvider{}
|
||||||
|
|
||||||
|
in := make(chan Input, 1)
|
||||||
|
in <- Input{Text: "hi"}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out := loop.Run(ctx, in)
|
||||||
|
close(in)
|
||||||
|
events := drain(t, out, 3*time.Second)
|
||||||
|
|
||||||
|
sawError := false
|
||||||
|
for _, ev := range events {
|
||||||
|
if _, ok := ev.(tool.ErrorEvent); ok {
|
||||||
|
sawError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawError {
|
||||||
|
t.Error("expected ErrorEvent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorProvider struct{}
|
||||||
|
|
||||||
|
func (errorProvider) Name() string { return "error" }
|
||||||
|
func (errorProvider) Caps() llm.Caps { return llm.Caps{} }
|
||||||
|
func (errorProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
|
||||||
|
return func(yield func(llm.Event, error) bool) {
|
||||||
|
yield(nil, context.DeadlineExceeded)
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
154
internal/agent/permission_test.go
Normal file
154
internal/agent/permission_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/permission"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countingTool zählt, wie oft Run tatsächlich aufgerufen wurde — Grundlage,
|
||||||
|
// um zu beweisen, dass deny/ask-abgelehnte Calls das Tool gar nicht erst
|
||||||
|
// ausführen.
|
||||||
|
type countingTool struct{ calls *int32 }
|
||||||
|
|
||||||
|
func (t countingTool) Name() string { return "echo" }
|
||||||
|
func (t countingTool) Description() string { return "echoes input" }
|
||||||
|
func (t countingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (t countingTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
atomic.AddInt32(t.calls, 1)
|
||||||
|
return tool.Result{ForModel: "ran"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPermissionTestLoop(t *testing.T, calls *int32) (*Loop, *fakeProvider) {
|
||||||
|
t.Helper()
|
||||||
|
provider := &fakeProvider{batches: [][]llm.Event{
|
||||||
|
toolCallEvents("call_1", "echo", `{}`),
|
||||||
|
textEvents("done"),
|
||||||
|
}}
|
||||||
|
loop, reg := newTestLoop(provider)
|
||||||
|
reg.Register(countingTool{calls: calls}) // überschreibt das echoTool aus newTestLoop
|
||||||
|
return loop, provider
|
||||||
|
}
|
||||||
|
|
||||||
|
func runOneRound(t *testing.T, loop *Loop) []tool.UIEvent {
|
||||||
|
t.Helper()
|
||||||
|
in := make(chan Input, 1)
|
||||||
|
in <- Input{Text: "go"}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out := loop.Run(ctx, in)
|
||||||
|
close(in)
|
||||||
|
return drain(t, out, 3*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findToolCallOutput(events []tool.UIEvent) *tool.ToolCallOutput {
|
||||||
|
for _, ev := range events {
|
||||||
|
if o, ok := ev.(tool.ToolCallOutput); ok {
|
||||||
|
return &o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTools_DenyModeBlocksWithoutRunning(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
loop, _ := newPermissionTestLoop(t, &calls)
|
||||||
|
loop.Permissions = &permission.Policy{Modes: map[string]permission.Mode{"echo": permission.ModeDeny}}
|
||||||
|
|
||||||
|
events := runOneRound(t, loop)
|
||||||
|
out := findToolCallOutput(events)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatal("expected a ToolCallOutput event")
|
||||||
|
}
|
||||||
|
if !out.Result.IsError {
|
||||||
|
t.Error("expected IsError for a denied tool call")
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&calls) != 0 {
|
||||||
|
t.Errorf("tool.Run was called %d times, want 0 (deny must block before execution)", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTools_AskModeWithNilHookIsHardError(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
loop, _ := newPermissionTestLoop(t, &calls)
|
||||||
|
loop.Permissions = &permission.Policy{Modes: map[string]permission.Mode{"echo": permission.ModeAsk}}
|
||||||
|
// RequestPermission bleibt nil.
|
||||||
|
|
||||||
|
events := runOneRound(t, loop)
|
||||||
|
out := findToolCallOutput(events)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatal("expected a ToolCallOutput event")
|
||||||
|
}
|
||||||
|
if !out.Result.IsError {
|
||||||
|
t.Error("expected IsError when ask has no RequestPermission hook")
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&calls) != 0 {
|
||||||
|
t.Errorf("tool.Run was called %d times, want 0", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTools_AskModeAllowedByHookRuns(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
loop, _ := newPermissionTestLoop(t, &calls)
|
||||||
|
loop.Permissions = &permission.Policy{Modes: map[string]permission.Mode{"echo": permission.ModeAsk}}
|
||||||
|
loop.RequestPermission = func(ctx context.Context, toolName string, input json.RawMessage) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
events := runOneRound(t, loop)
|
||||||
|
out := findToolCallOutput(events)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatal("expected a ToolCallOutput event")
|
||||||
|
}
|
||||||
|
if out.Result.IsError {
|
||||||
|
t.Errorf("expected success when the hook allows, got: %s", out.Result.ForModel)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&calls) != 1 {
|
||||||
|
t.Errorf("tool.Run was called %d times, want 1", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTools_AskModeDeniedByHookDoesNotRun(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
loop, _ := newPermissionTestLoop(t, &calls)
|
||||||
|
loop.Permissions = &permission.Policy{Modes: map[string]permission.Mode{"echo": permission.ModeAsk}}
|
||||||
|
loop.RequestPermission = func(ctx context.Context, toolName string, input json.RawMessage) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
events := runOneRound(t, loop)
|
||||||
|
out := findToolCallOutput(events)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatal("expected a ToolCallOutput event")
|
||||||
|
}
|
||||||
|
if !out.Result.IsError {
|
||||||
|
t.Error("expected IsError when the user declines")
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&calls) != 0 {
|
||||||
|
t.Errorf("tool.Run was called %d times, want 0", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTools_NilPolicyDefaultsToAuto(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
loop, _ := newPermissionTestLoop(t, &calls)
|
||||||
|
// loop.Permissions bleibt nil.
|
||||||
|
|
||||||
|
events := runOneRound(t, loop)
|
||||||
|
out := findToolCallOutput(events)
|
||||||
|
if out == nil {
|
||||||
|
t.Fatal("expected a ToolCallOutput event")
|
||||||
|
}
|
||||||
|
if out.Result.IsError {
|
||||||
|
t.Errorf("expected success with no policy configured, got: %s", out.Result.ForModel)
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&calls) != 1 {
|
||||||
|
t.Errorf("tool.Run was called %d times, want 1", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
internal/agent/tools.go
Normal file
146
internal/agent/tools.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/permission"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type toolResult struct {
|
||||||
|
id string
|
||||||
|
result tool.Result
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTools startet alle Tool-Calls eines Turns parallel, an einen von ctx
|
||||||
|
// abgeleiteten Kontext gebunden (Ablauf pro Turn, Schritt 3). Jeder Call
|
||||||
|
// bekommt garantiert ein Ergebnis — auch bei Timeout oder Abbruch — weil das
|
||||||
|
// Protokoll für jeden tool_use zwingend einen tool_result verlangt.
|
||||||
|
//
|
||||||
|
// Permission-Prüfung (E-11) läuft davor, sequentiell für den ganzen Batch:
|
||||||
|
// so muss die UI nie mehrere gleichzeitige Rückfragen anzeigen. Erst danach
|
||||||
|
// starten die tatsächlich erlaubten Calls parallel wie bisher.
|
||||||
|
func (l *Loop) runTools(ctx context.Context, calls []llm.Block, out chan<- tool.UIEvent) []toolResult {
|
||||||
|
results := make([]toolResult, len(calls))
|
||||||
|
runnable := make([]int, 0, len(calls))
|
||||||
|
|
||||||
|
for i, call := range calls {
|
||||||
|
if res, blocked := l.checkPermission(ctx, call); blocked {
|
||||||
|
results[i] = res
|
||||||
|
out <- tool.ToolCallOutput{ID: call.ID, Name: call.Name, Result: res.result}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
runnable = append(runnable, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, i := range runnable {
|
||||||
|
call := calls[i]
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int, call llm.Block) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
toolCtx, cancel := context.WithTimeout(ctx, l.maxToolTime())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
env := l.Env
|
||||||
|
env.Emit = func(ev tool.UIEvent) {
|
||||||
|
if st, ok := ev.(tool.ToolStream); ok {
|
||||||
|
st.ID = call.ID
|
||||||
|
out <- st
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out <- ev
|
||||||
|
}
|
||||||
|
env.Ask = l.AskUser
|
||||||
|
|
||||||
|
res, err := l.Tools.Run(toolCtx, call.Name, call.Input, env)
|
||||||
|
if err != nil {
|
||||||
|
res = tool.Result{ForModel: err.Error(), IsError: true}
|
||||||
|
}
|
||||||
|
if toolCtx.Err() == context.DeadlineExceeded && !res.IsError {
|
||||||
|
res = tool.Result{ForModel: fmt.Sprintf("tool %s timed out after %s", call.Name, l.maxToolTime()), IsError: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[i] = toolResult{id: call.ID, result: res}
|
||||||
|
out <- tool.ToolCallOutput{ID: call.ID, Name: call.Name, Result: res}
|
||||||
|
}(i, call)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPermission entscheidet, ob ein Tool-Call überhaupt starten darf.
|
||||||
|
// blocked=true bedeutet: res ist bereits das finale (Fehler-)Ergebnis, der
|
||||||
|
// Call wird nicht ausgeführt.
|
||||||
|
func (l *Loop) checkPermission(ctx context.Context, call llm.Block) (res toolResult, blocked bool) {
|
||||||
|
switch l.Permissions.Check(call.Name, call.Input) {
|
||||||
|
case permission.ModeDeny:
|
||||||
|
return toolResult{id: call.ID, result: tool.Result{
|
||||||
|
ForModel: fmt.Sprintf("permission denied: %s is not allowed by policy", call.Name),
|
||||||
|
IsError: true,
|
||||||
|
}}, true
|
||||||
|
|
||||||
|
case permission.ModeAsk:
|
||||||
|
if l.RequestPermission == nil {
|
||||||
|
return toolResult{id: call.ID, result: tool.Result{
|
||||||
|
ForModel: fmt.Sprintf("tool %s requires confirmation ('ask'), which this mode does not support", call.Name),
|
||||||
|
IsError: true,
|
||||||
|
}}, true
|
||||||
|
}
|
||||||
|
if !l.RequestPermission(ctx, call.Name, call.Input) {
|
||||||
|
return toolResult{id: call.ID, result: tool.Result{
|
||||||
|
ForModel: fmt.Sprintf("permission denied by user for %s", call.Name),
|
||||||
|
IsError: true,
|
||||||
|
}}, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toolResult{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultsMessage(results []toolResult) llm.Message {
|
||||||
|
msg := llm.Message{Role: llm.RoleUser}
|
||||||
|
for _, r := range results {
|
||||||
|
msg.Content = append(msg.Content, llm.Block{
|
||||||
|
Kind: llm.KindToolResult,
|
||||||
|
ToolUseID: r.id,
|
||||||
|
Result: []llm.Block{{Kind: llm.KindText, Text: r.result.ForModel}},
|
||||||
|
IsError: r.result.IsError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelledResults(calls []llm.Block, reason string) []toolResult {
|
||||||
|
out := make([]toolResult, len(calls))
|
||||||
|
for i, c := range calls {
|
||||||
|
out[i] = toolResult{id: c.ID, result: tool.Result{ForModel: reason, IsError: true}}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractToolUse(msg llm.Message) []llm.Block {
|
||||||
|
var out []llm.Block
|
||||||
|
for _, b := range msg.Content {
|
||||||
|
if b.Kind == llm.KindToolUse {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func turnSignature(calls []llm.Block) string {
|
||||||
|
var b []byte
|
||||||
|
for _, c := range calls {
|
||||||
|
b = append(b, c.Name...)
|
||||||
|
b = append(b, 0)
|
||||||
|
b = append(b, c.Input...)
|
||||||
|
b = append(b, 0x1f)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
83
internal/config/load.go
Normal file
83
internal/config/load.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load baut die Config aus allen Schichten auf (spätere gewinnt): Defaults,
|
||||||
|
// ~/.config/nub/config.toml, <repoRoot>/.nub/config.toml, NUB_*-Umgebungs-
|
||||||
|
// variablen. Flags folgen erst, wenn cmd/nub welche anbietet.
|
||||||
|
func Load(repoRoot string) (Config, error) {
|
||||||
|
cfg := Defaults()
|
||||||
|
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
if err := mergeFile(&cfg, filepath.Join(home, ".config", "nub", "config.toml")); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mergeFile(&cfg, filepath.Join(repoRoot, ".nub", "config.toml")); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
applyEnvOverrides(&cfg)
|
||||||
|
|
||||||
|
if cfg.Version != 1 {
|
||||||
|
return Config{}, fmt.Errorf("config: unsupported version %d (nub unterstützt nur version 1)", cfg.Version)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeFile(cfg *Config, path string) error {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil // Datei fehlt -> Schicht wird übersprungen
|
||||||
|
}
|
||||||
|
if _, err := toml.DecodeFile(path, cfg); err != nil {
|
||||||
|
return fmt.Errorf("config: %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyEnvOverrides deckt den kleinen, konkreten Satz an NUB_*-Variablen ab,
|
||||||
|
// der v1 als letzte Schicht vor den (noch nicht existierenden) Flags dient.
|
||||||
|
func applyEnvOverrides(cfg *Config) {
|
||||||
|
if v := os.Getenv("NUB_MODEL"); v != "" {
|
||||||
|
cfg.Model.Default = v
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := os.Getenv("NUB_BASE_URL")
|
||||||
|
apiKey := firstNonEmpty(os.Getenv("NUB_API_KEY"), os.Getenv("OPENAI_API_KEY"))
|
||||||
|
if baseURL == "" && apiKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := -1
|
||||||
|
for i, e := range cfg.Endpoints {
|
||||||
|
if e.Name == cfg.Model.Endpoint {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
cfg.Endpoints = append(cfg.Endpoints, Endpoint{Name: cfg.Model.Endpoint})
|
||||||
|
idx = len(cfg.Endpoints) - 1
|
||||||
|
}
|
||||||
|
if baseURL != "" {
|
||||||
|
cfg.Endpoints[idx].BaseURL = baseURL
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
cfg.Endpoints[idx].APIKey = apiKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(vals ...string) string {
|
||||||
|
for _, v := range vals {
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
154
internal/config/load_test.go
Normal file
154
internal/config/load_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeConfig(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_LayerPrecedence_RepoOverridesHome(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
repo := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "") // macOS UserHomeDir ignores this, aber sauber halten
|
||||||
|
t.Setenv("NUB_MODEL", "")
|
||||||
|
t.Setenv("NUB_BASE_URL", "")
|
||||||
|
t.Setenv("NUB_API_KEY", "")
|
||||||
|
t.Setenv("OPENAI_API_KEY", "")
|
||||||
|
|
||||||
|
writeConfig(t, filepath.Join(home, ".config", "nub", "config.toml"), `
|
||||||
|
version = 1
|
||||||
|
[model]
|
||||||
|
default = "from-home"
|
||||||
|
endpoint = "openai"
|
||||||
|
`)
|
||||||
|
writeConfig(t, filepath.Join(repo, ".nub", "config.toml"), `
|
||||||
|
version = 1
|
||||||
|
[model]
|
||||||
|
default = "from-repo"
|
||||||
|
endpoint = "openai"
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := Load(repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Model.Default != "from-repo" {
|
||||||
|
t.Errorf("model.default = %q, want from-repo (repo layer must win over home)", cfg.Model.Default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_EnvOverridesFiles(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
repo := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("NUB_MODEL", "from-env")
|
||||||
|
t.Setenv("NUB_BASE_URL", "")
|
||||||
|
t.Setenv("NUB_API_KEY", "")
|
||||||
|
t.Setenv("OPENAI_API_KEY", "")
|
||||||
|
|
||||||
|
writeConfig(t, filepath.Join(repo, ".nub", "config.toml"), `
|
||||||
|
version = 1
|
||||||
|
[model]
|
||||||
|
default = "from-repo"
|
||||||
|
endpoint = "openai"
|
||||||
|
`)
|
||||||
|
|
||||||
|
cfg, err := Load(repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Model.Default != "from-env" {
|
||||||
|
t.Errorf("model.default = %q, want from-env (env must win over repo file)", cfg.Model.Default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_DefaultsApplyWithoutAnyConfigFile(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
repo := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("NUB_MODEL", "")
|
||||||
|
t.Setenv("NUB_BASE_URL", "")
|
||||||
|
t.Setenv("NUB_API_KEY", "")
|
||||||
|
t.Setenv("OPENAI_API_KEY", "")
|
||||||
|
|
||||||
|
cfg, err := Load(repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.Model.Default != "gpt-4o" {
|
||||||
|
t.Errorf("model.default = %q, want default gpt-4o", cfg.Model.Default)
|
||||||
|
}
|
||||||
|
if len(cfg.Context.Files) != 2 {
|
||||||
|
t.Errorf("context.files = %v, want 2 defaults", cfg.Context.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_RejectsUnsupportedVersion(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
repo := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
writeConfig(t, filepath.Join(repo, ".nub", "config.toml"), `version = 2`)
|
||||||
|
|
||||||
|
if _, err := Load(repo); err == nil {
|
||||||
|
t.Fatal("expected error for unsupported config version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_EnvKeyIndirection(t *testing.T) {
|
||||||
|
t.Setenv("MY_TEST_KEY", "sk-secret")
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Endpoints[0].APIKey = "env:MY_TEST_KEY"
|
||||||
|
|
||||||
|
resolved, err := Resolve(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved.APIKey != "sk-secret" {
|
||||||
|
t.Errorf("api key = %q, want sk-secret", resolved.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_MissingKeyOnRemoteEndpointErrors(t *testing.T) {
|
||||||
|
t.Setenv("OPENAI_API_KEY", "")
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Endpoints[0].APIKey = "env:DOES_NOT_EXIST_ENV_VAR"
|
||||||
|
|
||||||
|
if _, err := Resolve(cfg); err == nil {
|
||||||
|
t.Fatal("expected error for missing API key on non-local endpoint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_LocalEndpointWithoutKeyIsFine(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Endpoints[0].BaseURL = "http://localhost:11434/v1"
|
||||||
|
cfg.Endpoints[0].APIKey = "none"
|
||||||
|
|
||||||
|
resolved, err := Resolve(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resolved.APIKey != "" {
|
||||||
|
t.Errorf("api key = %q, want empty for local endpoint", resolved.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolve_UnknownEndpointErrors(t *testing.T) {
|
||||||
|
cfg := Defaults()
|
||||||
|
cfg.Model.Endpoint = "does-not-exist"
|
||||||
|
|
||||||
|
if _, err := Resolve(cfg); err == nil {
|
||||||
|
t.Fatal("expected error for unknown model.endpoint reference")
|
||||||
|
}
|
||||||
|
}
|
||||||
62
internal/config/resolve.go
Normal file
62
internal/config/resolve.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/llm/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resolved ist alles, was der OpenAI-Adapter zum Verbindungsaufbau braucht.
|
||||||
|
type Resolved struct {
|
||||||
|
Model string
|
||||||
|
BaseURL string
|
||||||
|
APIKey string
|
||||||
|
Caps llm.Caps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve löst den in Model.Endpoint referenzierten Endpoint auf, ermittelt
|
||||||
|
// den API-Key (inkl. "env:VAR"-Indirektion) und merged Caps aus der
|
||||||
|
// Modell-Registry (E-10) mit den Endpoint-Overrides.
|
||||||
|
func Resolve(cfg Config) (Resolved, error) {
|
||||||
|
var ep *Endpoint
|
||||||
|
for i := range cfg.Endpoints {
|
||||||
|
if cfg.Endpoints[i].Name == cfg.Model.Endpoint {
|
||||||
|
ep = &cfg.Endpoints[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ep == nil {
|
||||||
|
return Resolved{}, fmt.Errorf("config: model.endpoint %q referenziert keinen konfigurierten [[endpoint]]", cfg.Model.Endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := resolveAPIKey(ep.APIKey)
|
||||||
|
if key == "" && !isLocalEndpoint(ep.BaseURL) {
|
||||||
|
return Resolved{}, fmt.Errorf("config: kein API-Key für Endpoint %q — in der Config setzen oder NUB_API_KEY/OPENAI_API_KEY exportieren", ep.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
caps := registry.Apply(registry.DefaultCaps(cfg.Model.Default), ep.Caps)
|
||||||
|
|
||||||
|
return Resolved{
|
||||||
|
Model: cfg.Model.Default,
|
||||||
|
BaseURL: ep.BaseURL,
|
||||||
|
APIKey: key,
|
||||||
|
Caps: caps,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAPIKey(v string) string {
|
||||||
|
if rest, ok := strings.CutPrefix(v, "env:"); ok {
|
||||||
|
return os.Getenv(rest)
|
||||||
|
}
|
||||||
|
if v == "none" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLocalEndpoint(baseURL string) bool {
|
||||||
|
return strings.HasPrefix(baseURL, "http://")
|
||||||
|
}
|
||||||
82
internal/config/schema.go
Normal file
82
internal/config/schema.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
// Package config implementiert die geschichtete Konfiguration aus
|
||||||
|
// Abschnitt 4.7: Defaults -> ~/.config/nub/config.toml -> <repo>/.nub/config.toml
|
||||||
|
// -> Umgebungsvariablen. Spätere Schicht gewinnt.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "nub/internal/llm/registry"
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Version int `toml:"version"`
|
||||||
|
Model ModelConfig `toml:"model"`
|
||||||
|
Endpoints []Endpoint `toml:"endpoint"`
|
||||||
|
Context ContextConfig `toml:"context"`
|
||||||
|
Skills SkillsConfig `toml:"skills"`
|
||||||
|
MCP []MCPServer `toml:"mcp"`
|
||||||
|
Profiles map[string][]string `toml:"profiles"`
|
||||||
|
Permissions PermissionsConfig `toml:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelConfig struct {
|
||||||
|
Default string `toml:"default"`
|
||||||
|
Endpoint string `toml:"endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Endpoint struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
BaseURL string `toml:"base_url"`
|
||||||
|
APIKey string `toml:"api_key"`
|
||||||
|
Caps registry.CapsOverride `toml:"caps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContextConfig struct {
|
||||||
|
Files []string `toml:"files"`
|
||||||
|
WalkUp bool `toml:"walk_up"`
|
||||||
|
MaxTokens int `toml:"max_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillsConfig struct {
|
||||||
|
Paths []string `toml:"paths"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MCPServer struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Command string `toml:"command"`
|
||||||
|
Args []string `toml:"args"`
|
||||||
|
URL string `toml:"url"`
|
||||||
|
Tools []string `toml:"tools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionsConfig struct {
|
||||||
|
Read string `toml:"read"`
|
||||||
|
Glob string `toml:"glob"`
|
||||||
|
Grep string `toml:"grep"`
|
||||||
|
Write string `toml:"write"`
|
||||||
|
Edit string `toml:"edit"`
|
||||||
|
Bash string `toml:"bash"`
|
||||||
|
DenyPaths []string `toml:"deny_paths"`
|
||||||
|
DenyBash []string `toml:"deny_bash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults liefert die Konfiguration, die ohne jede Config-Datei gilt.
|
||||||
|
func Defaults() Config {
|
||||||
|
return Config{
|
||||||
|
Version: 1,
|
||||||
|
Model: ModelConfig{Default: "gpt-4o", Endpoint: "openai"},
|
||||||
|
Endpoints: []Endpoint{
|
||||||
|
{Name: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "env:OPENAI_API_KEY"},
|
||||||
|
},
|
||||||
|
Context: ContextConfig{
|
||||||
|
Files: []string{"AGENTS.md", "REPOMAP.md"},
|
||||||
|
WalkUp: true,
|
||||||
|
MaxTokens: 20000,
|
||||||
|
},
|
||||||
|
Skills: SkillsConfig{
|
||||||
|
Paths: []string{"~/.nub/skills", ".nub/skills"},
|
||||||
|
},
|
||||||
|
Permissions: PermissionsConfig{
|
||||||
|
Read: "auto", Glob: "auto", Grep: "auto",
|
||||||
|
Write: "auto", Edit: "auto", Bash: "auto",
|
||||||
|
DenyPaths: []string{".git/**", "**/.env", "**/id_rsa*"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
188
internal/ctxasm/assemble.go
Normal file
188
internal/ctxasm/assemble.go
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
// Package ctxasm baut den System-Block eines Requests auf: stabil vor
|
||||||
|
// volatil, mit einem Cache-Breakpoint am Ende (5.4). Alles Dynamische
|
||||||
|
// (Git-Status, Datum, Tool-Ausgaben) gehört in Messages, nie hierher.
|
||||||
|
package ctxasm
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed default_system.md
|
||||||
|
var defaultSystemPrompt string
|
||||||
|
|
||||||
|
// Options steuert den Assemble-Lauf. RepoRoot/Cwd werden für die
|
||||||
|
// Umgebungs-Zeile und die walk_up-Dateisuche gebraucht.
|
||||||
|
type Options struct {
|
||||||
|
RepoRoot string
|
||||||
|
Cwd string
|
||||||
|
Files []string // konfigurierte Kontext-Dateinamen, z.B. AGENTS.md
|
||||||
|
WalkUp bool
|
||||||
|
MaxTokens int // Default 20000, wenn <= 0
|
||||||
|
SkillsIndex string // vorgerendert (internal/skill.RenderIndex), leer = kein Block
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileOrigin protokolliert Herkunft und Kosten jeder geladenen Kontext-Datei
|
||||||
|
// (Grundlage für `nub context`).
|
||||||
|
type FileOrigin struct {
|
||||||
|
Path string // repo-relativ
|
||||||
|
Source string // absoluter Pfad, aus dem gelesen wurde
|
||||||
|
Tokens int
|
||||||
|
Truncated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meta struct {
|
||||||
|
Files []FileOrigin
|
||||||
|
Warnings []string
|
||||||
|
TotalTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble baut die System-Blöcke in der Reihenfolge: Basis-System-Prompt,
|
||||||
|
// statischer Umgebungsblock, konfigurierte Kontext-Dateien. Der letzte Block
|
||||||
|
// bekommt CacheMark=true.
|
||||||
|
func Assemble(opts Options) ([]llm.Block, Meta, error) {
|
||||||
|
budget := opts.MaxTokens
|
||||||
|
if budget <= 0 {
|
||||||
|
budget = 20000
|
||||||
|
}
|
||||||
|
|
||||||
|
var blocks []llm.Block
|
||||||
|
var meta Meta
|
||||||
|
|
||||||
|
systemText := systemPromptText(opts.RepoRoot)
|
||||||
|
envText := environmentBlock(opts.RepoRoot)
|
||||||
|
blocks = append(blocks, llm.Block{Kind: llm.KindText, Text: systemText})
|
||||||
|
blocks = append(blocks, llm.Block{Kind: llm.KindText, Text: envText})
|
||||||
|
|
||||||
|
// Das Budget gilt für die konfigurierten Kontext-Dateien, nicht für den
|
||||||
|
// (kleinen, festen) System-Prompt/Env-Block.
|
||||||
|
used := 0
|
||||||
|
for _, name := range opts.Files {
|
||||||
|
for _, path := range resolveFilePaths(opts.RepoRoot, opts.Cwd, name, opts.WalkUp) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(opts.RepoRoot, path)
|
||||||
|
if err != nil {
|
||||||
|
rel = path
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(data)
|
||||||
|
tok := tokens.Estimate(text)
|
||||||
|
truncated := false
|
||||||
|
|
||||||
|
if used+tok > budget {
|
||||||
|
remaining := budget - used
|
||||||
|
if remaining <= 0 {
|
||||||
|
meta.Warnings = append(meta.Warnings, fmt.Sprintf("context budget (%d tokens) exceeded, skipping %s", budget, rel))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
maxChars := remaining * 4
|
||||||
|
if maxChars < len(text) {
|
||||||
|
text = text[:maxChars] + "\n... [truncated: context budget exceeded] ..."
|
||||||
|
tok = tokens.Estimate(text)
|
||||||
|
truncated = true
|
||||||
|
meta.Warnings = append(meta.Warnings, fmt.Sprintf("%s truncated: context budget (%d tokens) exceeded", rel, budget))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks = append(blocks, llm.Block{Kind: llm.KindText, Text: fmt.Sprintf("# %s\n\n%s", rel, text)})
|
||||||
|
used += tok
|
||||||
|
meta.Files = append(meta.Files, FileOrigin{Path: rel, Source: path, Tokens: tok, Truncated: truncated})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta.TotalTokens = used
|
||||||
|
|
||||||
|
if opts.SkillsIndex != "" {
|
||||||
|
blocks = append(blocks, llm.Block{Kind: llm.KindText, Text: opts.SkillsIndex})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(blocks) > 0 {
|
||||||
|
blocks[len(blocks)-1].CacheMark = true
|
||||||
|
}
|
||||||
|
|
||||||
|
warnStaleRepoMap(opts.RepoRoot, meta.Files, &meta.Warnings)
|
||||||
|
|
||||||
|
return blocks, meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemPromptText(repoRoot string) string {
|
||||||
|
if data, err := os.ReadFile(filepath.Join(repoRoot, "SYSTEM.md")); err == nil {
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
return defaultSystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
func environmentBlock(repoRoot string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "OS: %s\n", runtime.GOOS)
|
||||||
|
shell := os.Getenv("SHELL")
|
||||||
|
if shell == "" {
|
||||||
|
shell = "unknown"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "Shell: %s\n", shell)
|
||||||
|
fmt.Fprintf(&b, "RepoRoot: %s\n", repoRoot)
|
||||||
|
if branch, err := gitBranch(repoRoot); err == nil && branch != "" {
|
||||||
|
fmt.Fprintf(&b, "Branch: %s\n", branch)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitBranch(repoRoot string) (string, error) {
|
||||||
|
out, err := exec.Command("git", "-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFilePaths sammelt alle Treffer für `name` zwischen Cwd und RepoRoot
|
||||||
|
// (walk_up) in der Reihenfolge RepoRoot -> Cwd (näher am Arbeitsverzeichnis
|
||||||
|
// gewinnt inhaltlich, weil später gelesen). Ohne walk_up nur RepoRoot.
|
||||||
|
func resolveFilePaths(repoRoot, cwd, name string, walkUp bool) []string {
|
||||||
|
if !walkUp {
|
||||||
|
p := filepath.Join(repoRoot, name)
|
||||||
|
if fileExists(p) {
|
||||||
|
return []string{p}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs []string
|
||||||
|
d := filepath.Clean(cwd)
|
||||||
|
root := filepath.Clean(repoRoot)
|
||||||
|
for {
|
||||||
|
dirs = append(dirs, d)
|
||||||
|
if d == root {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(d)
|
||||||
|
if parent == d {
|
||||||
|
break // cwd liegt nicht unterhalb von repoRoot
|
||||||
|
}
|
||||||
|
d = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []string
|
||||||
|
for i := len(dirs) - 1; i >= 0; i-- {
|
||||||
|
p := filepath.Join(dirs[i], name)
|
||||||
|
if fileExists(p) {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
160
internal/ctxasm/assemble_test.go
Normal file
160
internal/ctxasm/assemble_test.go
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
package ctxasm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssemble_WalkUp_OrdersRepoRootBeforeCwd(t *testing.T) {
|
||||||
|
repoRoot := t.TempDir()
|
||||||
|
sub := filepath.Join(repoRoot, "sub", "deeper")
|
||||||
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(repoRoot, "AGENTS.md"), "root instructions")
|
||||||
|
writeFile(t, filepath.Join(sub, "AGENTS.md"), "deeper instructions")
|
||||||
|
|
||||||
|
blocks, meta, err := Assemble(Options{
|
||||||
|
RepoRoot: repoRoot, Cwd: sub,
|
||||||
|
Files: []string{"AGENTS.md"}, WalkUp: true, MaxTokens: 20000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(meta.Files) != 2 {
|
||||||
|
t.Fatalf("loaded %d files, want 2", len(meta.Files))
|
||||||
|
}
|
||||||
|
if meta.Files[0].Path != "AGENTS.md" {
|
||||||
|
t.Errorf("first file = %q, want repo-root AGENTS.md first", meta.Files[0].Path)
|
||||||
|
}
|
||||||
|
if meta.Files[1].Path != filepath.Join("sub", "deeper", "AGENTS.md") {
|
||||||
|
t.Errorf("second file = %q, want the deeper one last", meta.Files[1].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
joined := joinBlockTexts(blocks)
|
||||||
|
rootIdx := strings.Index(joined, "root instructions")
|
||||||
|
deeperIdx := strings.Index(joined, "deeper instructions")
|
||||||
|
if rootIdx == -1 || deeperIdx == -1 || rootIdx > deeperIdx {
|
||||||
|
t.Errorf("expected root instructions before deeper instructions in assembled text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssemble_LastBlockHasCacheMark(t *testing.T) {
|
||||||
|
repoRoot := t.TempDir()
|
||||||
|
blocks, _, err := Assemble(Options{RepoRoot: repoRoot, Cwd: repoRoot, MaxTokens: 20000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, b := range blocks {
|
||||||
|
want := i == len(blocks)-1
|
||||||
|
if b.CacheMark != want {
|
||||||
|
t.Errorf("block %d CacheMark = %v, want %v", i, b.CacheMark, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssemble_BudgetExceeded_TruncatesAndWarns(t *testing.T) {
|
||||||
|
repoRoot := t.TempDir()
|
||||||
|
big := strings.Repeat("word ", 2000)
|
||||||
|
writeFile(t, filepath.Join(repoRoot, "AGENTS.md"), big)
|
||||||
|
|
||||||
|
_, meta, err := Assemble(Options{
|
||||||
|
RepoRoot: repoRoot, Cwd: repoRoot,
|
||||||
|
Files: []string{"AGENTS.md"}, WalkUp: false, MaxTokens: 100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(meta.Warnings) == 0 {
|
||||||
|
t.Fatal("expected a budget warning")
|
||||||
|
}
|
||||||
|
if len(meta.Files) != 1 || !meta.Files[0].Truncated {
|
||||||
|
t.Errorf("expected AGENTS.md to be marked truncated, got: %+v", meta.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssemble_MissingFilesAreSkippedSilently(t *testing.T) {
|
||||||
|
repoRoot := t.TempDir()
|
||||||
|
_, meta, err := Assemble(Options{
|
||||||
|
RepoRoot: repoRoot, Cwd: repoRoot,
|
||||||
|
Files: []string{"DOES_NOT_EXIST.md"}, WalkUp: false, MaxTokens: 20000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(meta.Files) != 0 {
|
||||||
|
t.Errorf("expected no files loaded, got %+v", meta.Files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssemble_WarnsOnStaleRepoMap(t *testing.T) {
|
||||||
|
repoRoot := t.TempDir()
|
||||||
|
runGit(t, repoRoot, "init")
|
||||||
|
writeFile(t, filepath.Join(repoRoot, "f.txt"), "x")
|
||||||
|
runGit(t, repoRoot, "add", "-A")
|
||||||
|
runGit(t, repoRoot, "-c", "user.email=t@t.com", "-c", "user.name=t", "commit", "-m", "init")
|
||||||
|
|
||||||
|
content, err := GenerateRepoMap(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(repoRoot, "REPOMAP.md"), content)
|
||||||
|
|
||||||
|
// Neuer Commit nach Repomap-Generierung -> Repomap ist jetzt stale.
|
||||||
|
writeFile(t, filepath.Join(repoRoot, "f.txt"), "y")
|
||||||
|
runGit(t, repoRoot, "add", "-A")
|
||||||
|
runGit(t, repoRoot, "-c", "user.email=t@t.com", "-c", "user.name=t", "commit", "-m", "change")
|
||||||
|
|
||||||
|
_, meta, err := Assemble(Options{
|
||||||
|
RepoRoot: repoRoot, Cwd: repoRoot,
|
||||||
|
Files: []string{"REPOMAP.md"}, WalkUp: false, MaxTokens: 20000,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, w := range meta.Warnings {
|
||||||
|
if strings.Contains(w, "REPOMAP.md") {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected a staleness warning, got: %v", meta.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGit(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinBlockTexts(blocks []llm.Block) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, blk := range blocks {
|
||||||
|
b.WriteString(blk.Text)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
11
internal/ctxasm/default_system.md
Normal file
11
internal/ctxasm/default_system.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
Du bist nub, ein terminal-basierter Coding-Agent. Du arbeitest im aktuellen
|
||||||
|
Repository über die dir zur Verfügung gestellten Tools (read, write, edit,
|
||||||
|
bash, glob, grep).
|
||||||
|
|
||||||
|
- Lies relevante Dateien, bevor du sie änderst.
|
||||||
|
- Nutze `edit` für gezielte Änderungen, `write` nur für neue oder komplett
|
||||||
|
zu ersetzende Dateien.
|
||||||
|
- Bei mehrdeutigen edit-Treffern: alten String präzisieren, nicht raten.
|
||||||
|
- Führe nach Änderungen, wenn sinnvoll, Tests oder einen Build-Check über
|
||||||
|
`bash` aus.
|
||||||
|
- Antworte knapp. Erkläre nur, was für den nächsten Schritt relevant ist.
|
||||||
204
internal/ctxasm/repomap.go
Normal file
204
internal/ctxasm/repomap.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package ctxasm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const repoMapCommitPrefix = "commit: "
|
||||||
|
|
||||||
|
// GenerateRepoMap baut Verzeichnisbaum + exportierte Symbole (5.4). Für
|
||||||
|
// .go-Dateien über go/ast, sonst der sprachagnostische Fallback: die erste
|
||||||
|
// nicht-leere Zeile der Datei. Der Header trägt den Git-Commit, gegen den
|
||||||
|
// gebaut wurde — Assemble() warnt später, wenn HEAD davon abweicht.
|
||||||
|
func GenerateRepoMap(repoRoot string) (string, error) {
|
||||||
|
commit, _ := gitHead(repoRoot) // best effort; leer, wenn kein Git-Repo
|
||||||
|
|
||||||
|
var files []string
|
||||||
|
err := filepath.WalkDir(repoRoot, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, relErr := filepath.Rel(repoRoot, path)
|
||||||
|
if relErr != nil || rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
base := filepath.Base(path)
|
||||||
|
if d.IsDir() {
|
||||||
|
if base == ".git" || base == ".nub" || base == "node_modules" || base == "vendor" {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
files = append(files, rel)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# REPOMAP.md\n%s%s\ngenerated_by: nub map\n\n", repoMapCommitPrefix, commit)
|
||||||
|
|
||||||
|
b.WriteString("## Tree\n\n")
|
||||||
|
for _, f := range files {
|
||||||
|
fmt.Fprintf(&b, "%s\n", f)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString("\n## Symbols\n\n")
|
||||||
|
for _, f := range files {
|
||||||
|
abs := filepath.Join(repoRoot, f)
|
||||||
|
if strings.HasSuffix(f, ".go") {
|
||||||
|
syms, err := goSymbols(abs)
|
||||||
|
if err != nil || len(syms) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "### %s\n", f)
|
||||||
|
for _, s := range syms {
|
||||||
|
fmt.Fprintf(&b, "- %s\n", s)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if header := fileHeader(abs); header != "" {
|
||||||
|
fmt.Fprintf(&b, "### %s\n%s\n\n", f, header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func goSymbols(path string) ([]string, error) {
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var syms []string
|
||||||
|
for _, decl := range f.Decls {
|
||||||
|
switch d := decl.(type) {
|
||||||
|
case *ast.FuncDecl:
|
||||||
|
if d.Name.IsExported() {
|
||||||
|
recv := ""
|
||||||
|
if d.Recv != nil && len(d.Recv.List) == 1 {
|
||||||
|
recv = "(" + exprString(d.Recv.List[0].Type) + ") "
|
||||||
|
}
|
||||||
|
syms = append(syms, "func "+recv+d.Name.Name)
|
||||||
|
}
|
||||||
|
case *ast.GenDecl:
|
||||||
|
for _, spec := range d.Specs {
|
||||||
|
switch s := spec.(type) {
|
||||||
|
case *ast.TypeSpec:
|
||||||
|
if s.Name.IsExported() {
|
||||||
|
syms = append(syms, "type "+s.Name.Name)
|
||||||
|
}
|
||||||
|
case *ast.ValueSpec:
|
||||||
|
kind := "var"
|
||||||
|
if d.Tok == token.CONST {
|
||||||
|
kind = "const"
|
||||||
|
}
|
||||||
|
for _, name := range s.Names {
|
||||||
|
if name.IsExported() {
|
||||||
|
syms = append(syms, kind+" "+name.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return syms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func exprString(e ast.Expr) string {
|
||||||
|
switch t := e.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
return t.Name
|
||||||
|
case *ast.StarExpr:
|
||||||
|
return "*" + exprString(t.X)
|
||||||
|
default:
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileHeader ist der sprachagnostische Fallback: die erste nicht-leere Zeile.
|
||||||
|
func fileHeader(path string) string {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(line) > 120 {
|
||||||
|
line = line[:120]
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func gitHead(repoRoot string) (string, error) {
|
||||||
|
out, err := exec.Command("git", "-C", repoRoot, "rev-parse", "HEAD").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnStaleRepoMap vergleicht den im REPOMAP.md-Header vermerkten Commit mit
|
||||||
|
// dem aktuellen HEAD und hängt bei Abweichung eine Warnung an (nicht fatal —
|
||||||
|
// eine falsche Repomap ist schlechter als keine, aber sie soll den Start
|
||||||
|
// nicht verhindern).
|
||||||
|
func warnStaleRepoMap(repoRoot string, files []FileOrigin, warnings *[]string) {
|
||||||
|
for _, f := range files {
|
||||||
|
if filepath.Base(f.Path) != "REPOMAP.md" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(f.Source)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recorded := ""
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
if rest, ok := strings.CutPrefix(line, repoMapCommitPrefix); ok {
|
||||||
|
recorded = strings.TrimSpace(rest)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if recorded == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current, err := gitHead(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if current != recorded {
|
||||||
|
*warnings = append(*warnings, fmt.Sprintf(
|
||||||
|
"REPOMAP.md wurde gegen Commit %s erzeugt, HEAD ist jetzt %s — `nub map` erneut ausführen",
|
||||||
|
shortSHA(recorded), shortSHA(current)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortSHA(sha string) string {
|
||||||
|
if len(sha) > 12 {
|
||||||
|
return sha[:12]
|
||||||
|
}
|
||||||
|
return sha
|
||||||
|
}
|
||||||
45
internal/llm/event.go
Normal file
45
internal/llm/event.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
// Modelliert nach dem expliziteren Anthropic-Schema; der OpenAI-Adapter synthetisiert.
|
||||||
|
|
||||||
|
type Event interface{ isEvent() }
|
||||||
|
|
||||||
|
type BlockStart struct {
|
||||||
|
Index int
|
||||||
|
Block Block // Block ohne Inhalt, nur Kind/ID/Name
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockDelta struct {
|
||||||
|
Index int
|
||||||
|
Text string
|
||||||
|
PartialJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlockStop struct {
|
||||||
|
Index int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Done struct {
|
||||||
|
Stop StopReason
|
||||||
|
Usage Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BlockStart) isEvent() {}
|
||||||
|
func (BlockDelta) isEvent() {}
|
||||||
|
func (BlockStop) isEvent() {}
|
||||||
|
func (Done) isEvent() {}
|
||||||
|
|
||||||
|
type StopReason string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StopEnd StopReason = "end_turn"
|
||||||
|
StopToolUse StopReason = "tool_use"
|
||||||
|
StopMaxTokens StopReason = "max_tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Usage struct {
|
||||||
|
InputTokens int `json:"input_tokens,omitempty"`
|
||||||
|
OutputTokens int `json:"output_tokens,omitempty"`
|
||||||
|
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
|
||||||
|
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
|
||||||
|
}
|
||||||
69
internal/llm/message.go
Normal file
69
internal/llm/message.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Package llm definiert das providerunabhängige Nachrichten- und Event-Modell.
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleUser Role = "user"
|
||||||
|
RoleAssistant Role = "assistant"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BlockKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindText BlockKind = "text"
|
||||||
|
KindThinking BlockKind = "thinking"
|
||||||
|
KindToolUse BlockKind = "tool_use"
|
||||||
|
KindToolResult BlockKind = "tool_result"
|
||||||
|
KindImage BlockKind = "image"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Block struct {
|
||||||
|
Kind BlockKind `json:"kind"`
|
||||||
|
|
||||||
|
// text / thinking
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
|
||||||
|
// tool_use
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
|
|
||||||
|
// tool_result
|
||||||
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
|
Result []Block `json:"result,omitempty"`
|
||||||
|
IsError bool `json:"is_error,omitempty"`
|
||||||
|
|
||||||
|
// image
|
||||||
|
MediaType string `json:"media_type,omitempty"`
|
||||||
|
Data []byte `json:"data,omitempty"`
|
||||||
|
|
||||||
|
// Opaker Provider-Ballast (Anthropic-Signatures, reasoning-IDs).
|
||||||
|
// Muss unverändert zurückgesendet werden können.
|
||||||
|
Raw json.RawMessage `json:"raw,omitempty"`
|
||||||
|
|
||||||
|
// Cache-Breakpoint. Adapter ohne Cache-Steuerung ignorieren das Feld.
|
||||||
|
CacheMark bool `json:"cache_mark,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Content []Block `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Model string
|
||||||
|
System []Block // eigenes Feld, NICHT als Message in der Historie
|
||||||
|
Messages []Message
|
||||||
|
Tools []ToolDef
|
||||||
|
MaxTokens int
|
||||||
|
Temp *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolDef struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Schema json.RawMessage // kanonisch: JSON Schema
|
||||||
|
}
|
||||||
98
internal/llm/openai/adapter.go
Normal file
98
internal/llm/openai/adapter.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"iter"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Adapter struct {
|
||||||
|
name string
|
||||||
|
baseURL string
|
||||||
|
apiKey string
|
||||||
|
caps llm.Caps
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(name, baseURL, apiKey string, caps llm.Caps) *Adapter {
|
||||||
|
return &Adapter{
|
||||||
|
name: name,
|
||||||
|
baseURL: baseURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
caps: caps,
|
||||||
|
httpClient: &http.Client{Timeout: 10 * time.Minute},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Adapter) Name() string { return a.name }
|
||||||
|
func (a *Adapter) Caps() llm.Caps { return a.caps }
|
||||||
|
|
||||||
|
// Stream öffnet die HTTP-Verbindung synchron (damit Verbindungsfehler sofort
|
||||||
|
// als error zurückkommen) und liefert einen Iterator über die synthetisierten
|
||||||
|
// Events. Retry ist bewusst nicht Teil dieser Methode (E-12) — siehe
|
||||||
|
// withRetry im Loop-Aufrufer.
|
||||||
|
func (a *Adapter) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
|
||||||
|
wireReq := buildRequest(req, a.caps)
|
||||||
|
body, err := json.Marshal(wireReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build http request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
if a.apiKey != "" && a.apiKey != "none" {
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := a.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||||
|
return nil, &StatusError{Code: resp.StatusCode, Body: string(payload)}
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := newDecoder(resp.Body)
|
||||||
|
|
||||||
|
return func(yield func(llm.Event, error) bool) {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
for {
|
||||||
|
events, more, err := dec.next()
|
||||||
|
if err != nil {
|
||||||
|
yield(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, ev := range events {
|
||||||
|
if !yield(ev, nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !more {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusError trägt den HTTP-Status, damit der Retry-Layer (E-12, 5.9) ihn
|
||||||
|
// klassifizieren kann, ohne Strings zu parsen.
|
||||||
|
type StatusError struct {
|
||||||
|
Code int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *StatusError) Error() string {
|
||||||
|
return fmt.Sprintf("openai: http %d: %s", e.Code, e.Body)
|
||||||
|
}
|
||||||
143
internal/llm/openai/request.go
Normal file
143
internal/llm/openai/request.go
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const systemSeparator = "--- system ---\n"
|
||||||
|
|
||||||
|
func buildRequest(req llm.Request, caps llm.Caps) wireRequest {
|
||||||
|
wr := wireRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
MaxTokens: req.MaxTokens,
|
||||||
|
Temperature: req.Temp,
|
||||||
|
Stream: true,
|
||||||
|
}
|
||||||
|
if caps.UsageInStream {
|
||||||
|
wr.StreamOptions = &streamOptions{IncludeUsage: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []wireMessage
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
messages = append(messages, mapMessage(m)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System-Rolle je nach Endpoint-Capability (5.1 Punkt 4): "system" oder
|
||||||
|
// "developer" als eigene Message-Rolle, sonst als erste User-Message mit
|
||||||
|
// Trennmarker (Endpoint ohne System-Rollen-Unterstützung).
|
||||||
|
if len(req.System) > 0 {
|
||||||
|
text := blocksToText(req.System)
|
||||||
|
switch caps.SystemRole {
|
||||||
|
case "system", "developer":
|
||||||
|
messages = append([]wireMessage{{Role: caps.SystemRole, Content: textContent(text)}}, messages...)
|
||||||
|
default:
|
||||||
|
messages = prependSystemAsUser(messages, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wr.Messages = messages
|
||||||
|
|
||||||
|
// "tools" weglassen statt leer senden (5.1 Punkt 2).
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
wr.Tools = make([]wireToolDef, len(req.Tools))
|
||||||
|
for i, t := range req.Tools {
|
||||||
|
wr.Tools[i] = wireToolDef{
|
||||||
|
Type: "function",
|
||||||
|
Function: wireFunctionDef{
|
||||||
|
Name: t.Name,
|
||||||
|
Description: t.Description,
|
||||||
|
Parameters: t.Schema,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return wr
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapMessage(m llm.Message) []wireMessage {
|
||||||
|
var out []wireMessage
|
||||||
|
role := string(m.Role)
|
||||||
|
|
||||||
|
var textParts []string
|
||||||
|
var toolCalls []wireToolCall
|
||||||
|
|
||||||
|
flushAssistant := func() {
|
||||||
|
if len(textParts) == 0 && len(toolCalls) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wm := wireMessage{Role: role, ToolCalls: toolCalls}
|
||||||
|
if len(textParts) > 0 {
|
||||||
|
wm.Content = textContent(joinText(textParts))
|
||||||
|
}
|
||||||
|
out = append(out, wm)
|
||||||
|
textParts = nil
|
||||||
|
toolCalls = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range m.Content {
|
||||||
|
switch b.Kind {
|
||||||
|
case llm.KindText:
|
||||||
|
textParts = append(textParts, b.Text)
|
||||||
|
case llm.KindThinking:
|
||||||
|
// Reasoning-Blöcke werden beim Zurücksenden nicht erneut mitgeschickt;
|
||||||
|
// sie sind rein empfangsseitig relevant (5.1 Punkt 5).
|
||||||
|
case llm.KindToolUse:
|
||||||
|
toolCalls = append(toolCalls, wireToolCall{
|
||||||
|
ID: b.ID,
|
||||||
|
Type: "function",
|
||||||
|
Function: wireToolCallFunc{
|
||||||
|
Name: b.Name,
|
||||||
|
Arguments: string(b.Input),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case llm.KindToolResult:
|
||||||
|
flushAssistant()
|
||||||
|
out = append(out, wireMessage{
|
||||||
|
Role: "tool",
|
||||||
|
ToolCallID: b.ToolUseID,
|
||||||
|
Content: textContent(blocksToText(b.Result)),
|
||||||
|
})
|
||||||
|
case llm.KindImage:
|
||||||
|
// v1: Bild-Content wird nicht gemappt (SupportsImages-Endpoints folgen später).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushAssistant()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func blocksToText(blocks []llm.Block) string {
|
||||||
|
var parts []string
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Kind == llm.KindText || b.Kind == llm.KindThinking {
|
||||||
|
parts = append(parts, b.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return joinText(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinText(parts []string) string {
|
||||||
|
out := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
out += "\n"
|
||||||
|
}
|
||||||
|
out += p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func textContent(s string) json.RawMessage {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// prependSystemAsUser wird verwendet, wenn Caps.SystemRole == "none" gesetzt
|
||||||
|
// ist (Endpoint ohne System-Rolle): System-Text als erste User-Message mit
|
||||||
|
// Trennmarker.
|
||||||
|
func prependSystemAsUser(messages []wireMessage, systemText string) []wireMessage {
|
||||||
|
marker := fmt.Sprintf("%s%s", systemSeparator, systemText)
|
||||||
|
return append([]wireMessage{{Role: "user", Content: textContent(marker)}}, messages...)
|
||||||
|
}
|
||||||
215
internal/llm/openai/stream.go
Normal file
215
internal/llm/openai/stream.go
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// partialCall akkumuliert die fragmentierten tool_calls-Deltas. `index` ist
|
||||||
|
// die einzige verlässliche Korrelation (5.1 Punkt 1); id/name kommen
|
||||||
|
// typischerweise nur im ersten Chunk.
|
||||||
|
type partialCall struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
ourIndex int
|
||||||
|
started bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// decoder liest einen OpenAI-kompatiblen SSE-Chatstream und synthetisiert
|
||||||
|
// unser internes Event-Schema. Tolerant gegenüber Server-Eigenheiten:
|
||||||
|
// kompletter Call in einem Chunk, falsch gezählter index, nachträglicher name.
|
||||||
|
type decoder struct {
|
||||||
|
scanner *bufio.Scanner
|
||||||
|
calls map[int]*partialCall
|
||||||
|
nextIndex int
|
||||||
|
textIndex int
|
||||||
|
textOpen bool
|
||||||
|
thinkIndex int
|
||||||
|
thinkOpen bool
|
||||||
|
usage wireUsage
|
||||||
|
haveUsage bool
|
||||||
|
finishStop string
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDecoder(body io.Reader) *decoder {
|
||||||
|
s := bufio.NewScanner(body)
|
||||||
|
s.Buffer(make([]byte, 64*1024), 8*1024*1024)
|
||||||
|
return &decoder{scanner: s, calls: make(map[int]*partialCall)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// next liest den nächsten SSE-Frame und liefert die daraus resultierenden
|
||||||
|
// Events. Ein Frame kann 0, 1 oder mehrere Events erzeugen (z.B. BlockStart
|
||||||
|
// + BlockDelta beim ersten Tool-Call-Fragment). more=false signalisiert das
|
||||||
|
// Ende des Streams (nach dem letzten Done-Event).
|
||||||
|
func (d *decoder) next() (events []llm.Event, more bool, err error) {
|
||||||
|
if d.done {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
for d.scanner.Scan() {
|
||||||
|
line := d.scanner.Text()
|
||||||
|
if line == "" || bytes.HasPrefix([]byte(line), []byte(":")) {
|
||||||
|
continue // Leerzeile oder SSE-Kommentar/Keep-Alive
|
||||||
|
}
|
||||||
|
data, ok := cutPrefix(line, "data: ")
|
||||||
|
if !ok {
|
||||||
|
data, ok = cutPrefix(line, "data:")
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data = trimSpace(data)
|
||||||
|
if data == "[DONE]" {
|
||||||
|
d.done = true
|
||||||
|
return d.finalEvents(), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var streamErr wireStreamError
|
||||||
|
if err := json.Unmarshal([]byte(data), &streamErr); err == nil && streamErr.Error != nil {
|
||||||
|
d.done = true
|
||||||
|
return nil, false, fmt.Errorf("stream error: %s (%s)", streamErr.Error.Message, streamErr.Error.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunk wireChunk
|
||||||
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||||
|
d.done = true
|
||||||
|
return nil, false, fmt.Errorf("decode chunk: %w", err)
|
||||||
|
}
|
||||||
|
return d.applyChunk(chunk), true, nil
|
||||||
|
}
|
||||||
|
if err := d.scanner.Err(); err != nil {
|
||||||
|
d.done = true
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
// Stream endete ohne explizites [DONE] (manche Server tun das) -> Finalize.
|
||||||
|
d.done = true
|
||||||
|
return d.finalEvents(), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *decoder) applyChunk(chunk wireChunk) []llm.Event {
|
||||||
|
var events []llm.Event
|
||||||
|
if chunk.Usage != nil {
|
||||||
|
d.usage = *chunk.Usage
|
||||||
|
d.haveUsage = true
|
||||||
|
}
|
||||||
|
if len(chunk.Choices) == 0 {
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
choice := chunk.Choices[0]
|
||||||
|
|
||||||
|
if choice.Delta.Content != nil && *choice.Delta.Content != "" {
|
||||||
|
if !d.textOpen {
|
||||||
|
d.textIndex = d.nextIndex
|
||||||
|
d.nextIndex++
|
||||||
|
d.textOpen = true
|
||||||
|
events = append(events, llm.BlockStart{Index: d.textIndex, Block: llm.Block{Kind: llm.KindText}})
|
||||||
|
}
|
||||||
|
events = append(events, llm.BlockDelta{Index: d.textIndex, Text: *choice.Delta.Content})
|
||||||
|
}
|
||||||
|
|
||||||
|
if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" {
|
||||||
|
if !d.thinkOpen {
|
||||||
|
d.thinkIndex = d.nextIndex
|
||||||
|
d.nextIndex++
|
||||||
|
d.thinkOpen = true
|
||||||
|
events = append(events, llm.BlockStart{Index: d.thinkIndex, Block: llm.Block{Kind: llm.KindThinking}})
|
||||||
|
}
|
||||||
|
events = append(events, llm.BlockDelta{Index: d.thinkIndex, Text: *choice.Delta.ReasoningContent})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range choice.Delta.ToolCalls {
|
||||||
|
pc, ok := d.calls[tc.Index]
|
||||||
|
if !ok {
|
||||||
|
pc = &partialCall{}
|
||||||
|
d.calls[tc.Index] = pc
|
||||||
|
}
|
||||||
|
if tc.ID != nil {
|
||||||
|
pc.id = *tc.ID
|
||||||
|
}
|
||||||
|
if tc.Function != nil && tc.Function.Name != nil {
|
||||||
|
pc.name += *tc.Function.Name
|
||||||
|
}
|
||||||
|
if !pc.started && pc.id != "" && pc.name != "" {
|
||||||
|
pc.started = true
|
||||||
|
pc.ourIndex = d.nextIndex
|
||||||
|
d.nextIndex++
|
||||||
|
events = append(events, llm.BlockStart{
|
||||||
|
Index: pc.ourIndex,
|
||||||
|
Block: llm.Block{Kind: llm.KindToolUse, ID: pc.id, Name: pc.name},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if tc.Function != nil && tc.Function.Arguments != nil && pc.started {
|
||||||
|
events = append(events, llm.BlockDelta{Index: pc.ourIndex, PartialJSON: *tc.Function.Arguments})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if choice.FinishReason != nil {
|
||||||
|
d.finishStop = *choice.FinishReason
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalEvents schließt alle offenen Blöcke und liefert das abschließende
|
||||||
|
// Done-Event.
|
||||||
|
func (d *decoder) finalEvents() []llm.Event {
|
||||||
|
var events []llm.Event
|
||||||
|
if d.textOpen {
|
||||||
|
events = append(events, llm.BlockStop{Index: d.textIndex})
|
||||||
|
d.textOpen = false
|
||||||
|
}
|
||||||
|
if d.thinkOpen {
|
||||||
|
events = append(events, llm.BlockStop{Index: d.thinkIndex})
|
||||||
|
d.thinkOpen = false
|
||||||
|
}
|
||||||
|
for _, pc := range d.calls {
|
||||||
|
if pc.started {
|
||||||
|
events = append(events, llm.BlockStop{Index: pc.ourIndex})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := mapStopReason(d.finishStop)
|
||||||
|
usage := llm.Usage{}
|
||||||
|
if d.haveUsage {
|
||||||
|
usage.InputTokens = d.usage.PromptTokens
|
||||||
|
usage.OutputTokens = d.usage.CompletionTokens
|
||||||
|
if d.usage.PromptTokensDetails != nil {
|
||||||
|
usage.CacheReadTokens = d.usage.PromptTokensDetails.CachedTokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events = append(events, llm.Done{Stop: stop, Usage: usage})
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStopReason(finish string) llm.StopReason {
|
||||||
|
switch finish {
|
||||||
|
case "tool_calls":
|
||||||
|
return llm.StopToolUse
|
||||||
|
case "length":
|
||||||
|
return llm.StopMaxTokens
|
||||||
|
default:
|
||||||
|
return llm.StopEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cutPrefix(s, prefix string) (string, bool) {
|
||||||
|
if len(s) < len(prefix) || s[:len(prefix)] != prefix {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return s[len(prefix):], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpace(s string) string {
|
||||||
|
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
|
||||||
|
s = s[1:]
|
||||||
|
}
|
||||||
|
for len(s) > 0 && (s[len(s)-1] == '\r' || s[len(s)-1] == '\n') {
|
||||||
|
s = s[:len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
132
internal/llm/openai/stream_test.go
Normal file
132
internal/llm/openai/stream_test.go
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecoder_FragmentedToolCall(t *testing.T) {
|
||||||
|
f, err := os.Open("testdata/tool_call_fragmented.sse")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
dec := newDecoder(f)
|
||||||
|
var events []llm.Event
|
||||||
|
for {
|
||||||
|
evs, more, err := dec.next()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
events = append(events, evs...)
|
||||||
|
if !more {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
gotTextStart bool
|
||||||
|
gotTextDelta string
|
||||||
|
gotToolStart *llm.BlockStart
|
||||||
|
toolArgs string
|
||||||
|
gotDone *llm.Done
|
||||||
|
)
|
||||||
|
for _, ev := range events {
|
||||||
|
switch e := ev.(type) {
|
||||||
|
case llm.BlockStart:
|
||||||
|
if e.Block.Kind == llm.KindText {
|
||||||
|
gotTextStart = true
|
||||||
|
}
|
||||||
|
if e.Block.Kind == llm.KindToolUse {
|
||||||
|
cp := e
|
||||||
|
gotToolStart = &cp
|
||||||
|
}
|
||||||
|
case llm.BlockDelta:
|
||||||
|
if gotToolStart != nil && e.Index == gotToolStart.Index {
|
||||||
|
toolArgs += e.PartialJSON
|
||||||
|
} else {
|
||||||
|
gotTextDelta += e.Text
|
||||||
|
}
|
||||||
|
case llm.Done:
|
||||||
|
cp := e
|
||||||
|
gotDone = &cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !gotTextStart {
|
||||||
|
t.Error("expected a text BlockStart")
|
||||||
|
}
|
||||||
|
if gotTextDelta != "Ich lese die Datei." {
|
||||||
|
t.Errorf("text delta = %q", gotTextDelta)
|
||||||
|
}
|
||||||
|
if gotToolStart == nil {
|
||||||
|
t.Fatal("expected a tool_use BlockStart")
|
||||||
|
}
|
||||||
|
if gotToolStart.Block.ID != "call_abc" || gotToolStart.Block.Name != "read" {
|
||||||
|
t.Errorf("tool start = %+v", gotToolStart.Block)
|
||||||
|
}
|
||||||
|
if toolArgs != `{"path":"a.go"}` {
|
||||||
|
t.Errorf("accumulated tool args = %q", toolArgs)
|
||||||
|
}
|
||||||
|
if gotDone == nil {
|
||||||
|
t.Fatal("expected a Done event")
|
||||||
|
}
|
||||||
|
if gotDone.Stop != llm.StopToolUse {
|
||||||
|
t.Errorf("stop reason = %q, want tool_use", gotDone.Stop)
|
||||||
|
}
|
||||||
|
if gotDone.Usage.InputTokens != 42 || gotDone.Usage.OutputTokens != 7 {
|
||||||
|
t.Errorf("usage = %+v", gotDone.Usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDecoder_ParsesCachedTokens verifiziert usage.prompt_tokens_details.
|
||||||
|
// cached_tokens (M7: Grundlage für die Prompt-Cache-Verifikation) landet
|
||||||
|
// korrekt in llm.Usage.CacheReadTokens.
|
||||||
|
func TestDecoder_ParsesCachedTokens(t *testing.T) {
|
||||||
|
f, err := os.Open("testdata/cached_usage.sse")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
dec := newDecoder(f)
|
||||||
|
var gotDone *llm.Done
|
||||||
|
for {
|
||||||
|
evs, more, err := dec.next()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
for _, ev := range evs {
|
||||||
|
if d, ok := ev.(llm.Done); ok {
|
||||||
|
cp := d
|
||||||
|
gotDone = &cp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !more {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotDone == nil {
|
||||||
|
t.Fatal("expected a Done event")
|
||||||
|
}
|
||||||
|
if gotDone.Usage.InputTokens != 1200 || gotDone.Usage.OutputTokens != 5 {
|
||||||
|
t.Errorf("usage = %+v", gotDone.Usage)
|
||||||
|
}
|
||||||
|
if gotDone.Usage.CacheReadTokens != 896 {
|
||||||
|
t.Errorf("cache read tokens = %d, want 896", gotDone.Usage.CacheReadTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecoder_StreamErrorEvent(t *testing.T) {
|
||||||
|
body := `data: {"error":{"message":"rate limited","type":"rate_limit_error"}}` + "\n\ndata: [DONE]\n"
|
||||||
|
dec := newDecoder(strings.NewReader(body))
|
||||||
|
_, _, err := dec.next()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from stream error event")
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/llm/openai/testdata/cached_usage.sse
vendored
Normal file
5
internal/llm/openai/testdata/cached_usage.sse
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
data: {"choices":[{"delta":{"content":"Hallo!"},"finish_reason":null}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1200,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":896}}}
|
||||||
|
|
||||||
|
data: [DONE]
|
||||||
11
internal/llm/openai/testdata/tool_call_fragmented.sse
vendored
Normal file
11
internal/llm/openai/testdata/tool_call_fragmented.sse
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
data: {"choices":[{"delta":{"content":"Ich lese die Datei."},"finish_reason":null}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"path\""}}]},"finish_reason":null}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\"a.go\"}"}}]},"finish_reason":null}]}
|
||||||
|
|
||||||
|
data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":42,"completion_tokens":7}}
|
||||||
|
|
||||||
|
data: [DONE]
|
||||||
110
internal/llm/openai/wire.go
Normal file
110
internal/llm/openai/wire.go
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// Package openai ist der OpenAI-kompatible Provider-Adapter. Provider-SDK-Typen
|
||||||
|
// und das Wire-Format bleiben strikt in diesem Paket (E-02).
|
||||||
|
package openai
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
type wireRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []wireMessage `json:"messages"`
|
||||||
|
Tools []wireToolDef `json:"tools,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamOptions struct {
|
||||||
|
IncludeUsage bool `json:"include_usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
|
ToolCalls []wireToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function wireToolCallFunc `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireToolCallFunc struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireToolDef struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function wireFunctionDef `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireFunctionDef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *wireImageURL `json:"image_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stream chunk ---
|
||||||
|
|
||||||
|
type wireChunk struct {
|
||||||
|
Choices []wireChoice `json:"choices"`
|
||||||
|
Usage *wireUsage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireChoice struct {
|
||||||
|
Delta wireDelta `json:"delta"`
|
||||||
|
FinishReason *string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireDelta struct {
|
||||||
|
Content *string `json:"content"`
|
||||||
|
ReasoningContent *string `json:"reasoning_content"`
|
||||||
|
ToolCalls []wireToolCallDelta `json:"tool_calls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireToolCallDelta struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
ID *string `json:"id"`
|
||||||
|
Type *string `json:"type"`
|
||||||
|
Function *wireToolCallFuncDelta `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireToolCallFuncDelta struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Arguments *string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireUsage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
PromptTokensDetails *wireTokensDetails `json:"prompt_tokens_details"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireTokensDetails struct {
|
||||||
|
CachedTokens int `json:"cached_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireStreamError deckt Endpoints ab, die Fehler als SSE-Event statt als
|
||||||
|
// HTTP-Fehler senden (5.1 Punkt 7).
|
||||||
|
type wireStreamError struct {
|
||||||
|
Error *wireErrorBody `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wireErrorBody struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
23
internal/llm/provider.go
Normal file
23
internal/llm/provider.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Provider interface {
|
||||||
|
Stream(ctx context.Context, req Request) (iter.Seq2[Event, error], error)
|
||||||
|
Caps() Caps
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Caps struct {
|
||||||
|
ParallelToolCalls bool
|
||||||
|
UsageInStream bool
|
||||||
|
SystemRole string // "system" | "developer" | "" = eigenes Request-Feld
|
||||||
|
ExplicitCache bool
|
||||||
|
Reasoning bool
|
||||||
|
StrictSchemas bool
|
||||||
|
MaxContext int
|
||||||
|
SupportsImages bool
|
||||||
|
}
|
||||||
90
internal/llm/registry/registry.go
Normal file
90
internal/llm/registry/registry.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Package registry löst Modell-Namen auf Caps-Defaults auf (E-10):
|
||||||
|
// "OpenAI-kompatibel" ist ein Sammelbegriff, Groq/Cerebras/vLLM/Ollama/
|
||||||
|
// LM Studio/OpenRouter verhalten sich unterschiedlich. Die Tabelle hier ist
|
||||||
|
// bewusst klein — unbekannte Modelle fallen auf einen konservativen Default,
|
||||||
|
// Config-Overrides (CapsOverride) korrigieren im Einzelfall.
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import "nub/internal/llm"
|
||||||
|
|
||||||
|
var known = map[string]llm.Caps{
|
||||||
|
"gpt-4o": {
|
||||||
|
ParallelToolCalls: true, UsageInStream: true, SystemRole: "system",
|
||||||
|
MaxContext: 128000,
|
||||||
|
},
|
||||||
|
"gpt-4o-mini": {
|
||||||
|
ParallelToolCalls: true, UsageInStream: true, SystemRole: "system",
|
||||||
|
MaxContext: 128000,
|
||||||
|
},
|
||||||
|
"gpt-4.1": {
|
||||||
|
ParallelToolCalls: true, UsageInStream: true, SystemRole: "system",
|
||||||
|
MaxContext: 1000000,
|
||||||
|
},
|
||||||
|
"o1": {
|
||||||
|
ParallelToolCalls: false, UsageInStream: true, SystemRole: "developer",
|
||||||
|
Reasoning: true, MaxContext: 200000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCaps liefert die bekannten Caps für ein Modell, oder einen
|
||||||
|
// Fallback für unbekannte Modelle (z.B. neuere OpenAI-Modelle, die noch
|
||||||
|
// nicht in der Tabelle stehen, oder Drittanbieter/lokale Endpoints).
|
||||||
|
//
|
||||||
|
// UsageInStream im Fallback ist bewusst true: stream_options.include_usage
|
||||||
|
// ist ein Request-Flag, kein modellspezifisches Feature — praktisch jedes
|
||||||
|
// OpenAI-Modell (auch zukünftige) honoriert es. Ein Endpoint, der es nicht
|
||||||
|
// unterstützt, ist die Ausnahme, nicht die Regel, und lässt sich gezielt
|
||||||
|
// über [endpoint.caps] usage_in_stream = false abschalten.
|
||||||
|
func DefaultCaps(model string) llm.Caps {
|
||||||
|
if caps, ok := known[model]; ok {
|
||||||
|
return caps
|
||||||
|
}
|
||||||
|
return llm.Caps{
|
||||||
|
ParallelToolCalls: true,
|
||||||
|
UsageInStream: true,
|
||||||
|
SystemRole: "system",
|
||||||
|
MaxContext: 32000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapsOverride überschreibt einzelne Caps-Felder aus der Endpoint-Config.
|
||||||
|
// Pointer-Felder unterscheiden "nicht gesetzt" von "explizit false".
|
||||||
|
type CapsOverride struct {
|
||||||
|
ParallelToolCalls *bool `toml:"parallel_tool_calls"`
|
||||||
|
UsageInStream *bool `toml:"usage_in_stream"`
|
||||||
|
SystemRole *string `toml:"system_role"`
|
||||||
|
ExplicitCache *bool `toml:"explicit_cache"`
|
||||||
|
Reasoning *bool `toml:"reasoning"`
|
||||||
|
StrictSchemas *bool `toml:"strict_schemas"`
|
||||||
|
MaxContext *int `toml:"max_context"`
|
||||||
|
SupportsImages *bool `toml:"supports_images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply merged eine CapsOverride über eine Basis-Caps-Struktur.
|
||||||
|
func Apply(base llm.Caps, o CapsOverride) llm.Caps {
|
||||||
|
if o.ParallelToolCalls != nil {
|
||||||
|
base.ParallelToolCalls = *o.ParallelToolCalls
|
||||||
|
}
|
||||||
|
if o.UsageInStream != nil {
|
||||||
|
base.UsageInStream = *o.UsageInStream
|
||||||
|
}
|
||||||
|
if o.SystemRole != nil {
|
||||||
|
base.SystemRole = *o.SystemRole
|
||||||
|
}
|
||||||
|
if o.ExplicitCache != nil {
|
||||||
|
base.ExplicitCache = *o.ExplicitCache
|
||||||
|
}
|
||||||
|
if o.Reasoning != nil {
|
||||||
|
base.Reasoning = *o.Reasoning
|
||||||
|
}
|
||||||
|
if o.StrictSchemas != nil {
|
||||||
|
base.StrictSchemas = *o.StrictSchemas
|
||||||
|
}
|
||||||
|
if o.MaxContext != nil {
|
||||||
|
base.MaxContext = *o.MaxContext
|
||||||
|
}
|
||||||
|
if o.SupportsImages != nil {
|
||||||
|
base.SupportsImages = *o.SupportsImages
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
35
internal/llm/registry/registry_test.go
Normal file
35
internal/llm/registry/registry_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestDefaultCaps_UnknownModelStillReportsUsage sichert einen konkreten Bug
|
||||||
|
// ab: für unbekannte Modelle (z.B. neue OpenAI-Modelle, die noch nicht in
|
||||||
|
// `known` stehen) darf UsageInStream nicht fälschlich auf false fallen —
|
||||||
|
// sonst bleiben Token-/Cache-Zahlen in der UI dauerhaft bei 0, obwohl der
|
||||||
|
// Endpoint stream_options.include_usage längst unterstützt.
|
||||||
|
func TestDefaultCaps_UnknownModelStillReportsUsage(t *testing.T) {
|
||||||
|
caps := DefaultCaps("gpt-5-nano") // absichtlich nicht in der known-Tabelle
|
||||||
|
if !caps.UsageInStream {
|
||||||
|
t.Error("unknown model should default to UsageInStream=true, not silently disable usage reporting")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultCaps_KnownModelUsesTable(t *testing.T) {
|
||||||
|
caps := DefaultCaps("gpt-4o")
|
||||||
|
if caps.MaxContext != 128000 {
|
||||||
|
t.Errorf("MaxContext = %d, want 128000 for a known model", caps.MaxContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApply_OverridesOnlySetFields(t *testing.T) {
|
||||||
|
base := DefaultCaps("gpt-4o")
|
||||||
|
disabled := false
|
||||||
|
got := Apply(base, CapsOverride{UsageInStream: &disabled})
|
||||||
|
|
||||||
|
if got.UsageInStream {
|
||||||
|
t.Error("explicit override to false must take effect")
|
||||||
|
}
|
||||||
|
if got.MaxContext != base.MaxContext {
|
||||||
|
t.Error("fields not present in the override must stay untouched")
|
||||||
|
}
|
||||||
|
}
|
||||||
106
internal/mcpc/bridge.go
Normal file
106
internal/mcpc/bridge.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package mcpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"nub/internal/config"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const toolCountWarnThreshold = 15
|
||||||
|
|
||||||
|
// bridgeTool bindet ein einzelnes MCP-Tool eines Servers als tool.Tool ein.
|
||||||
|
// ForModel sammelt die Textblöcke, ForUI trägt den strukturierten Content.
|
||||||
|
type bridgeTool struct {
|
||||||
|
server string
|
||||||
|
def *mcp.Tool
|
||||||
|
session *mcp.ClientSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bridgeTool) Name() string { return t.server + "__" + t.def.Name }
|
||||||
|
func (t *bridgeTool) Description() string { return t.def.Description }
|
||||||
|
|
||||||
|
func (t *bridgeTool) Schema() json.RawMessage {
|
||||||
|
b, err := json.Marshal(t.def.InputSchema)
|
||||||
|
if err != nil {
|
||||||
|
return json.RawMessage(`{"type":"object"}`)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *bridgeTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var args map[string]any
|
||||||
|
if len(input) > 0 {
|
||||||
|
if err := json.Unmarshal(input, &args); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := t.session.CallTool(ctx, &mcp.CallToolParams{Name: t.def.Name, Arguments: args})
|
||||||
|
if err != nil {
|
||||||
|
// Server-Ausfall zur Laufzeit: IsError statt Panic/Loop-Abbruch (5.6).
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("mcp %s: %v", t.Name(), err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var textParts []string
|
||||||
|
for _, c := range res.Content {
|
||||||
|
if tc, ok := c.(*mcp.TextContent); ok {
|
||||||
|
textParts = append(textParts, tc.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool.Result{
|
||||||
|
ForModel: strings.Join(textParts, "\n"),
|
||||||
|
ForUI: Result{Server: t.server, Content: res.Content},
|
||||||
|
IsError: res.IsError,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result ist die strukturierte UI-Repräsentation eines MCP-Tool-Aufrufs.
|
||||||
|
type Result struct {
|
||||||
|
Server string
|
||||||
|
Content []mcp.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTools listet die Tools eines verbundenen Servers, filtert nach der
|
||||||
|
// konfigurierten Allowlist (cfg.Tools) und registriert sie mit
|
||||||
|
// <server>__-Präfix. Ohne Allowlist werden alle Tools geladen, aber ab
|
||||||
|
// mehr als 15 Tools gewarnt (E-07).
|
||||||
|
func RegisterTools(ctx context.Context, reg *tool.Registry, srv *Server, cfg config.MCPServer) ([]string, error) {
|
||||||
|
var all []*mcp.Tool
|
||||||
|
for t, err := range srv.Session.Tools(ctx, nil) {
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all = append(all, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
var warnings []string
|
||||||
|
allowed := all
|
||||||
|
if len(cfg.Tools) > 0 {
|
||||||
|
allowSet := make(map[string]bool, len(cfg.Tools))
|
||||||
|
for _, n := range cfg.Tools {
|
||||||
|
allowSet[n] = true
|
||||||
|
}
|
||||||
|
allowed = make([]*mcp.Tool, 0, len(all))
|
||||||
|
for _, t := range all {
|
||||||
|
if allowSet[t.Name] {
|
||||||
|
allowed = append(allowed, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if len(all) > toolCountWarnThreshold {
|
||||||
|
warnings = append(warnings, fmt.Sprintf(
|
||||||
|
"mcp server %q liefert %d Tools ohne Allowlist — 'tools = [...]' in der Config empfohlen",
|
||||||
|
cfg.Name, len(all)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range allowed {
|
||||||
|
reg.Register(&bridgeTool{server: cfg.Name, def: t, session: srv.Session})
|
||||||
|
}
|
||||||
|
return warnings, nil
|
||||||
|
}
|
||||||
132
internal/mcpc/bridge_test.go
Normal file
132
internal/mcpc/bridge_test.go
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
package mcpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"nub/internal/config"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type echoArgs struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTestServer läuft komplett in-process über mcp.NewInMemoryTransports —
|
||||||
|
// ein echter MCP-Server/-Client-Roundtrip ohne Subprozess oder Netzwerk.
|
||||||
|
func startTestServer(t *testing.T, toolNames ...string) *mcp.ClientSession {
|
||||||
|
t.Helper()
|
||||||
|
if len(toolNames) == 0 {
|
||||||
|
toolNames = []string{"echo"}
|
||||||
|
}
|
||||||
|
|
||||||
|
server := mcp.NewServer(&mcp.Implementation{Name: "test-server"}, nil)
|
||||||
|
for _, name := range toolNames {
|
||||||
|
name := name
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: name, Description: "echoes text"},
|
||||||
|
func(ctx context.Context, req *mcp.CallToolRequest, args echoArgs) (*mcp.CallToolResult, any, error) {
|
||||||
|
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: name + ":" + args.Text}}}, nil, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
serverTransport, clientTransport := mcp.NewInMemoryTransports()
|
||||||
|
ctx := context.Background()
|
||||||
|
go func() { _ = server.Run(ctx, serverTransport) }()
|
||||||
|
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil)
|
||||||
|
session, err := client.Connect(ctx, clientTransport, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = session.Close() })
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBridge_RegisterAndCallTool(t *testing.T) {
|
||||||
|
session := startTestServer(t, "echo")
|
||||||
|
srv := &Server{Name: "testsrv", Session: session}
|
||||||
|
|
||||||
|
reg := tool.NewRegistry()
|
||||||
|
warnings, err := RegisterTools(context.Background(), reg, srv, config.MCPServer{Name: "testsrv"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(warnings) != 0 {
|
||||||
|
t.Errorf("unexpected warnings: %v", warnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := reg.Get("testsrv__echo")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected tool testsrv__echo to be registered with server-prefixed name")
|
||||||
|
}
|
||||||
|
|
||||||
|
input, _ := json.Marshal(map[string]string{"text": "hi"})
|
||||||
|
res, err := got.Run(context.Background(), input, tool.Env{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error result: %s", res.ForModel)
|
||||||
|
}
|
||||||
|
if res.ForModel != "echo:hi" {
|
||||||
|
t.Errorf("ForModel = %q, want echo:hi", res.ForModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBridge_AllowlistFiltersTools(t *testing.T) {
|
||||||
|
session := startTestServer(t, "echo", "danger")
|
||||||
|
srv := &Server{Name: "testsrv", Session: session}
|
||||||
|
|
||||||
|
reg := tool.NewRegistry()
|
||||||
|
_, err := RegisterTools(context.Background(), reg, srv, config.MCPServer{Name: "testsrv", Tools: []string{"echo"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := reg.Get("testsrv__echo"); !ok {
|
||||||
|
t.Error("expected allowed tool to be registered")
|
||||||
|
}
|
||||||
|
if _, ok := reg.Get("testsrv__danger"); ok {
|
||||||
|
t.Error("expected non-allowlisted tool to be filtered out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBridge_RuntimeFailureIsErrorNotPanic(t *testing.T) {
|
||||||
|
session := startTestServer(t, "echo")
|
||||||
|
srv := &Server{Name: "testsrv", Session: session}
|
||||||
|
|
||||||
|
reg := tool.NewRegistry()
|
||||||
|
if _, err := RegisterTools(context.Background(), reg, srv, config.MCPServer{Name: "testsrv"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _ := reg.Get("testsrv__echo")
|
||||||
|
|
||||||
|
// Server-Ausfall simulieren: Session vor dem Aufruf schließen.
|
||||||
|
if err := session.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
input, _ := json.Marshal(map[string]string{"text": "hi"})
|
||||||
|
res, err := got.Run(context.Background(), input, tool.Env{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run should not return a Go error on server failure, got: %v", err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Error("expected IsError=true when the underlying MCP session is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectAll_BrokenServerDoesNotBlockStart(t *testing.T) {
|
||||||
|
result := ConnectAll(context.Background(), []config.MCPServer{
|
||||||
|
{Name: "broken", Command: "this-binary-does-not-exist-xyz"},
|
||||||
|
})
|
||||||
|
if len(result.Servers) != 0 {
|
||||||
|
t.Errorf("expected no connected servers, got %d", len(result.Servers))
|
||||||
|
}
|
||||||
|
if len(result.Warnings) != 1 {
|
||||||
|
t.Fatalf("expected exactly one warning, got %v", result.Warnings)
|
||||||
|
}
|
||||||
|
}
|
||||||
90
internal/mcpc/connect.go
Normal file
90
internal/mcpc/connect.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Package mcpc ist der MCP-Client: Verbindungsaufbau, Bridge MCP-Tool ->
|
||||||
|
// tool.Tool (5.6).
|
||||||
|
package mcpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"nub/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultConnectTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// Server ist eine erfolgreich verbundene MCP-Session.
|
||||||
|
type Server struct {
|
||||||
|
Name string
|
||||||
|
Session *mcp.ClientSession
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectResult struct {
|
||||||
|
Servers []*Server
|
||||||
|
Warnings []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectAll verbindet alle konfigurierten Server parallel, mit einem
|
||||||
|
// Timeout pro Server (Default 5s, E-06). Ein einzelner kaputter Server
|
||||||
|
// verhindert den Start nicht — er fällt raus, mit einer Warnung.
|
||||||
|
func ConnectAll(ctx context.Context, servers []config.MCPServer) ConnectResult {
|
||||||
|
type outcome struct {
|
||||||
|
srv *Server
|
||||||
|
warn string
|
||||||
|
}
|
||||||
|
ch := make(chan outcome, len(servers))
|
||||||
|
|
||||||
|
for _, cfg := range servers {
|
||||||
|
go func(cfg config.MCPServer) {
|
||||||
|
connCtx, cancel := context.WithTimeout(ctx, defaultConnectTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
session, err := connect(connCtx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
ch <- outcome{warn: fmt.Sprintf("mcp server %q: %v — skipped", cfg.Name, err)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- outcome{srv: &Server{Name: cfg.Name, Session: session}}
|
||||||
|
}(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result ConnectResult
|
||||||
|
for range servers {
|
||||||
|
o := <-ch
|
||||||
|
if o.srv != nil {
|
||||||
|
result.Servers = append(result.Servers, o.srv)
|
||||||
|
}
|
||||||
|
if o.warn != "" {
|
||||||
|
result.Warnings = append(result.Warnings, o.warn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(ctx context.Context, cfg config.MCPServer) (*mcp.ClientSession, error) {
|
||||||
|
client := mcp.NewClient(&mcp.Implementation{Name: "nub", Version: "0.1.0"}, nil)
|
||||||
|
|
||||||
|
var transport mcp.Transport
|
||||||
|
switch {
|
||||||
|
case cfg.Command != "":
|
||||||
|
// Der Prozess läuft für die gesamte Session, nicht nur für den
|
||||||
|
// Verbindungsaufbau — deshalb kein an connCtx gebundenes CommandContext.
|
||||||
|
transport = &mcp.CommandTransport{Command: exec.Command(cfg.Command, cfg.Args...)}
|
||||||
|
case cfg.URL != "":
|
||||||
|
transport = &mcp.StreamableClientTransport{Endpoint: cfg.URL}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("neither command nor url configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.Connect(ctx, transport, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseAll schließt alle Sessions; Fehler werden ignoriert (best effort beim
|
||||||
|
// Beenden).
|
||||||
|
func CloseAll(servers []*Server) {
|
||||||
|
for _, s := range servers {
|
||||||
|
_ = s.Session.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/mcpc/profile.go
Normal file
37
internal/mcpc/profile.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package mcpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"nub/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SelectServers filtert die konfigurierten MCP-Server nach einem Profil
|
||||||
|
// (4.7: `[profiles] review = ["github"]`). Ohne konfigurierte Profile
|
||||||
|
// werden alle Server verbunden. Ist profileName leer, wird "default"
|
||||||
|
// verwendet, falls definiert.
|
||||||
|
func SelectServers(servers []config.MCPServer, profiles map[string][]string, profileName string) ([]config.MCPServer, error) {
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
if profileName == "" {
|
||||||
|
profileName = "default"
|
||||||
|
}
|
||||||
|
names, ok := profiles[profileName]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown profile %q", profileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
allow := make(map[string]bool, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
allow[n] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []config.MCPServer
|
||||||
|
for _, s := range servers {
|
||||||
|
if allow[s.Name] {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
53
internal/mcpc/profile_test.go
Normal file
53
internal/mcpc/profile_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package mcpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectServers_NoProfilesConfigured_ReturnsAll(t *testing.T) {
|
||||||
|
servers := []config.MCPServer{{Name: "a"}, {Name: "b"}}
|
||||||
|
out, err := SelectServers(servers, nil, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Errorf("got %d servers, want 2 (no filtering without profiles)", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectServers_FiltersToNamedProfile(t *testing.T) {
|
||||||
|
servers := []config.MCPServer{{Name: "github"}, {Name: "linear"}}
|
||||||
|
profiles := map[string][]string{"review": {"github"}, "default": {}}
|
||||||
|
|
||||||
|
out, err := SelectServers(servers, profiles, "review")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 || out[0].Name != "github" {
|
||||||
|
t.Errorf("got %+v, want only github", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectServers_DefaultsToDefaultProfile(t *testing.T) {
|
||||||
|
servers := []config.MCPServer{{Name: "github"}}
|
||||||
|
profiles := map[string][]string{"default": {}}
|
||||||
|
|
||||||
|
out, err := SelectServers(servers, profiles, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("got %+v, want empty (default profile has no servers)", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectServers_UnknownProfileErrors(t *testing.T) {
|
||||||
|
servers := []config.MCPServer{{Name: "github"}}
|
||||||
|
profiles := map[string][]string{"default": {}}
|
||||||
|
|
||||||
|
if _, err := SelectServers(servers, profiles, "does-not-exist"); err == nil {
|
||||||
|
t.Fatal("expected error for unknown profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
104
internal/permission/policy.go
Normal file
104
internal/permission/policy.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
// Package permission entscheidet pro Tool-Aufruf, ob er automatisch laufen
|
||||||
|
// darf, eine Rückfrage braucht, oder abgelehnt wird (E-11). Bewusst
|
||||||
|
// config-unabhängig (nur Strings/Maps) — der Aufrufer übersetzt
|
||||||
|
// config.PermissionsConfig in eine Policy, damit dieses Paket ohne
|
||||||
|
// Config-Kopplung testbar bleibt.
|
||||||
|
package permission
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeAuto Mode = "auto"
|
||||||
|
ModeAsk Mode = "ask"
|
||||||
|
ModeDeny Mode = "deny"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Policy ist die ausgewertete Permissions-Config für eine Session.
|
||||||
|
type Policy struct {
|
||||||
|
// Modes bildet Tool-Namen auf einen Modus ab. Tools ohne Eintrag (z.B.
|
||||||
|
// MCP-Tools, todo, read_skill) gelten als ModeAuto.
|
||||||
|
Modes map[string]Mode
|
||||||
|
|
||||||
|
// DenyPaths/DenyBash sind zusätzliche Sperren, die "auto" und "ask"
|
||||||
|
// immer überstimmen — eine explizite Deny-Regel gewinnt.
|
||||||
|
DenyPaths []string
|
||||||
|
DenyBash []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModeFor liefert den konfigurierten Modus für ein Tool, ohne Pfad-/Bash-
|
||||||
|
// Denylist zu prüfen. Unbekannte Tools und ein nil-Policy ergeben ModeAuto.
|
||||||
|
func (p *Policy) ModeFor(toolName string) Mode {
|
||||||
|
if p == nil {
|
||||||
|
return ModeAuto
|
||||||
|
}
|
||||||
|
if m, ok := p.Modes[toolName]; ok && m != "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return ModeAuto
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check entscheidet den tatsächlichen Modus für einen konkreten Aufruf
|
||||||
|
// inklusive Pfad-/Bash-Denylist. Eine Deny-Regel überstimmt immer, auch
|
||||||
|
// wenn das Tool selbst auf "auto" oder "ask" steht.
|
||||||
|
func (p *Policy) Check(toolName string, input json.RawMessage) Mode {
|
||||||
|
mode := p.ModeFor(toolName)
|
||||||
|
if p == nil {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
if path := extractPath(input); path != "" && matchesAny(p.DenyPaths, path) {
|
||||||
|
return ModeDeny
|
||||||
|
}
|
||||||
|
if toolName == "bash" {
|
||||||
|
if cmd := extractCommand(input); cmd != "" && matchesAny(p.DenyBash, cmd) {
|
||||||
|
return ModeDeny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractPath(input json.RawMessage) string {
|
||||||
|
var v struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(input, &v); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractCommand(input json.RawMessage) string {
|
||||||
|
var v struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(input, &v); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesAny prüft ein Glob-Match (deny_paths: ".git/**", "**/id_rsa*") und
|
||||||
|
// zusätzlich ein Substring-Match, weil deny_bash-Beispiele im Konzept
|
||||||
|
// literale Fragmente ohne Wildcard sind ("rm -rf /") — ein reines
|
||||||
|
// Vollstring-Glob-Match würde "cd /tmp && rm -rf /" nicht fangen. Für eine
|
||||||
|
// Denylist ist Über-Treffen die sicherere Richtung als Unter-Treffen.
|
||||||
|
func matchesAny(patterns []string, s string) bool {
|
||||||
|
for _, p := range patterns {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok, _ := doublestar.Match(p, s); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(s, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
102
internal/permission/policy_test.go
Normal file
102
internal/permission/policy_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package permission
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func input(t *testing.T, v any) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeFor_UnknownToolDefaultsToAuto(t *testing.T) {
|
||||||
|
p := &Policy{Modes: map[string]Mode{"edit": ModeAsk}}
|
||||||
|
if got := p.ModeFor("github__create_issue"); got != ModeAuto {
|
||||||
|
t.Errorf("ModeFor(unknown) = %q, want auto", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeFor_NilPolicyDefaultsToAuto(t *testing.T) {
|
||||||
|
var p *Policy
|
||||||
|
if got := p.ModeFor("edit"); got != ModeAuto {
|
||||||
|
t.Errorf("ModeFor on nil policy = %q, want auto", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeFor_UsesConfiguredMode(t *testing.T) {
|
||||||
|
p := &Policy{Modes: map[string]Mode{"edit": ModeAsk, "bash": ModeDeny}}
|
||||||
|
if got := p.ModeFor("edit"); got != ModeAsk {
|
||||||
|
t.Errorf("edit = %q, want ask", got)
|
||||||
|
}
|
||||||
|
if got := p.ModeFor("bash"); got != ModeDeny {
|
||||||
|
t.Errorf("bash = %q, want deny", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_DenyPathOverridesAutoMode(t *testing.T) {
|
||||||
|
p := &Policy{
|
||||||
|
Modes: map[string]Mode{"edit": ModeAuto},
|
||||||
|
DenyPaths: []string{".git/**", "**/.env", "**/id_rsa*"},
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want Mode
|
||||||
|
}{
|
||||||
|
{".git/config", ModeDeny},
|
||||||
|
{".env", ModeDeny},
|
||||||
|
{"sub/.env", ModeDeny},
|
||||||
|
{"id_rsa", ModeDeny},
|
||||||
|
{"~/.ssh/id_rsa_backup", ModeDeny}, // "**/id_rsa*" matcht auch mit Verzeichnis-Präfix
|
||||||
|
{"main.go", ModeAuto},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := p.Check("edit", input(t, map[string]string{"path": c.path}))
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("Check(edit, path=%q) = %q, want %q", c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_DenyBashOverridesAutoMode(t *testing.T) {
|
||||||
|
p := &Policy{
|
||||||
|
Modes: map[string]Mode{"bash": ModeAuto},
|
||||||
|
DenyBash: []string{"rm -rf /", "git push --force*"},
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
cmd string
|
||||||
|
want Mode
|
||||||
|
}{
|
||||||
|
{"rm -rf /", ModeDeny},
|
||||||
|
{"cd /tmp && rm -rf / --no-preserve-root", ModeDeny},
|
||||||
|
{"git push --force origin main", ModeDeny},
|
||||||
|
{"git status", ModeAuto},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := p.Check("bash", input(t, map[string]string{"command": c.cmd}))
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("Check(bash, command=%q) = %q, want %q", c.cmd, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_NonPathToolIgnoresDenyPaths(t *testing.T) {
|
||||||
|
p := &Policy{DenyPaths: []string{"**/.env"}}
|
||||||
|
// todo hat kein "path"-Feld -> DenyPaths darf nicht versehentlich greifen.
|
||||||
|
got := p.Check("todo", input(t, map[string]any{"items": []any{}}))
|
||||||
|
if got != ModeAuto {
|
||||||
|
t.Errorf("Check(todo) = %q, want auto (no path field to match against)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_AskModePreservedWithoutDenyMatch(t *testing.T) {
|
||||||
|
p := &Policy{Modes: map[string]Mode{"edit": ModeAsk}}
|
||||||
|
got := p.Check("edit", input(t, map[string]string{"path": "main.go"}))
|
||||||
|
if got != ModeAsk {
|
||||||
|
t.Errorf("Check(edit) = %q, want ask", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
internal/scaffold/init.go
Normal file
133
internal/scaffold/init.go
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// Package scaffold legt die Grundausstattung für ein Projekt an
|
||||||
|
// (`nub init` / `/init`): .nub/config.toml mit einer kommentierten
|
||||||
|
// Beispiel-Config und ein minimalistisches AGENTS.md. Von TUI und CLI
|
||||||
|
// gemeinsam genutzt.
|
||||||
|
package scaffold
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"nub/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result protokolliert, was Init tatsächlich angelegt bzw. übersprungen hat.
|
||||||
|
type Result struct {
|
||||||
|
ConfigPath string
|
||||||
|
ConfigCreated bool
|
||||||
|
AgentsPath string
|
||||||
|
AgentsCreated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init legt .nub/config.toml und AGENTS.md an, falls sie noch nicht
|
||||||
|
// existieren. Bestehende Dateien werden nie überschrieben — Init meldet in
|
||||||
|
// Result nur, was es tatsächlich geschrieben hat.
|
||||||
|
func Init(repoRoot string) (Result, error) {
|
||||||
|
var res Result
|
||||||
|
|
||||||
|
nubDir := filepath.Join(repoRoot, ".nub")
|
||||||
|
if err := os.MkdirAll(nubDir, 0o700); err != nil {
|
||||||
|
return res, fmt.Errorf("scaffold: .nub anlegen: %w", err)
|
||||||
|
}
|
||||||
|
if err := session.EnsureGitExclude(repoRoot); err != nil {
|
||||||
|
return res, fmt.Errorf("scaffold: git exclude: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.ConfigPath = filepath.Join(nubDir, "config.toml")
|
||||||
|
created, err := writeIfAbsent(res.ConfigPath, exampleConfigTOML, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("scaffold: config.toml: %w", err)
|
||||||
|
}
|
||||||
|
res.ConfigCreated = created
|
||||||
|
|
||||||
|
res.AgentsPath = filepath.Join(repoRoot, "AGENTS.md")
|
||||||
|
created, err = writeIfAbsent(res.AgentsPath, exampleAgentsMD, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("scaffold: AGENTS.md: %w", err)
|
||||||
|
}
|
||||||
|
res.AgentsCreated = created
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeIfAbsent(path, content string, perm os.FileMode) (bool, error) {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return false, nil // existiert schon -> nicht anfassen
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), perm); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const exampleConfigTOML = `version = 1
|
||||||
|
|
||||||
|
[model]
|
||||||
|
default = "gpt-4o-mini"
|
||||||
|
endpoint = "openai"
|
||||||
|
|
||||||
|
[[endpoint]]
|
||||||
|
name = "openai"
|
||||||
|
base_url = "https://api.openai.com/v1"
|
||||||
|
# "env:VARNAME" liest den Key aus einer Umgebungsvariable; alternativ direkt
|
||||||
|
# den Key eintragen, oder "none" für einen lokalen Endpoint ohne Key.
|
||||||
|
api_key = "env:OPENAI_API_KEY"
|
||||||
|
|
||||||
|
# [[endpoint]]
|
||||||
|
# name = "local"
|
||||||
|
# base_url = "http://localhost:11434/v1"
|
||||||
|
# api_key = "none"
|
||||||
|
# [endpoint.caps]
|
||||||
|
# parallel_tool_calls = false
|
||||||
|
|
||||||
|
[context]
|
||||||
|
files = ["AGENTS.md", "REPOMAP.md"]
|
||||||
|
walk_up = true
|
||||||
|
max_tokens = 20000
|
||||||
|
|
||||||
|
[skills]
|
||||||
|
paths = ["~/.nub/skills", ".nub/skills"]
|
||||||
|
|
||||||
|
# Pro Tool: "auto" (ohne Rückfrage), "ask" (Rückfrage in der TUI, im
|
||||||
|
# Print-Modus ein harter Fehler) oder "deny" (nie). deny_paths/deny_bash
|
||||||
|
# überstimmen den Modus immer, auch bei "auto".
|
||||||
|
[permissions]
|
||||||
|
read = "auto"
|
||||||
|
glob = "auto"
|
||||||
|
grep = "auto"
|
||||||
|
write = "ask"
|
||||||
|
edit = "ask"
|
||||||
|
bash = "ask"
|
||||||
|
deny_paths = [".git/**", "**/.env", "**/id_rsa*"]
|
||||||
|
deny_bash = ["rm -rf /", "git push --force*"]
|
||||||
|
|
||||||
|
# [[mcp]]
|
||||||
|
# name = "github"
|
||||||
|
# command = "gh-mcp-server"
|
||||||
|
# tools = ["create_issue", "get_pull_request"]
|
||||||
|
|
||||||
|
# [profiles]
|
||||||
|
# review = ["github"]
|
||||||
|
# default = []
|
||||||
|
`
|
||||||
|
|
||||||
|
const exampleAgentsMD = `# Agent Instructions
|
||||||
|
|
||||||
|
Kurze, projektspezifische Hinweise für nub. Diese Datei landet automatisch
|
||||||
|
im System-Prompt jeder Session.
|
||||||
|
|
||||||
|
## Projekt
|
||||||
|
|
||||||
|
<Was macht dieses Projekt, in ein bis zwei Sätzen?>
|
||||||
|
|
||||||
|
## Konventionen
|
||||||
|
|
||||||
|
- <z.B. Codestil, Teststrategie, Verzeichnisstruktur>
|
||||||
|
|
||||||
|
## Vorsicht bei
|
||||||
|
|
||||||
|
- <z.B. generierte Dateien, sensible Verzeichnisse, kritische Module>
|
||||||
|
`
|
||||||
130
internal/scaffold/init_test.go
Normal file
130
internal/scaffold/init_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package scaffold
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInit_CreatesConfigAndAgentsFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
res, err := Init(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.ConfigCreated {
|
||||||
|
t.Error("expected config.toml to be created")
|
||||||
|
}
|
||||||
|
if !res.AgentsCreated {
|
||||||
|
t.Error("expected AGENTS.md to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".nub", "config.toml")); err != nil {
|
||||||
|
t.Errorf(".nub/config.toml missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil {
|
||||||
|
t.Errorf("AGENTS.md missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, ".nub", "config.toml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(string(content), "[permissions]") {
|
||||||
|
t.Error("expected the example config to include a [permissions] section")
|
||||||
|
}
|
||||||
|
if !contains(string(content), "version = 1") {
|
||||||
|
t.Error("expected the example config to declare version = 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_DoesNotOverwriteExistingFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, ".nub"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
customConfig := "version = 1\n# my custom config, do not touch\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, ".nub", "config.toml"), []byte(customConfig), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
customAgents := "# my custom AGENTS.md\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(customAgents), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := Init(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.ConfigCreated {
|
||||||
|
t.Error("expected an existing config.toml not to be reported as created")
|
||||||
|
}
|
||||||
|
if res.AgentsCreated {
|
||||||
|
t.Error("expected an existing AGENTS.md not to be reported as created")
|
||||||
|
}
|
||||||
|
|
||||||
|
gotConfig, _ := os.ReadFile(filepath.Join(dir, ".nub", "config.toml"))
|
||||||
|
if string(gotConfig) != customConfig {
|
||||||
|
t.Error("existing config.toml must not be overwritten")
|
||||||
|
}
|
||||||
|
gotAgents, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
|
||||||
|
if string(gotAgents) != customAgents {
|
||||||
|
t.Error("existing AGENTS.md must not be overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_GeneratedConfigParsesAndRoundTripsPermissions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("NUB_MODEL", "")
|
||||||
|
t.Setenv("NUB_BASE_URL", "")
|
||||||
|
t.Setenv("NUB_API_KEY", "")
|
||||||
|
t.Setenv("OPENAI_API_KEY", "dummy-for-this-test")
|
||||||
|
|
||||||
|
if _, err := Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generated config.toml failed to parse: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Permissions.Write != "ask" || cfg.Permissions.Edit != "ask" || cfg.Permissions.Bash != "ask" {
|
||||||
|
t.Errorf("expected write/edit/bash = ask in the generated config, got %+v", cfg.Permissions)
|
||||||
|
}
|
||||||
|
if len(cfg.Permissions.DenyPaths) == 0 {
|
||||||
|
t.Error("expected deny_paths to be populated in the generated config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInit_SetsUpGitExclude(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, ".git", "info"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := Init(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
excludeContent, err := os.ReadFile(filepath.Join(dir, ".git", "info", "exclude"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !contains(string(excludeContent), ".nub/") {
|
||||||
|
t.Error("expected .nub/ to be added to .git/info/exclude")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (func() bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})()
|
||||||
|
}
|
||||||
41
internal/session/gitexclude.go
Normal file
41
internal/session/gitexclude.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnsureGitExclude trägt .nub/ in .git/info/exclude ein, nicht in die
|
||||||
|
// .gitignore (4.5) — die gehört dem Repo, nicht dem Tool. Kein Git-Repo:
|
||||||
|
// no-op.
|
||||||
|
func EnsureGitExclude(repoRoot string) error {
|
||||||
|
gitDir := filepath.Join(repoRoot, ".git")
|
||||||
|
info, err := os.Stat(gitDir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
excludePath := filepath.Join(gitDir, "info", "exclude")
|
||||||
|
data, _ := os.ReadFile(excludePath)
|
||||||
|
if bytes.Contains(data, []byte(".nub/")) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if len(data) > 0 && data[len(data)-1] != '\n' {
|
||||||
|
if _, err := f.WriteString("\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = f.WriteString(".nub/\n")
|
||||||
|
return err
|
||||||
|
}
|
||||||
67
internal/session/node.go
Normal file
67
internal/session/node.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// Package session enthält das Baum-Modell der Konversation (E-03) und dessen
|
||||||
|
// Persistenz als Append-only JSONL (Abschnitt 4.5).
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NodeKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
NodeMessage NodeKind = "message"
|
||||||
|
NodeSummary NodeKind = "summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Node struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ParentID string `json:"parent_id"` // "" = Wurzel
|
||||||
|
Kind NodeKind `json:"kind"`
|
||||||
|
Message llm.Message `json:"message"`
|
||||||
|
Replaces []string `json:"replaces,omitempty"` // nur bei NodeSummary
|
||||||
|
Meta NodeMeta `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeMeta struct {
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Usage llm.Usage `json:"usage"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Bookmark bool `json:"bookmark,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNode erzeugt einen neuen Message-Node mit frischer ULID. parentID ist
|
||||||
|
// typischerweise der aktuelle Head.
|
||||||
|
func NewNode(parentID string, msg llm.Message, meta NodeMeta) *Node {
|
||||||
|
if meta.CreatedAt.IsZero() {
|
||||||
|
meta.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
return &Node{
|
||||||
|
ID: ulid.Make().String(),
|
||||||
|
ParentID: parentID,
|
||||||
|
Kind: NodeMessage,
|
||||||
|
Message: msg,
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSummaryNode erzeugt einen Summary-Node, der die Nodes in replaces aus
|
||||||
|
// dem aktiven Kontext ersetzt (E-08). Die ersetzten Nodes bleiben im Baum
|
||||||
|
// erhalten — kein destruktives Löschen, Rewind auf sie funktioniert weiter.
|
||||||
|
func NewSummaryNode(parentID, text string, replaces []string, meta NodeMeta) *Node {
|
||||||
|
if meta.CreatedAt.IsZero() {
|
||||||
|
meta.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
return &Node{
|
||||||
|
ID: ulid.Make().String(),
|
||||||
|
ParentID: parentID,
|
||||||
|
Kind: NodeSummary,
|
||||||
|
Message: llm.Message{Role: llm.RoleUser, Content: []llm.Block{{Kind: llm.KindText, Text: text}}},
|
||||||
|
Replaces: replaces,
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
}
|
||||||
92
internal/session/session.go
Normal file
92
internal/session/session.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session ist der reine In-Memory-Baum, ab Tag 1 (E-03): jeder Zugriff auf
|
||||||
|
// "die Historie" geht über PathToHead(), nie über einen Slice.
|
||||||
|
type Session struct {
|
||||||
|
ID string
|
||||||
|
Nodes map[string]*Node
|
||||||
|
Head string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(id string) *Session {
|
||||||
|
return &Session{ID: id, Nodes: make(map[string]*Node)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadID gibt den aktuellen Head zurück (Methode statt nur Feld, damit
|
||||||
|
// Session und Store gemeinsam gegen ein schmales Interface programmierbar
|
||||||
|
// sind, siehe internal/agent.Sessioner).
|
||||||
|
func (s *Session) HeadID() string { return s.Head }
|
||||||
|
|
||||||
|
// Append hängt einen Node an. ParentID muss "" (Wurzel, nur beim allerersten
|
||||||
|
// Node zulässig) oder ein existierender Node sein. Setzt Head auf den neuen
|
||||||
|
// Node.
|
||||||
|
func (s *Session) Append(n *Node) error {
|
||||||
|
if n.ParentID != "" {
|
||||||
|
if _, ok := s.Nodes[n.ParentID]; !ok {
|
||||||
|
return fmt.Errorf("session: unknown parent %q", n.ParentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exists := s.Nodes[n.ID]; exists {
|
||||||
|
return fmt.Errorf("session: node %q already exists", n.ID)
|
||||||
|
}
|
||||||
|
s.Nodes[n.ID] = n
|
||||||
|
s.Head = n.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch setzt Head auf einen früheren Knoten. Weiterarbeit danach hängt
|
||||||
|
// neue Nodes an diesem Knoten an — ein zweiter Ast entsteht implizit, weil
|
||||||
|
// mehrere Nodes denselben ParentID referenzieren können.
|
||||||
|
func (s *Session) Branch(from string) error {
|
||||||
|
if from == "" {
|
||||||
|
s.Head = ""
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, ok := s.Nodes[from]; !ok {
|
||||||
|
return fmt.Errorf("session: unknown node %q", from)
|
||||||
|
}
|
||||||
|
s.Head = from
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathNodes läuft von Head Richtung Wurzel und stoppt am ersten Summary-Node
|
||||||
|
// (E-08); das Ergebnis ist in Wurzel→Head-Reihenfolge. Anders als
|
||||||
|
// PathToHead liefert es die Nodes selbst — Grundlage für Compaction, die
|
||||||
|
// Node-IDs für Replaces braucht.
|
||||||
|
func (s *Session) PathNodes() []*Node {
|
||||||
|
var nodes []*Node
|
||||||
|
id := s.Head
|
||||||
|
for id != "" {
|
||||||
|
n, ok := s.Nodes[id]
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nodes = append(nodes, n)
|
||||||
|
if n.Kind == NodeSummary {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id = n.ParentID
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, j := 0, len(nodes)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
nodes[i], nodes[j] = nodes[j], nodes[i]
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathToHead läuft von Head Richtung Wurzel und stoppt am ersten
|
||||||
|
// Summary-Node (E-08); das Ergebnis ist in Wurzel→Head-Reihenfolge.
|
||||||
|
func (s *Session) PathToHead() []llm.Message {
|
||||||
|
nodes := s.PathNodes()
|
||||||
|
messages := make([]llm.Message, len(nodes))
|
||||||
|
for i, n := range nodes {
|
||||||
|
messages[i] = n.Message
|
||||||
|
}
|
||||||
|
return messages
|
||||||
|
}
|
||||||
106
internal/session/session_test.go
Normal file
106
internal/session/session_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func textMsg(role llm.Role, text string) llm.Message {
|
||||||
|
return llm.Message{Role: role, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSession_PathToHead_OrdersRootToHead(t *testing.T) {
|
||||||
|
s := New("s1")
|
||||||
|
n1 := NewNode("", textMsg(llm.RoleUser, "one"), NodeMeta{})
|
||||||
|
must(t, s.Append(n1))
|
||||||
|
n2 := NewNode(n1.ID, textMsg(llm.RoleAssistant, "two"), NodeMeta{})
|
||||||
|
must(t, s.Append(n2))
|
||||||
|
n3 := NewNode(n2.ID, textMsg(llm.RoleUser, "three"), NodeMeta{})
|
||||||
|
must(t, s.Append(n3))
|
||||||
|
|
||||||
|
msgs := s.PathToHead()
|
||||||
|
if len(msgs) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3", len(msgs))
|
||||||
|
}
|
||||||
|
want := []string{"one", "two", "three"}
|
||||||
|
for i, w := range want {
|
||||||
|
if msgs[i].Content[0].Text != w {
|
||||||
|
t.Errorf("msgs[%d] = %q, want %q", i, msgs[i].Content[0].Text, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSession_PathToHead_StopsAtSummary(t *testing.T) {
|
||||||
|
s := New("s1")
|
||||||
|
n1 := NewNode("", textMsg(llm.RoleUser, "old-1"), NodeMeta{})
|
||||||
|
must(t, s.Append(n1))
|
||||||
|
n2 := NewNode(n1.ID, textMsg(llm.RoleAssistant, "old-2"), NodeMeta{})
|
||||||
|
must(t, s.Append(n2))
|
||||||
|
|
||||||
|
summary := &Node{
|
||||||
|
ID: "summary-1",
|
||||||
|
ParentID: n2.ID,
|
||||||
|
Kind: NodeSummary,
|
||||||
|
Message: textMsg(llm.RoleUser, "SUMMARY of old-1/old-2"),
|
||||||
|
Replaces: []string{n1.ID, n2.ID},
|
||||||
|
}
|
||||||
|
must(t, s.Append(summary))
|
||||||
|
|
||||||
|
n3 := NewNode(summary.ID, textMsg(llm.RoleUser, "new"), NodeMeta{})
|
||||||
|
must(t, s.Append(n3))
|
||||||
|
|
||||||
|
msgs := s.PathToHead()
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2 (summary + new)", len(msgs))
|
||||||
|
}
|
||||||
|
if msgs[0].Content[0].Text != "SUMMARY of old-1/old-2" {
|
||||||
|
t.Errorf("msgs[0] = %q", msgs[0].Content[0].Text)
|
||||||
|
}
|
||||||
|
if msgs[1].Content[0].Text != "new" {
|
||||||
|
t.Errorf("msgs[1] = %q", msgs[1].Content[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSession_Branch_CreatesSecondPath(t *testing.T) {
|
||||||
|
s := New("s1")
|
||||||
|
n1 := NewNode("", textMsg(llm.RoleUser, "root"), NodeMeta{})
|
||||||
|
must(t, s.Append(n1))
|
||||||
|
n2a := NewNode(n1.ID, textMsg(llm.RoleAssistant, "branch-a"), NodeMeta{})
|
||||||
|
must(t, s.Append(n2a))
|
||||||
|
|
||||||
|
must(t, s.Branch(n1.ID))
|
||||||
|
n2b := NewNode(n1.ID, textMsg(llm.RoleAssistant, "branch-b"), NodeMeta{})
|
||||||
|
must(t, s.Append(n2b))
|
||||||
|
|
||||||
|
if s.Head != n2b.ID {
|
||||||
|
t.Fatalf("head = %s, want %s", s.Head, n2b.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
must(t, s.Branch(n2a.ID))
|
||||||
|
msgsA := s.PathToHead()
|
||||||
|
if msgsA[len(msgsA)-1].Content[0].Text != "branch-a" {
|
||||||
|
t.Errorf("branch a tip = %q", msgsA[len(msgsA)-1].Content[0].Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
must(t, s.Branch(n2b.ID))
|
||||||
|
msgsB := s.PathToHead()
|
||||||
|
if msgsB[len(msgsB)-1].Content[0].Text != "branch-b" {
|
||||||
|
t.Errorf("branch b tip = %q", msgsB[len(msgsB)-1].Content[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSession_Append_RejectsUnknownParent(t *testing.T) {
|
||||||
|
s := New("s1")
|
||||||
|
n := NewNode("does-not-exist", textMsg(llm.RoleUser, "x"), NodeMeta{})
|
||||||
|
if err := s.Append(n); err == nil {
|
||||||
|
t.Fatal("expected error for unknown parent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(t *testing.T, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
230
internal/session/store.go
Normal file
230
internal/session/store.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dirPerm = 0o700
|
||||||
|
filePerm = 0o600
|
||||||
|
)
|
||||||
|
|
||||||
|
// record ist die JSONL-Zeile: entweder ein Node oder ein Head-Pointer.
|
||||||
|
// Jeder Record trägt "v":1 (4.5) — unbekannte höhere Version ist ein Fehler,
|
||||||
|
// kein stilles Teilparsen.
|
||||||
|
type record struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Node *Node `json:"node,omitempty"`
|
||||||
|
Head string `json:"head,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store bindet eine Session an ihre Append-only-JSONL-Datei unter
|
||||||
|
// .nub/sessions/<id>.jsonl.
|
||||||
|
type Store struct {
|
||||||
|
*Session
|
||||||
|
file *os.File
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionsDir(repoRoot string) string {
|
||||||
|
return filepath.Join(repoRoot, ".nub", "sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create legt eine neue Session mit frischer ULID an und öffnet ihre Datei.
|
||||||
|
func Create(repoRoot string) (*Store, error) {
|
||||||
|
dir := sessionsDir(repoRoot)
|
||||||
|
if err := os.MkdirAll(dir, dirPerm); err != nil {
|
||||||
|
return nil, fmt.Errorf("create session dir: %w", err)
|
||||||
|
}
|
||||||
|
if err := EnsureGitExclude(repoRoot); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure git exclude: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := ulid.Make().String()
|
||||||
|
path := filepath.Join(dir, id+".jsonl")
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_EXCL, filePerm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create session file: %w", err)
|
||||||
|
}
|
||||||
|
return &Store{Session: New(id), file: f}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load liest eine existierende Session vollständig und hält die Datei zum
|
||||||
|
// Weiterschreiben offen.
|
||||||
|
func Load(repoRoot, id string) (*Store, error) {
|
||||||
|
dir := sessionsDir(repoRoot)
|
||||||
|
path := filepath.Join(dir, id+".jsonl")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read session %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := New(id)
|
||||||
|
for i, line := range bytes.Split(data, []byte("\n")) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec record
|
||||||
|
if err := json.Unmarshal(line, &rec); err != nil {
|
||||||
|
return nil, fmt.Errorf("session %s: corrupt record at line %d: %w", id, i+1, err)
|
||||||
|
}
|
||||||
|
if rec.V != 1 {
|
||||||
|
return nil, fmt.Errorf("session %s: unsupported record version %d at line %d", id, rec.V, i+1)
|
||||||
|
}
|
||||||
|
if rec.Node != nil {
|
||||||
|
sess.Nodes[rec.Node.ID] = rec.Node
|
||||||
|
}
|
||||||
|
if rec.Head != "" {
|
||||||
|
sess.Head = rec.Head
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, filePerm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open session %s for append: %w", id, err)
|
||||||
|
}
|
||||||
|
return &Store{Session: sess, file: f}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *Store) Close() error {
|
||||||
|
return st.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append hängt den Node an den In-Memory-Baum an und schreibt ihn plus den
|
||||||
|
// neuen Head-Pointer als JSONL-Zeilen.
|
||||||
|
func (st *Store) Append(n *Node) error {
|
||||||
|
st.mu.Lock()
|
||||||
|
defer st.mu.Unlock()
|
||||||
|
|
||||||
|
if err := st.Session.Append(n); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := st.writeRecord(record{V: 1, Node: n}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return st.writeRecord(record{V: 1, Head: st.Head})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *Store) writeRecord(rec record) error {
|
||||||
|
data, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
_, err = st.file.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete entfernt eine Sessiondatei unwiderruflich. Der Aufrufer muss
|
||||||
|
// sicherstellen, dass es nicht die gerade offene/aktive Session ist.
|
||||||
|
func Delete(repoRoot, id string) error {
|
||||||
|
path := filepath.Join(sessionsDir(repoRoot), id+".jsonl")
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
return fmt.Errorf("delete session %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info ist die Kurzübersicht für `nub sessions`.
|
||||||
|
type Info struct {
|
||||||
|
ID string
|
||||||
|
Head string
|
||||||
|
NodeCount int
|
||||||
|
Created time.Time
|
||||||
|
Summary string
|
||||||
|
}
|
||||||
|
|
||||||
|
// List liest alle Sessions unter .nub/sessions und liefert ihre Kurzübersicht,
|
||||||
|
// neueste zuerst. Beschädigte Sessiondateien werden übersprungen statt den
|
||||||
|
// gesamten Aufruf scheitern zu lassen.
|
||||||
|
func List(repoRoot string) ([]Info, error) {
|
||||||
|
dir := sessionsDir(repoRoot)
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var infos []Info
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := strings.TrimSuffix(e.Name(), ".jsonl")
|
||||||
|
info, err := loadInfo(dir, id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
infos = append(infos, info)
|
||||||
|
}
|
||||||
|
sort.Slice(infos, func(i, j int) bool { return infos[i].Created.After(infos[j].Created) })
|
||||||
|
return infos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadInfo(dir, id string) (Info, error) {
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, id+".jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
return Info{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info := Info{ID: id}
|
||||||
|
for _, line := range bytes.Split(data, []byte("\n")) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec record
|
||||||
|
if err := json.Unmarshal(line, &rec); err != nil {
|
||||||
|
return Info{}, err
|
||||||
|
}
|
||||||
|
if rec.V != 1 {
|
||||||
|
return Info{}, fmt.Errorf("unsupported record version %d", rec.V)
|
||||||
|
}
|
||||||
|
if rec.Node != nil {
|
||||||
|
info.NodeCount++
|
||||||
|
if info.Created.IsZero() || rec.Node.Meta.CreatedAt.Before(info.Created) {
|
||||||
|
info.Created = rec.Node.Meta.CreatedAt
|
||||||
|
}
|
||||||
|
if info.Summary == "" && rec.Node.Message.Role == llm.RoleUser {
|
||||||
|
info.Summary = firstText(rec.Node.Message.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rec.Head != "" {
|
||||||
|
info.Head = rec.Head
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstText(blocks []llm.Block) string {
|
||||||
|
for _, b := range blocks {
|
||||||
|
if b.Kind == llm.KindText && b.Text != "" {
|
||||||
|
return truncate(b.Text, 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "…"
|
||||||
|
}
|
||||||
169
internal/session/store_test.go
Normal file
169
internal/session/store_test.go
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStore_CreateAppendLoad_Roundtrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
st, err := Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n1 := NewNode("", textMsg(llm.RoleUser, "hello"), NodeMeta{Model: "test-model"})
|
||||||
|
must(t, st.Append(n1))
|
||||||
|
n2 := NewNode(n1.ID, textMsg(llm.RoleAssistant, "hi there"), NodeMeta{Model: "test-model"})
|
||||||
|
must(t, st.Append(n2))
|
||||||
|
id := st.ID
|
||||||
|
must(t, st.Close())
|
||||||
|
|
||||||
|
loaded, err := Load(dir, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer loaded.Close()
|
||||||
|
|
||||||
|
if loaded.Head != n2.ID {
|
||||||
|
t.Errorf("head = %s, want %s", loaded.Head, n2.ID)
|
||||||
|
}
|
||||||
|
msgs := loaded.PathToHead()
|
||||||
|
if len(msgs) != 2 || msgs[1].Content[0].Text != "hi there" {
|
||||||
|
t.Errorf("unexpected path: %+v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStore_RewindAndBranch_BothPathsRemainLoadable ist das M2-Fertig-
|
||||||
|
// Kriterium aus Abschnitt 6: Rewind auf einen früheren Knoten + Weiterarbeit
|
||||||
|
// erzeugt einen zweiten Ast, beide bleiben ladbar.
|
||||||
|
func TestStore_RewindAndBranch_BothPathsRemainLoadable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
st, err := Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root := NewNode("", textMsg(llm.RoleUser, "root"), NodeMeta{})
|
||||||
|
must(t, st.Append(root))
|
||||||
|
tipA := NewNode(root.ID, textMsg(llm.RoleAssistant, "original continuation"), NodeMeta{})
|
||||||
|
must(t, st.Append(tipA))
|
||||||
|
id := st.ID
|
||||||
|
must(t, st.Close())
|
||||||
|
|
||||||
|
// Rewind: erneut laden, auf root zurückspringen, neu weiterarbeiten.
|
||||||
|
resumed, err := Load(dir, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
must(t, resumed.Branch(root.ID))
|
||||||
|
tipB := NewNode(root.ID, textMsg(llm.RoleAssistant, "rewound continuation"), NodeMeta{})
|
||||||
|
must(t, resumed.Append(tipB))
|
||||||
|
must(t, resumed.Close())
|
||||||
|
|
||||||
|
// Beide Äste müssen nach erneutem Laden noch da sein.
|
||||||
|
final, err := Load(dir, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer final.Close()
|
||||||
|
|
||||||
|
if _, ok := final.Nodes[tipA.ID]; !ok {
|
||||||
|
t.Error("original branch tip missing after reload")
|
||||||
|
}
|
||||||
|
if _, ok := final.Nodes[tipB.ID]; !ok {
|
||||||
|
t.Error("rewound branch tip missing after reload")
|
||||||
|
}
|
||||||
|
if final.Head != tipB.ID {
|
||||||
|
t.Errorf("head after reload = %s, want %s (latest branch)", final.Head, tipB.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
must(t, final.Branch(tipA.ID))
|
||||||
|
msgsA := final.PathToHead()
|
||||||
|
if got := msgsA[len(msgsA)-1].Content[0].Text; got != "original continuation" {
|
||||||
|
t.Errorf("branch A tip = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
must(t, final.Branch(tipB.ID))
|
||||||
|
msgsB := final.PathToHead()
|
||||||
|
if got := msgsB[len(msgsB)-1].Content[0].Text; got != "rewound continuation" {
|
||||||
|
t.Errorf("branch B tip = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_FilePermissions(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Unix-Dateirechte, siehe Abschnitt 9 (kein Windows-Pfad)")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
|
dirInfo, err := os.Stat(filepath.Join(dir, ".nub", "sessions"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if perm := dirInfo.Mode().Perm(); perm != 0o700 {
|
||||||
|
t.Errorf(".nub/sessions perm = %o, want 0700", perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(filepath.Join(dir, ".nub", "sessions", st.ID+".jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if perm := fileInfo.Mode().Perm(); perm != 0o600 {
|
||||||
|
t.Errorf("session file perm = %o, want 0600", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_Load_RejectsUnsupportedVersion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sessDir := filepath.Join(dir, ".nub", "sessions")
|
||||||
|
must(t, os.MkdirAll(sessDir, 0o700))
|
||||||
|
path := filepath.Join(sessDir, "bad.jsonl")
|
||||||
|
must(t, os.WriteFile(path, []byte(`{"v":2,"node":{"id":"x"}}`+"\n"), 0o600))
|
||||||
|
|
||||||
|
if _, err := Load(dir, "bad"); err == nil {
|
||||||
|
t.Fatal("expected error for unsupported record version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_List_ReturnsSessionsNewestFirst(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
st1, err := Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
must(t, st1.Append(NewNode("", textMsg(llm.RoleUser, "first session"), NodeMeta{})))
|
||||||
|
must(t, st1.Close())
|
||||||
|
|
||||||
|
st2, err := Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
must(t, st2.Append(NewNode("", textMsg(llm.RoleUser, "second session"), NodeMeta{})))
|
||||||
|
must(t, st2.Close())
|
||||||
|
|
||||||
|
infos, err := List(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(infos) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(infos))
|
||||||
|
}
|
||||||
|
found := map[string]bool{}
|
||||||
|
for _, info := range infos {
|
||||||
|
found[info.Summary] = true
|
||||||
|
}
|
||||||
|
if !found["first session"] || !found["second session"] {
|
||||||
|
t.Errorf("infos = %+v", infos)
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/skill/index.go
Normal file
23
internal/skill/index.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package skill
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// RenderIndex baut den kompakten Index-Block (~30 Tokens/Skill): nur Name
|
||||||
|
// und Description, der Body kommt erst über read_skill(name).
|
||||||
|
func RenderIndex(skills []Skill) string {
|
||||||
|
if len(skills) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("Verfügbare Skills (Body per read_skill(name) laden):\n")
|
||||||
|
for _, s := range skills {
|
||||||
|
b.WriteString("- ")
|
||||||
|
b.WriteString(s.Name)
|
||||||
|
if s.Description != "" {
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(s.Description)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
114
internal/skill/skill.go
Normal file
114
internal/skill/skill.go
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// Package skill implementiert Skill-Discovery mit Progressive Disclosure
|
||||||
|
// (5.5): Beim Start werden alle Skills gefunden, aber nur Name+Description
|
||||||
|
// in den Prompt geschrieben. Der Body kommt erst über das read_skill-Tool.
|
||||||
|
package skill
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Skill struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Dir string // Verzeichnis des Skills (für weitere Dateien, die das Modell per read findet)
|
||||||
|
Path string // absoluter Pfad zu SKILL.md
|
||||||
|
Body string // Inhalt nach dem Frontmatter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover sammelt Skills aus mehreren Verzeichnissen (4.7: skills.paths).
|
||||||
|
// Jedes Unterverzeichnis mit einer SKILL.md ist ein Skill. Spätere Pfade
|
||||||
|
// gewinnen bei Namenskollisionen (projekt-lokal überschreibt global) —
|
||||||
|
// dasselbe Präzedenzprinzip wie bei der Config (4.7).
|
||||||
|
func Discover(paths []string) ([]Skill, error) {
|
||||||
|
byName := make(map[string]Skill)
|
||||||
|
var order []string
|
||||||
|
|
||||||
|
for _, root := range paths {
|
||||||
|
root = expandHome(root)
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
continue // Pfad fehlt -> überspringen, kein Fehler
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skillPath := filepath.Join(root, e.Name(), "SKILL.md")
|
||||||
|
data, err := os.ReadFile(skillPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
meta, body, err := parseFrontmatter(string(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("skill %s: %w", skillPath, err)
|
||||||
|
}
|
||||||
|
name := meta["name"]
|
||||||
|
if name == "" {
|
||||||
|
name = e.Name()
|
||||||
|
}
|
||||||
|
if _, seen := byName[name]; !seen {
|
||||||
|
order = append(order, name)
|
||||||
|
}
|
||||||
|
byName[name] = Skill{
|
||||||
|
Name: name,
|
||||||
|
Description: meta["description"],
|
||||||
|
Dir: filepath.Join(root, e.Name()),
|
||||||
|
Path: skillPath,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Skill, 0, len(order))
|
||||||
|
for _, name := range order {
|
||||||
|
out = append(out, byName[name])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFrontmatter liest den Bereich zwischen den ersten beiden `---`-Zeilen
|
||||||
|
// als flache key: value-Paare. Kein echter YAML-Parser — für das dokumentierte
|
||||||
|
// Format (zwei flache String-Felder) reicht das, und es spart eine
|
||||||
|
// Dependency, die nicht in der Allowlist steht (2.3).
|
||||||
|
func parseFrontmatter(content string) (map[string]string, string, error) {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||||
|
return nil, "", fmt.Errorf("missing frontmatter (expected leading ---)")
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := make(map[string]string)
|
||||||
|
i := 1
|
||||||
|
for ; i < len(lines); i++ {
|
||||||
|
line := lines[i]
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
val = strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
|
meta[key] = val
|
||||||
|
}
|
||||||
|
if i >= len(lines) {
|
||||||
|
return nil, "", fmt.Errorf("unterminated frontmatter (missing closing ---)")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := strings.TrimLeft(strings.Join(lines[i+1:], "\n"), "\n")
|
||||||
|
return meta, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHome(path string) string {
|
||||||
|
if !strings.HasPrefix(path, "~") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.Join(home, strings.TrimPrefix(path, "~"))
|
||||||
|
}
|
||||||
101
internal/skill/skill_test.go
Normal file
101
internal/skill/skill_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package skill
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeSkill(t *testing.T, root, dirName, frontmatter, body string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(root, dirName)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := "---\n" + frontmatter + "---\n\n" + body
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover_ParsesFrontmatterAndBody(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeSkill(t, root, "refactoring",
|
||||||
|
"name: refactoring\ndescription: Vorgehen für größere Refactorings.\n",
|
||||||
|
"# Refactoring\n\nSchritt 1: Tests lesen.\n")
|
||||||
|
|
||||||
|
skills, err := Discover([]string{root})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(skills) != 1 {
|
||||||
|
t.Fatalf("got %d skills, want 1", len(skills))
|
||||||
|
}
|
||||||
|
s := skills[0]
|
||||||
|
if s.Name != "refactoring" {
|
||||||
|
t.Errorf("name = %q", s.Name)
|
||||||
|
}
|
||||||
|
if s.Description != "Vorgehen für größere Refactorings." {
|
||||||
|
t.Errorf("description = %q", s.Description)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s.Body, "Schritt 1: Tests lesen.") {
|
||||||
|
t.Errorf("body missing expected content: %q", s.Body)
|
||||||
|
}
|
||||||
|
if strings.Contains(s.Body, "---") {
|
||||||
|
t.Errorf("body should not contain frontmatter delimiters: %q", s.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover_LaterPathOverridesEarlierByName(t *testing.T) {
|
||||||
|
global := t.TempDir()
|
||||||
|
project := t.TempDir()
|
||||||
|
writeSkill(t, global, "refactoring", "name: refactoring\ndescription: global version\n", "global body")
|
||||||
|
writeSkill(t, project, "refactoring", "name: refactoring\ndescription: project version\n", "project body")
|
||||||
|
|
||||||
|
skills, err := Discover([]string{global, project})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(skills) != 1 {
|
||||||
|
t.Fatalf("got %d skills, want 1 (deduped by name)", len(skills))
|
||||||
|
}
|
||||||
|
if skills[0].Description != "project version" {
|
||||||
|
t.Errorf("description = %q, want project version to win (later path)", skills[0].Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscover_MissingPathIsSkippedNotError(t *testing.T) {
|
||||||
|
skills, err := Discover([]string{"/does/not/exist/at/all"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(skills) != 0 {
|
||||||
|
t.Errorf("got %d skills, want 0", len(skills))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderIndex_OmitsBody ist das M5-Fertig-Kriterium aus Abschnitt 6: ein
|
||||||
|
// Skill wird erst bei Bedarf geladen — der Index darf nur Name+Description
|
||||||
|
// enthalten, ein Token-Delta zum vollen Body muss messbar sein.
|
||||||
|
func TestRenderIndex_OmitsBody(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
longBody := strings.Repeat("Dies ist eine lange Anleitung. ", 200) // ~1400 Zeichen
|
||||||
|
writeSkill(t, root, "refactoring", "name: refactoring\ndescription: kurze Beschreibung\n", longBody)
|
||||||
|
|
||||||
|
skills, err := Discover([]string{root})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
index := RenderIndex(skills)
|
||||||
|
if strings.Contains(index, longBody) {
|
||||||
|
t.Fatal("index must not contain the full skill body")
|
||||||
|
}
|
||||||
|
if !strings.Contains(index, "refactoring") || !strings.Contains(index, "kurze Beschreibung") {
|
||||||
|
t.Errorf("index missing name/description: %q", index)
|
||||||
|
}
|
||||||
|
if len(index) >= len(longBody) {
|
||||||
|
t.Errorf("index (%d bytes) should be far smaller than the body (%d bytes)", len(index), len(longBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/tokens/blocks.go
Normal file
29
internal/tokens/blocks.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package tokens
|
||||||
|
|
||||||
|
import "nub/internal/llm"
|
||||||
|
|
||||||
|
// EstimateBlocks summiert die Schätzung über Text, Tool-Input und
|
||||||
|
// (rekursiv) Tool-Result-Blöcke.
|
||||||
|
func EstimateBlocks(blocks []llm.Block) int {
|
||||||
|
total := 0
|
||||||
|
for _, b := range blocks {
|
||||||
|
total += Estimate(b.Text)
|
||||||
|
if len(b.Input) > 0 {
|
||||||
|
total += Estimate(string(b.Input))
|
||||||
|
}
|
||||||
|
if len(b.Result) > 0 {
|
||||||
|
total += EstimateBlocks(b.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// EstimateMessages schätzt die Tokenkosten eines kompletten Requests
|
||||||
|
// (System-Blöcke + Message-Historie) — Grundlage für den Compaction-Trigger.
|
||||||
|
func EstimateMessages(system []llm.Block, messages []llm.Message) int {
|
||||||
|
total := EstimateBlocks(system)
|
||||||
|
for _, m := range messages {
|
||||||
|
total += EstimateBlocks(m.Content)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
46
internal/tokens/calibrate.go
Normal file
46
internal/tokens/calibrate.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package tokens
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// Calibrator passt die Schätzung anhand echter usage-Werte aus der API-
|
||||||
|
// Antwort an (E-09). Ein laufender Korrekturfaktor pro Prozess; Persistenz
|
||||||
|
// über Sessions hinweg ist für v1 nicht vorgesehen — jeder Lauf kalibriert
|
||||||
|
// neu, was bei einem einzelnen Modell pro Session ausreicht.
|
||||||
|
type Calibrator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
factor float64
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCalibrator() *Calibrator {
|
||||||
|
return &Calibrator{factor: 1.0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe nimmt einen (geschätzt, tatsächlich)-Datenpunkt auf und aktualisiert
|
||||||
|
// den Korrekturfaktor per exponentiell gleitendem Mittel.
|
||||||
|
func (c *Calibrator) Observe(estimated, actual int) {
|
||||||
|
if estimated <= 0 || actual <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
ratio := float64(actual) / float64(estimated)
|
||||||
|
const alpha = 0.2
|
||||||
|
if c.n == 0 {
|
||||||
|
c.factor = ratio
|
||||||
|
} else {
|
||||||
|
c.factor = c.factor*(1-alpha) + ratio*alpha
|
||||||
|
}
|
||||||
|
c.n++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust wendet den aktuellen Korrekturfaktor plus einen 15%-Sicherheits-
|
||||||
|
// abstand auf eine rohe Schätzung an (E-09: konservativ schätzen ist
|
||||||
|
// billiger als ein echter Tokenizer).
|
||||||
|
func (c *Calibrator) Adjust(estimate int) int {
|
||||||
|
c.mu.Lock()
|
||||||
|
factor := c.factor
|
||||||
|
c.mu.Unlock()
|
||||||
|
return int(float64(estimate) * factor * 1.15)
|
||||||
|
}
|
||||||
35
internal/tokens/calibrate_test.go
Normal file
35
internal/tokens/calibrate_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package tokens
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCalibrator_ConvergesTowardObservedRatio(t *testing.T) {
|
||||||
|
c := NewCalibrator()
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
c.Observe(100, 200) // Modell verbraucht durchweg doppelt so viele Tokens wie geschätzt
|
||||||
|
}
|
||||||
|
adjusted := c.Adjust(100)
|
||||||
|
// Nach Konvergenz: factor ~2.0, plus 15% Sicherheitsabstand -> ~230.
|
||||||
|
if adjusted < 220 || adjusted > 240 {
|
||||||
|
t.Errorf("adjusted = %d, want ~230 after convergence to ratio 2.0", adjusted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalibrator_IgnoresZeroObservations(t *testing.T) {
|
||||||
|
c := NewCalibrator()
|
||||||
|
c.Observe(0, 100)
|
||||||
|
c.Observe(100, 0)
|
||||||
|
// Ohne gültige Beobachtung bleibt der Faktor bei 1.0 (+15% Sicherheitsabstand).
|
||||||
|
adjusted := c.Adjust(100)
|
||||||
|
if adjusted < 110 || adjusted > 120 {
|
||||||
|
t.Errorf("adjusted = %d, want ~115 (factor still 1.0)", adjusted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEstimate_NonEmpty(t *testing.T) {
|
||||||
|
if Estimate("") != 0 {
|
||||||
|
t.Error("empty string should estimate to 0 tokens")
|
||||||
|
}
|
||||||
|
if Estimate("hello world") <= 0 {
|
||||||
|
t.Error("non-empty text should estimate to > 0 tokens")
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/tokens/estimate.go
Normal file
32
internal/tokens/estimate.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Package tokens schätzt Tokenzahlen ohne echten Tokenizer (E-09).
|
||||||
|
// Kalibrierung gegen die tatsächliche usage aus jeder API-Antwort folgt in M4.
|
||||||
|
package tokens
|
||||||
|
|
||||||
|
import "unicode"
|
||||||
|
|
||||||
|
// Estimate schätzt die Tokenzahl eines Texts: len/4 für lateinischen Text,
|
||||||
|
// len/3 für überwiegend CJK-Text (dort ist ein Token meist ~1 Zeichen).
|
||||||
|
func Estimate(s string) int {
|
||||||
|
if s == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
cjk := 0
|
||||||
|
for _, r := range runes {
|
||||||
|
if isCJK(r) {
|
||||||
|
cjk++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := len(runes)
|
||||||
|
if cjk*2 > n {
|
||||||
|
return n/3 + 1
|
||||||
|
}
|
||||||
|
return n/4 + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCJK(r rune) bool {
|
||||||
|
return unicode.Is(unicode.Han, r) ||
|
||||||
|
unicode.Is(unicode.Hiragana, r) ||
|
||||||
|
unicode.Is(unicode.Katakana, r) ||
|
||||||
|
unicode.Is(unicode.Hangul, r)
|
||||||
|
}
|
||||||
152
internal/tool/builtin/bash.go
Normal file
152
internal/tool/builtin/bash.go
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultBashTimeout = 120 * time.Second
|
||||||
|
maxBashOutput = 30000
|
||||||
|
)
|
||||||
|
|
||||||
|
type BashTool struct{}
|
||||||
|
|
||||||
|
func (BashTool) Name() string { return "bash" }
|
||||||
|
func (BashTool) Description() string {
|
||||||
|
return "Führt einen Shell-Befehl aus. Timeout, Output wird bei ~30k Zeichen in der Mitte gekürzt."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BashTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {"type": "string"},
|
||||||
|
"timeout_seconds": {"type": "integer", "description": "Default 120"}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type bashInput struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BashTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in bashInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := defaultBashTimeout
|
||||||
|
if in.TimeoutSeconds > 0 {
|
||||||
|
timeout = time.Duration(in.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(runCtx, "/bin/sh", "-c", in.Command)
|
||||||
|
cmd.Dir = env.RepoRoot
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
// CommandContext killt bei Timeout nur den direkten Prozess; wir killen
|
||||||
|
// stattdessen die ganze Prozessgruppe (siehe Cancel unten), damit keine
|
||||||
|
// Kindprozesse überleben.
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
var mu sync.Mutex
|
||||||
|
truncated := false
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("bash: %v", err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
reader := bufio.NewReader(stdout)
|
||||||
|
chunk := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, rerr := reader.Read(chunk)
|
||||||
|
if n > 0 {
|
||||||
|
text := string(chunk[:n])
|
||||||
|
mu.Lock()
|
||||||
|
buf.WriteString(text)
|
||||||
|
mu.Unlock()
|
||||||
|
if env.Emit != nil {
|
||||||
|
env.Emit(tool.ToolStream{Text: text})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr == io.EOF {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-done
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
out := buf.String()
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if len(out) > maxBashOutput {
|
||||||
|
half := maxBashOutput / 2
|
||||||
|
out = out[:half] + "\n... [truncated] ...\n" + out[len(out)-half:]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if runCtx.Err() == context.DeadlineExceeded {
|
||||||
|
return tool.Result{
|
||||||
|
ForModel: fmt.Sprintf("command timed out after %s\n%s", timeout, out),
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := 0
|
||||||
|
if waitErr != nil {
|
||||||
|
if exitErr, ok := waitErr.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("bash: %v", waitErr), IsError: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Result{
|
||||||
|
ForModel: out,
|
||||||
|
ForUI: BashResult{Command: in.Command, ExitCode: exitCode, Truncated: truncated},
|
||||||
|
IsError: exitCode != 0,
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type BashResult struct {
|
||||||
|
Command string
|
||||||
|
ExitCode int
|
||||||
|
Truncated bool
|
||||||
|
}
|
||||||
177
internal/tool/builtin/builtin_test.go
Normal file
177
internal/tool/builtin/builtin_test.go
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testEnv(t *testing.T) tool.Env {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
return tool.Env{Cwd: dir, RepoRoot: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, env tool.Env, name, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(env.RepoRoot, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_AmbiguousMatchErrorsWithCount(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
writeFile(t, env, "f.go", "foo\nfoo\nfoo\n")
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "foo", "new_string": "bar"})
|
||||||
|
res, err := EditTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError for ambiguous match")
|
||||||
|
}
|
||||||
|
if !contains(res.ForModel, "3 matches") {
|
||||||
|
t.Errorf("error message should mention match count, got: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_ReplaceAllReplacesEveryMatch(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
path := writeFile(t, env, "f.go", "foo\nfoo\nfoo\n")
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "foo", "new_string": "bar", "replace_all": true})
|
||||||
|
res, err := EditTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", res.ForModel)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
if string(data) != "bar\nbar\nbar\n" {
|
||||||
|
t.Errorf("content = %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEdit_UniqueMatchSucceeds(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
writeFile(t, env, "f.go", "unique_marker\nother\n")
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]any{"path": "f.go", "old_string": "unique_marker", "new_string": "replaced"})
|
||||||
|
res, err := EditTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", res.ForModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPath_EscapeViaDotDotIsRejected(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
in, _ := json.Marshal(map[string]any{"path": "../outside.txt", "old_string": "a", "new_string": "b"})
|
||||||
|
res, err := EditTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected path escape to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPath_EscapeViaSymlinkIsRejected(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
outsideDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
link := filepath.Join(env.RepoRoot, "link")
|
||||||
|
if err := os.Symlink(outsideDir, link); err != nil {
|
||||||
|
t.Skipf("symlinks not supported: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]any{"path": "link/secret.txt"})
|
||||||
|
res, err := ReadTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected symlink escape to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBash_TimeoutKillsProcessGroup(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
in, _ := json.Marshal(map[string]any{"command": "sleep 30", "timeout_seconds": 1})
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
res, err := BashTool{}.Run(context.Background(), in, env)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected timeout to be reported as error")
|
||||||
|
}
|
||||||
|
if elapsed > 5*time.Second {
|
||||||
|
t.Errorf("timeout took too long: %s", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBash_CapturesStdoutAndExitCode(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
in, _ := json.Marshal(map[string]any{"command": "echo hi && exit 3"})
|
||||||
|
|
||||||
|
res, err := BashTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected non-zero exit to be reported as error")
|
||||||
|
}
|
||||||
|
if !contains(res.ForModel, "hi") {
|
||||||
|
t.Errorf("expected stdout captured, got: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlob_RespectsGitignore(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
writeFile(t, env, ".gitignore", "*.log\n")
|
||||||
|
writeFile(t, env, "keep.txt", "x")
|
||||||
|
writeFile(t, env, "skip.log", "x")
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]any{"pattern": "**/*"})
|
||||||
|
res, err := GlobTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if contains(res.ForModel, "skip.log") {
|
||||||
|
t.Errorf("gitignored file leaked into glob result: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
if !contains(res.ForModel, "keep.txt") {
|
||||||
|
t.Errorf("expected keep.txt in result: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (func() bool {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})()
|
||||||
|
}
|
||||||
77
internal/tool/builtin/diff.go
Normal file
77
internal/tool/builtin/diff.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
type DiffOp string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DiffEqual DiffOp = "equal"
|
||||||
|
DiffAdd DiffOp = "add"
|
||||||
|
DiffRemove DiffOp = "remove"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiffLine struct {
|
||||||
|
Op DiffOp
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiffResult ist die strukturierte UI-Repräsentation für write/edit.
|
||||||
|
type DiffResult struct {
|
||||||
|
Path string
|
||||||
|
Lines []DiffLine
|
||||||
|
}
|
||||||
|
|
||||||
|
// lineDiff berechnet einen minimalen zeilenbasierten Diff via LCS.
|
||||||
|
// Für Datei-großen Input (M1) ausreichend; kein externer Diff-Algorithmus nötig.
|
||||||
|
func lineDiff(oldText, newText string) []DiffLine {
|
||||||
|
oldLines := splitLines(oldText)
|
||||||
|
newLines := splitLines(newText)
|
||||||
|
n, m := len(oldLines), len(newLines)
|
||||||
|
|
||||||
|
lcs := make([][]int, n+1)
|
||||||
|
for i := range lcs {
|
||||||
|
lcs[i] = make([]int, m+1)
|
||||||
|
}
|
||||||
|
for i := n - 1; i >= 0; i-- {
|
||||||
|
for j := m - 1; j >= 0; j-- {
|
||||||
|
if oldLines[i] == newLines[j] {
|
||||||
|
lcs[i][j] = lcs[i+1][j+1] + 1
|
||||||
|
} else if lcs[i+1][j] >= lcs[i][j+1] {
|
||||||
|
lcs[i][j] = lcs[i+1][j]
|
||||||
|
} else {
|
||||||
|
lcs[i][j] = lcs[i][j+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []DiffLine
|
||||||
|
i, j := 0, 0
|
||||||
|
for i < n && j < m {
|
||||||
|
switch {
|
||||||
|
case oldLines[i] == newLines[j]:
|
||||||
|
out = append(out, DiffLine{DiffEqual, oldLines[i]})
|
||||||
|
i++
|
||||||
|
j++
|
||||||
|
case lcs[i+1][j] >= lcs[i][j+1]:
|
||||||
|
out = append(out, DiffLine{DiffRemove, oldLines[i]})
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
out = append(out, DiffLine{DiffAdd, newLines[j]})
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ; i < n; i++ {
|
||||||
|
out = append(out, DiffLine{DiffRemove, oldLines[i]})
|
||||||
|
}
|
||||||
|
for ; j < m; j++ {
|
||||||
|
out = append(out, DiffLine{DiffAdd, newLines[j]})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(s, "\n")
|
||||||
|
}
|
||||||
86
internal/tool/builtin/edit.go
Normal file
86
internal/tool/builtin/edit.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EditTool struct{}
|
||||||
|
|
||||||
|
func (EditTool) Name() string { return "edit" }
|
||||||
|
func (EditTool) Description() string {
|
||||||
|
return "Ersetzt einen exakten String in einer Datei. Bei mehreren Treffern ohne replace_all: Fehler."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EditTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"old_string": {"type": "string"},
|
||||||
|
"new_string": {"type": "string"},
|
||||||
|
"replace_all": {"type": "boolean"}
|
||||||
|
},
|
||||||
|
"required": ["path", "old_string", "new_string"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type editInput struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
OldString string `json:"old_string"`
|
||||||
|
NewString string `json:"new_string"`
|
||||||
|
ReplaceAll bool `json:"replace_all"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (EditTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in editInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
if in.OldString == in.NewString {
|
||||||
|
return tool.Result{ForModel: "old_string and new_string are identical", IsError: true}, nil
|
||||||
|
}
|
||||||
|
path, err := resolvePath(env.RepoRoot, env.Cwd, in.Path)
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("read %s: %v", in.Path, err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
old := string(data)
|
||||||
|
|
||||||
|
count := strings.Count(old, in.OldString)
|
||||||
|
if count == 0 {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("%s: old_string not found", in.Path), IsError: true}, nil
|
||||||
|
}
|
||||||
|
if count > 1 && !in.ReplaceAll {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("%s: old_string is ambiguous (%d matches); use replace_all or a more specific old_string", in.Path, count), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var next string
|
||||||
|
if in.ReplaceAll {
|
||||||
|
next = strings.ReplaceAll(old, in.OldString, in.NewString)
|
||||||
|
} else {
|
||||||
|
next = strings.Replace(old, in.OldString, in.NewString, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("write %s: %v", in.Path, err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
replaced := 1
|
||||||
|
if in.ReplaceAll {
|
||||||
|
replaced = count
|
||||||
|
}
|
||||||
|
return tool.Result{
|
||||||
|
ForModel: fmt.Sprintf("%s: %d replacement(s)", in.Path, replaced),
|
||||||
|
ForUI: DiffResult{Path: in.Path, Lines: lineDiff(old, next)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
53
internal/tool/builtin/gitignore.go
Normal file
53
internal/tool/builtin/gitignore.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitignore ist eine einfache, ausreichende Umsetzung: liest .gitignore im
|
||||||
|
// RepoRoot (keine verschachtelten .gitignore-Dateien, keine Negationen) und
|
||||||
|
// matcht Zeilen als doublestar-Patterns gegen den repo-relativen Pfad.
|
||||||
|
type gitignore struct {
|
||||||
|
patterns []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadGitignore(repoRoot string) gitignore {
|
||||||
|
g := gitignore{patterns: []string{".git/**"}}
|
||||||
|
data, err := os.ReadFile(filepath.Join(repoRoot, ".gitignore"))
|
||||||
|
if err != nil {
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "!") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = strings.TrimPrefix(line, "/")
|
||||||
|
if strings.HasSuffix(line, "/") {
|
||||||
|
line += "**"
|
||||||
|
}
|
||||||
|
g.patterns = append(g.patterns, line, line+"/**")
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g gitignore) match(relPath string) bool {
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
for _, p := range g.patterns {
|
||||||
|
if ok, _ := doublestar.Match(p, relPath); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Auch gegen den Basename matchen, wie git es für unqualifizierte
|
||||||
|
// Patterns ohne "/" tut.
|
||||||
|
if !strings.Contains(p, "/") {
|
||||||
|
if ok, _ := doublestar.Match(p, filepath.Base(relPath)); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
83
internal/tool/builtin/glob.go
Normal file
83
internal/tool/builtin/glob.go
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GlobTool struct{}
|
||||||
|
|
||||||
|
func (GlobTool) Name() string { return "glob" }
|
||||||
|
func (GlobTool) Description() string {
|
||||||
|
return "Findet Dateien per Glob-Pattern, .gitignore respektiert, nach mtime absteigend sortiert."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (GlobTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string", "description": "doublestar-Pattern, z.B. **/*.go"}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type globInput struct {
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (GlobTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in globInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore := loadGitignore(env.RepoRoot)
|
||||||
|
|
||||||
|
type match struct {
|
||||||
|
path string
|
||||||
|
mtime int64
|
||||||
|
}
|
||||||
|
var matches []match
|
||||||
|
|
||||||
|
err := doublestar.GlobWalk(os.DirFS(env.RepoRoot), in.Pattern, func(path string, d fs.DirEntry) error {
|
||||||
|
if d.IsDir() || ignore.match(path) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
matches = append(matches, match{path: path, mtime: info.ModTime().Unix()})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("glob %s: %v", in.Pattern, err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(matches, func(i, j int) bool { return matches[i].mtime > matches[j].mtime })
|
||||||
|
|
||||||
|
paths := make([]string, len(matches))
|
||||||
|
out := ""
|
||||||
|
for i, m := range matches {
|
||||||
|
paths[i] = m.path
|
||||||
|
out += m.path + "\n"
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
out = fmt.Sprintf("no matches for %s", in.Pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool.Result{ForModel: out, ForUI: GlobResult{Paths: paths}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobResult struct {
|
||||||
|
Paths []string
|
||||||
|
}
|
||||||
135
internal/tool/builtin/grep.go
Normal file
135
internal/tool/builtin/grep.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GrepTool struct{}
|
||||||
|
|
||||||
|
func (GrepTool) Name() string { return "grep" }
|
||||||
|
func (GrepTool) Description() string {
|
||||||
|
return "Durchsucht Dateien per Regex, in-process, mit Kontext-Zeilen."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (GrepTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pattern": {"type": "string"},
|
||||||
|
"path": {"type": "string", "description": "Startverzeichnis, relativ zu RepoRoot, Default '.'"},
|
||||||
|
"context": {"type": "integer", "description": "Kontext-Zeilen vor/nach jedem Treffer"}
|
||||||
|
},
|
||||||
|
"required": ["pattern"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type grepInput struct {
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Context int `json:"context"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type grepMatch struct {
|
||||||
|
path string
|
||||||
|
line int
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (GrepTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in grepInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile(in.Pattern)
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid pattern: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startRel := in.Path
|
||||||
|
if startRel == "" {
|
||||||
|
startRel = "."
|
||||||
|
}
|
||||||
|
start, err := resolvePath(env.RepoRoot, env.Cwd, startRel)
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore := loadGitignore(env.RepoRoot)
|
||||||
|
|
||||||
|
var matches []grepMatch
|
||||||
|
err = filepath.WalkDir(start, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, relErr := filepath.Rel(env.RepoRoot, p)
|
||||||
|
if relErr == nil && ignore.match(rel) {
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
grepFile(p, re, in.Context, &matches)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("grep: %v", err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("no matches for %q", in.Pattern)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for _, m := range matches {
|
||||||
|
relPath, _ := filepath.Rel(env.RepoRoot, m.path)
|
||||||
|
fmt.Fprintf(&b, "%s:%d:%s\n", relPath, m.line, m.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool.Result{ForModel: b.String(), ForUI: GrepResult{Count: len(matches)}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func grepFile(path string, re *regexp.Regexp, contextLines int, out *[]grepMatch) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
lines = append(lines, scanner.Text())
|
||||||
|
}
|
||||||
|
if scanner.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, line := range lines {
|
||||||
|
if !re.MatchString(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lo := max(0, i-contextLines)
|
||||||
|
hi := min(len(lines)-1, i+contextLines)
|
||||||
|
for j := lo; j <= hi; j++ {
|
||||||
|
*out = append(*out, grepMatch{path: path, line: j + 1, text: lines[j]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrepResult struct {
|
||||||
|
Count int
|
||||||
|
}
|
||||||
39
internal/tool/builtin/path.go
Normal file
39
internal/tool/builtin/path.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolvePath erzwingt die Pfad-Regel aus Abschnitt 5.3: nur unterhalb von
|
||||||
|
// RepoRoot, Symlinks werden aufgelöst und erneut geprüft.
|
||||||
|
func resolvePath(repoRoot, cwd, path string) (string, error) {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
path = filepath.Join(cwd, path)
|
||||||
|
}
|
||||||
|
clean := filepath.Clean(path)
|
||||||
|
|
||||||
|
root, err := filepath.EvalSymlinks(repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("repo root nicht auflösbar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symlinks im Zielpfad auflösen, soweit er existiert; fehlt er (z.B. bei
|
||||||
|
// write), am nächsten existierenden Vorfahren prüfen.
|
||||||
|
resolved := clean
|
||||||
|
if real, err := filepath.EvalSymlinks(clean); err == nil {
|
||||||
|
resolved = real
|
||||||
|
} else {
|
||||||
|
dir := filepath.Dir(clean)
|
||||||
|
if real, err := filepath.EvalSymlinks(dir); err == nil {
|
||||||
|
resolved = filepath.Join(real, filepath.Base(clean))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(root, resolved)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return "", fmt.Errorf("path escapes repo root: %s", path)
|
||||||
|
}
|
||||||
|
return clean, nil
|
||||||
|
}
|
||||||
74
internal/tool/builtin/question.go
Normal file
74
internal/tool/builtin/question.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QuestionTool stellt eine generische Rückfrage an den Nutzer — anders als
|
||||||
|
// Permissions (system-/config-entschieden, boolesch) entscheidet hier das
|
||||||
|
// Modell selbst, dass es etwas klären will, und bekommt eine Text-Antwort
|
||||||
|
// als normales Tool-Ergebnis zurück. Kein Permission-Gate: das Tool ist
|
||||||
|
// von sich aus interaktiv, eine Rückfrage auf die Rückfrage ergäbe keinen
|
||||||
|
// Sinn.
|
||||||
|
type QuestionTool struct{}
|
||||||
|
|
||||||
|
func (QuestionTool) Name() string { return "question" }
|
||||||
|
func (QuestionTool) Description() string {
|
||||||
|
return "Stellt dem Nutzer eine Rückfrage, wenn eine Aufgabe mehrdeutig ist oder eine Entscheidung nötig ist, bevor es weitergeht."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (QuestionTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"question": {"type": "string"},
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Optionale Vorschläge, die dem Nutzer zusätzlich zur Frage angezeigt werden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["question"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type questionInput struct {
|
||||||
|
Question string `json:"question"`
|
||||||
|
Options []string `json:"options"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (QuestionTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in questionInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
if in.Question == "" {
|
||||||
|
return tool.Result{ForModel: "question must not be empty", IsError: true}, nil
|
||||||
|
}
|
||||||
|
if env.Ask == nil {
|
||||||
|
return tool.Result{
|
||||||
|
ForModel: "interactive questions are not supported in this mode (e.g. print mode) — proceed with your best judgment, or state your assumption in your next reply instead of asking",
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
answer, err := env.Ask(ctx, in.Question, in.Options)
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("question failed: %v", err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool.Result{
|
||||||
|
ForModel: answer,
|
||||||
|
ForUI: QuestionResult{Question: in.Question, Options: in.Options, Answer: answer},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuestionResult struct {
|
||||||
|
Question string
|
||||||
|
Options []string
|
||||||
|
Answer string
|
||||||
|
}
|
||||||
87
internal/tool/builtin/question_test.go
Normal file
87
internal/tool/builtin/question_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestQuestionTool_NilAskHookIsAClearError(t *testing.T) {
|
||||||
|
env := testEnv(t) // env.Ask bleibt nil, wie im Print-Modus
|
||||||
|
in, _ := json.Marshal(map[string]any{"question": "welches Format?"})
|
||||||
|
|
||||||
|
res, err := QuestionTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError when env.Ask is nil")
|
||||||
|
}
|
||||||
|
if !contains(res.ForModel, "not supported") {
|
||||||
|
t.Errorf("expected a clear explanation, got: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionTool_EmptyQuestionIsRejected(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
in, _ := json.Marshal(map[string]any{"question": ""})
|
||||||
|
|
||||||
|
res, err := QuestionTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected IsError for an empty question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionTool_ReturnsTheUsersAnswer(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
var gotQuestion string
|
||||||
|
var gotOptions []string
|
||||||
|
env.Ask = func(ctx context.Context, question string, options []string) (string, error) {
|
||||||
|
gotQuestion = question
|
||||||
|
gotOptions = options
|
||||||
|
return "JSON bitte", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]any{"question": "Welches Format?", "options": []string{"JSON", "YAML"}})
|
||||||
|
res, err := QuestionTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", res.ForModel)
|
||||||
|
}
|
||||||
|
if res.ForModel != "JSON bitte" {
|
||||||
|
t.Errorf("ForModel = %q, want the raw answer", res.ForModel)
|
||||||
|
}
|
||||||
|
if gotQuestion != "Welches Format?" {
|
||||||
|
t.Errorf("question passed to Ask = %q", gotQuestion)
|
||||||
|
}
|
||||||
|
if len(gotOptions) != 2 || gotOptions[0] != "JSON" || gotOptions[1] != "YAML" {
|
||||||
|
t.Errorf("options passed to Ask = %+v", gotOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
ui, ok := res.ForUI.(QuestionResult)
|
||||||
|
if !ok || ui.Answer != "JSON bitte" {
|
||||||
|
t.Errorf("ForUI = %+v", res.ForUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuestionTool_AskErrorSurfacesAsToolError(t *testing.T) {
|
||||||
|
env := testEnv(t)
|
||||||
|
env.Ask = func(ctx context.Context, question string, options []string) (string, error) {
|
||||||
|
return "", errors.New("boom")
|
||||||
|
}
|
||||||
|
in, _ := json.Marshal(map[string]any{"question": "x"})
|
||||||
|
|
||||||
|
res, err := QuestionTool{}.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Error("expected an Ask error to surface as IsError, not a Go error")
|
||||||
|
}
|
||||||
|
}
|
||||||
98
internal/tool/builtin/read.go
Normal file
98
internal/tool/builtin/read.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultReadLimit = 2000
|
||||||
|
|
||||||
|
type ReadTool struct{}
|
||||||
|
|
||||||
|
func (ReadTool) Name() string { return "read" }
|
||||||
|
func (ReadTool) Description() string {
|
||||||
|
return "Liest eine Datei mit Zeilennummern, optional ab einem Offset mit Limit."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ReadTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"offset": {"type": "integer", "description": "1-basierte Startzeile, Default 1"},
|
||||||
|
"limit": {"type": "integer", "description": "maximale Zeilenzahl, Default 2000"}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type readInput struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ReadTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in readInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
path, err := resolvePath(env.RepoRoot, env.Cwd, in.Path)
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("read %s: %v", in.Path, err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
if bytes.IndexByte(data, 0) != -1 {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("%s: binary file, not readable as text", in.Path), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := in.Offset
|
||||||
|
if offset < 1 {
|
||||||
|
offset = 1
|
||||||
|
}
|
||||||
|
limit := in.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultReadLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
if offset > len(lines) {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("%s: offset %d beyond file end (%d lines)", in.Path, offset, len(lines)), IsError: true}, nil
|
||||||
|
}
|
||||||
|
end := offset - 1 + limit
|
||||||
|
if end > len(lines) {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i := offset - 1; i < end; i++ {
|
||||||
|
fmt.Fprintf(&b, "%6d\t%s\n", i+1, lines[i])
|
||||||
|
}
|
||||||
|
truncated := end < len(lines)
|
||||||
|
|
||||||
|
return tool.Result{
|
||||||
|
ForModel: b.String(),
|
||||||
|
ForUI: FileResult{
|
||||||
|
Path: in.Path,
|
||||||
|
Lines: end - (offset - 1),
|
||||||
|
Truncated: truncated,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileResult ist die strukturierte UI-Repräsentation für read/write.
|
||||||
|
type FileResult struct {
|
||||||
|
Path string
|
||||||
|
Lines int
|
||||||
|
Truncated bool
|
||||||
|
}
|
||||||
67
internal/tool/builtin/read_skill.go
Normal file
67
internal/tool/builtin/read_skill.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"nub/internal/skill"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReadSkillTool lädt den Body eines im Index (nur Name+Description)
|
||||||
|
// angekündigten Skills nach (5.5, Progressive Disclosure).
|
||||||
|
type ReadSkillTool struct {
|
||||||
|
skills []skill.Skill
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReadSkillTool(skills []skill.Skill) *ReadSkillTool {
|
||||||
|
return &ReadSkillTool{skills: skills}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ReadSkillTool) Name() string { return "read_skill" }
|
||||||
|
func (*ReadSkillTool) Description() string {
|
||||||
|
return "Lädt den vollständigen Inhalt eines Skills nach Name."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ReadSkillTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type readSkillInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ReadSkillTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in readSkillInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, s := range t.skills {
|
||||||
|
if s.Name == in.Name {
|
||||||
|
return tool.Result{ForModel: s.Body, ForUI: SkillResult{Name: s.Name, Dir: s.Dir}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, len(t.skills))
|
||||||
|
for i, s := range t.skills {
|
||||||
|
names[i] = s.Name
|
||||||
|
}
|
||||||
|
return tool.Result{
|
||||||
|
ForModel: fmt.Sprintf("unknown skill %q. Available: %s", in.Name, strings.Join(names, ", ")),
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SkillResult struct {
|
||||||
|
Name string
|
||||||
|
Dir string
|
||||||
|
}
|
||||||
48
internal/tool/builtin/read_skill_test.go
Normal file
48
internal/tool/builtin/read_skill_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/skill"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadSkillTool_LoadsBodyByName(t *testing.T) {
|
||||||
|
skills := []skill.Skill{
|
||||||
|
{Name: "refactoring", Description: "d1", Body: "full refactoring body"},
|
||||||
|
{Name: "testing", Description: "d2", Body: "full testing body"},
|
||||||
|
}
|
||||||
|
rt := NewReadSkillTool(skills)
|
||||||
|
env := testEnv(t)
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]string{"name": "refactoring"})
|
||||||
|
res, err := rt.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", res.ForModel)
|
||||||
|
}
|
||||||
|
if res.ForModel != "full refactoring body" {
|
||||||
|
t.Errorf("ForModel = %q", res.ForModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadSkillTool_UnknownNameListsAvailable(t *testing.T) {
|
||||||
|
skills := []skill.Skill{{Name: "refactoring", Body: "x"}}
|
||||||
|
rt := NewReadSkillTool(skills)
|
||||||
|
env := testEnv(t)
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]string{"name": "does-not-exist"})
|
||||||
|
res, err := rt.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected error for unknown skill name")
|
||||||
|
}
|
||||||
|
if !contains(res.ForModel, "refactoring") {
|
||||||
|
t.Errorf("expected available skills listed in error, got: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
97
internal/tool/builtin/todo.go
Normal file
97
internal/tool/builtin/todo.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TodoTool hält eine In-Memory-Todo-Liste im Session-State (5.3) — nicht im
|
||||||
|
// Baum persistiert. Zustand lebt im Tool selbst, deshalb Pointer-Registrierung
|
||||||
|
// (&TodoTool{}) statt eines Werttyps.
|
||||||
|
type TodoTool struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items []TodoItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodoItem struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"` // pending | in_progress | completed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*TodoTool) Name() string { return "todo" }
|
||||||
|
func (*TodoTool) Description() string {
|
||||||
|
return "Ersetzt die aktuelle Todo-Liste. Für lange Tasks: Fortschritt sichtbar halten."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*TodoTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {"type": "string"},
|
||||||
|
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
|
||||||
|
},
|
||||||
|
"required": ["content", "status"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["items"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type todoInput struct {
|
||||||
|
Items []TodoItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TodoTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in todoInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
for _, it := range in.Items {
|
||||||
|
switch it.Status {
|
||||||
|
case "pending", "in_progress", "completed":
|
||||||
|
default:
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("invalid status %q", it.Status), IsError: true}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
t.items = in.Items
|
||||||
|
items := make([]TodoItem, len(t.items))
|
||||||
|
copy(items, t.items)
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
return tool.Result{ForModel: renderTodos(items), ForUI: TodoResult{Items: items}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type TodoResult struct {
|
||||||
|
Items []TodoItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTodos(items []TodoItem) string {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "todo list is empty"
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, it := range items {
|
||||||
|
mark := " "
|
||||||
|
switch it.Status {
|
||||||
|
case "in_progress":
|
||||||
|
mark = "~"
|
||||||
|
case "completed":
|
||||||
|
mark = "x"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "[%s] %s\n", mark, it.Content)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
81
internal/tool/builtin/todo_test.go
Normal file
81
internal/tool/builtin/todo_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTodoTool_ReplacesListAndRendersStatus(t *testing.T) {
|
||||||
|
tt := &TodoTool{}
|
||||||
|
env := testEnv(t)
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]any{
|
||||||
|
"items": []map[string]string{
|
||||||
|
{"content": "read file", "status": "completed"},
|
||||||
|
{"content": "write fix", "status": "in_progress"},
|
||||||
|
{"content": "run tests", "status": "pending"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := tt.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("unexpected error: %s", res.ForModel)
|
||||||
|
}
|
||||||
|
if !contains(res.ForModel, "[x] read file") {
|
||||||
|
t.Errorf("expected completed marker, got: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
if !contains(res.ForModel, "[~] write fix") {
|
||||||
|
t.Errorf("expected in_progress marker, got: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
if !contains(res.ForModel, "[ ] run tests") {
|
||||||
|
t.Errorf("expected pending marker, got: %q", res.ForModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
ui, ok := res.ForUI.(TodoResult)
|
||||||
|
if !ok || len(ui.Items) != 3 {
|
||||||
|
t.Errorf("ForUI = %+v, want TodoResult with 3 items", res.ForUI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodoTool_RejectsInvalidStatus(t *testing.T) {
|
||||||
|
tt := &TodoTool{}
|
||||||
|
env := testEnv(t)
|
||||||
|
|
||||||
|
in, _ := json.Marshal(map[string]any{
|
||||||
|
"items": []map[string]string{{"content": "x", "status": "bogus"}},
|
||||||
|
})
|
||||||
|
res, err := tt.Run(context.Background(), in, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("expected error for invalid status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodoTool_SecondCallReplacesFirst(t *testing.T) {
|
||||||
|
tt := &TodoTool{}
|
||||||
|
env := testEnv(t)
|
||||||
|
|
||||||
|
first, _ := json.Marshal(map[string]any{
|
||||||
|
"items": []map[string]string{{"content": "a", "status": "pending"}, {"content": "b", "status": "pending"}},
|
||||||
|
})
|
||||||
|
if _, err := tt.Run(context.Background(), first, env); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, _ := json.Marshal(map[string]any{
|
||||||
|
"items": []map[string]string{{"content": "c", "status": "pending"}},
|
||||||
|
})
|
||||||
|
res, err := tt.Run(context.Background(), second, env)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ui := res.ForUI.(TodoResult)
|
||||||
|
if len(ui.Items) != 1 || ui.Items[0].Content != "c" {
|
||||||
|
t.Errorf("expected list replaced with single item 'c', got: %+v", ui.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
59
internal/tool/builtin/write.go
Normal file
59
internal/tool/builtin/write.go
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
package builtin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WriteTool struct{}
|
||||||
|
|
||||||
|
func (WriteTool) Name() string { return "write" }
|
||||||
|
func (WriteTool) Description() string {
|
||||||
|
return "Schreibt eine Datei vollständig (legt Parent-Dirs an)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (WriteTool) Schema() json.RawMessage {
|
||||||
|
return json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string"},
|
||||||
|
"content": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": ["path", "content"]
|
||||||
|
}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
type writeInput struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (WriteTool) Run(ctx context.Context, input json.RawMessage, env tool.Env) (tool.Result, error) {
|
||||||
|
var in writeInput
|
||||||
|
if err := json.Unmarshal(input, &in); err != nil {
|
||||||
|
return tool.Result{ForModel: "invalid input: " + err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
path, err := resolvePath(env.RepoRoot, env.Cwd, in.Path)
|
||||||
|
if err != nil {
|
||||||
|
return tool.Result{ForModel: err.Error(), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
old, _ := os.ReadFile(path) // fehlt die Datei, ist old leer -> reiner Add-Diff
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("write %s: %v", in.Path, err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(in.Content), 0o644); err != nil {
|
||||||
|
return tool.Result{ForModel: fmt.Sprintf("write %s: %v", in.Path, err), IsError: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool.Result{
|
||||||
|
ForModel: fmt.Sprintf("wrote %s (%d bytes)", in.Path, len(in.Content)),
|
||||||
|
ForUI: DiffResult{Path: in.Path, Lines: lineDiff(string(old), in.Content)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
89
internal/tool/tool.go
Normal file
89
internal/tool/tool.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// Package tool definiert das Tool-Interface, Ergebnis-Typen und die Registry,
|
||||||
|
// über die der Agent-Loop Tools parallel ausführt.
|
||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Tool interface {
|
||||||
|
Name() string
|
||||||
|
Description() string
|
||||||
|
Schema() json.RawMessage
|
||||||
|
Run(ctx context.Context, input json.RawMessage, env Env) (Result, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result ist zweigeteilt (E-05): ForModel geht knapp in den tool_result-Block,
|
||||||
|
// ForUI ist strukturiert (z.B. ein Diff) und für den jeweiligen Renderer gedacht.
|
||||||
|
type Result struct {
|
||||||
|
ForModel string
|
||||||
|
ForUI any
|
||||||
|
IsError bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Env struct {
|
||||||
|
Cwd string
|
||||||
|
RepoRoot string
|
||||||
|
Emit func(UIEvent) // für streamende Tools (bash)
|
||||||
|
|
||||||
|
// Ask stellt dem Nutzer eine Rückfrage und blockiert auf die Antwort.
|
||||||
|
// nil (typischerweise im Print-Modus) bedeutet: interaktive Rückfragen
|
||||||
|
// werden hier nicht unterstützt — das question-Tool meldet das dann als
|
||||||
|
// klaren Fehler statt zu hängen.
|
||||||
|
Ask func(ctx context.Context, question string, options []string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry hält die zur Laufzeit verfügbaren Tools (built-in + MCP-Bridge).
|
||||||
|
type Registry struct {
|
||||||
|
tools map[string]Tool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{tools: make(map[string]Tool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Register(t Tool) {
|
||||||
|
r.tools[t.Name()] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Get(name string) (Tool, bool) {
|
||||||
|
t, ok := r.tools[name]
|
||||||
|
return t, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defs liefert die Tool-Definitionen in stabiler (nach Name sortierter)
|
||||||
|
// Reihenfolge. Wichtig für Prompt-Caching (E-06): tools steht im Request vor
|
||||||
|
// messages und ist Teil des gecachten Prefix — Go-Map-Iteration ist pro
|
||||||
|
// Aufruf zufällig, eine unsortierte Ausgabe hätte den Cache bei jedem
|
||||||
|
// einzelnen Turn neu gebrochen.
|
||||||
|
func (r *Registry) Defs() []Def {
|
||||||
|
defs := make([]Def, 0, len(r.tools))
|
||||||
|
for _, t := range r.tools {
|
||||||
|
defs = append(defs, Def{
|
||||||
|
Name: t.Name(),
|
||||||
|
Description: t.Description(),
|
||||||
|
Schema: t.Schema(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(defs, func(i, j int) bool { return defs[i].Name < defs[j].Name })
|
||||||
|
return defs
|
||||||
|
}
|
||||||
|
|
||||||
|
type Def struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Schema json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run führt ein registriertes Tool aus; unbekannte Namen liefern ein
|
||||||
|
// Fehlerergebnis statt eines Panics (jeder tool_use braucht ein Ergebnis).
|
||||||
|
func (r *Registry) Run(ctx context.Context, name string, input json.RawMessage, env Env) (Result, error) {
|
||||||
|
t, ok := r.tools[name]
|
||||||
|
if !ok {
|
||||||
|
return Result{ForModel: fmt.Sprintf("unknown tool: %s", name), IsError: true}, nil
|
||||||
|
}
|
||||||
|
return t.Run(ctx, input, env)
|
||||||
|
}
|
||||||
48
internal/tool/tool_test.go
Normal file
48
internal/tool/tool_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package tool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stubTool struct{ name string }
|
||||||
|
|
||||||
|
func (s stubTool) Name() string { return s.name }
|
||||||
|
func (s stubTool) Description() string { return "" }
|
||||||
|
func (s stubTool) Schema() json.RawMessage { return json.RawMessage(`{}`) }
|
||||||
|
func (s stubTool) Run(ctx context.Context, input json.RawMessage, env Env) (Result, error) {
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegistry_Defs_StableOrder sichert einen konkreten Bug ab: Defs()
|
||||||
|
// iterierte früher direkt über die interne Map, deren Reihenfolge Go pro
|
||||||
|
// Aufruf zufällig mischt. Da tools im Request vor messages steht und Teil
|
||||||
|
// des gecachten Prefix ist (E-06), hat das Prompt-Caching bei jedem Turn
|
||||||
|
// neu gebrochen. Defs() muss deterministisch (nach Name) sortiert sein.
|
||||||
|
func TestRegistry_Defs_StableOrder(t *testing.T) {
|
||||||
|
r := NewRegistry()
|
||||||
|
names := []string{"read", "write", "edit", "bash", "glob", "grep", "todo", "read_skill"}
|
||||||
|
for _, n := range names {
|
||||||
|
r.Register(stubTool{name: n})
|
||||||
|
}
|
||||||
|
|
||||||
|
first := r.Defs()
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
got := r.Defs()
|
||||||
|
if len(got) != len(first) {
|
||||||
|
t.Fatalf("Defs() length changed between calls: %d vs %d", len(got), len(first))
|
||||||
|
}
|
||||||
|
for j := range got {
|
||||||
|
if got[j].Name != first[j].Name {
|
||||||
|
t.Fatalf("Defs() order not stable across calls (iteration %d): %+v vs %+v", i, got, first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 1; i < len(first); i++ {
|
||||||
|
if first[i-1].Name > first[i].Name {
|
||||||
|
t.Errorf("Defs() not sorted by name: %q before %q", first[i-1].Name, first[i].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
internal/tool/uievent.go
Normal file
51
internal/tool/uievent.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package tool
|
||||||
|
|
||||||
|
import "nub/internal/llm"
|
||||||
|
|
||||||
|
// UIEvent ist der einzige Kanal, über den Kern-Pakete Ausgabe erzeugen (E-04).
|
||||||
|
// Print-Modus, JSON-Modus und TUI sind dünne Consumer davon.
|
||||||
|
type UIEvent interface{ isUIEvent() }
|
||||||
|
|
||||||
|
type TextDelta struct{ Text string }
|
||||||
|
type ThinkingDelta struct{ Text string }
|
||||||
|
|
||||||
|
type ToolCallStart struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Input []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCallOutput struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Result Result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolStream trägt Zwischenausgabe eines laufenden Tools (z.B. bash-stdout).
|
||||||
|
type ToolStream struct {
|
||||||
|
ID string
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TurnDone struct {
|
||||||
|
Stop llm.StopReason
|
||||||
|
Usage llm.Usage
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorEvent struct{ Err error }
|
||||||
|
|
||||||
|
// CompactionEvent meldet eine abgeschlossene Auto- oder manuelle Compaction
|
||||||
|
// (5.7): wie viele Nodes durch die Zusammenfassung ersetzt wurden.
|
||||||
|
type CompactionEvent struct {
|
||||||
|
ReplacedNodes int
|
||||||
|
SummaryTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TextDelta) isUIEvent() {}
|
||||||
|
func (ThinkingDelta) isUIEvent() {}
|
||||||
|
func (ToolCallStart) isUIEvent() {}
|
||||||
|
func (ToolCallOutput) isUIEvent() {}
|
||||||
|
func (ToolStream) isUIEvent() {}
|
||||||
|
func (TurnDone) isUIEvent() {}
|
||||||
|
func (ErrorEvent) isUIEvent() {}
|
||||||
|
func (CompactionEvent) isUIEvent() {}
|
||||||
61
internal/ui/plain/plain.go
Normal file
61
internal/ui/plain/plain.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Package plain ist der stdout-Renderer für den Print-Modus (5.8): Textdeltas
|
||||||
|
// nach stdout, Tool-Aktivität nach stderr. Reiner Consumer von tool.UIEvent.
|
||||||
|
package plain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Renderer struct {
|
||||||
|
Out io.Writer
|
||||||
|
Err io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render konsumiert den Event-Channel, bis er schließt, und liefert den
|
||||||
|
// zuletzt gesehenen Fehler zurück (für die Exit-Code-Entscheidung in main).
|
||||||
|
func (r *Renderer) Render(events <-chan tool.UIEvent) error {
|
||||||
|
var lastErr error
|
||||||
|
openText := false
|
||||||
|
|
||||||
|
for ev := range events {
|
||||||
|
switch e := ev.(type) {
|
||||||
|
case tool.TextDelta:
|
||||||
|
fmt.Fprint(r.Out, e.Text)
|
||||||
|
openText = true
|
||||||
|
case tool.ThinkingDelta:
|
||||||
|
// v1: kein separates Rendering für Thinking im Print-Modus.
|
||||||
|
case tool.ToolCallStart:
|
||||||
|
if openText {
|
||||||
|
fmt.Fprintln(r.Out)
|
||||||
|
openText = false
|
||||||
|
}
|
||||||
|
fmt.Fprintf(r.Err, "→ %s %s\n", e.Name, string(e.Input))
|
||||||
|
case tool.ToolStream:
|
||||||
|
fmt.Fprint(r.Err, e.Text)
|
||||||
|
case tool.ToolCallOutput:
|
||||||
|
status := "ok"
|
||||||
|
if e.Result.IsError {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(r.Err, "← %s [%s]\n", e.Name, status)
|
||||||
|
case tool.TurnDone:
|
||||||
|
if openText {
|
||||||
|
fmt.Fprintln(r.Out)
|
||||||
|
openText = false
|
||||||
|
}
|
||||||
|
if e.Usage.InputTokens > 0 || e.Usage.OutputTokens > 0 {
|
||||||
|
fmt.Fprintf(r.Err, "usage: input=%d output=%d cache_read=%d\n",
|
||||||
|
e.Usage.InputTokens, e.Usage.OutputTokens, e.Usage.CacheReadTokens)
|
||||||
|
}
|
||||||
|
case tool.ErrorEvent:
|
||||||
|
fmt.Fprintf(r.Err, "error: %v\n", e.Err)
|
||||||
|
lastErr = e.Err
|
||||||
|
case tool.CompactionEvent:
|
||||||
|
fmt.Fprintf(r.Err, "compacted %d nodes into a %d-token summary\n", e.ReplacedNodes, e.SummaryTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
563
internal/ui/tui/commands.go
Normal file
563
internal/ui/tui/commands.go
Normal file
|
|
@ -0,0 +1,563 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"nub/internal/ctxasm"
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/scaffold"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/skill"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Model) runCommand(text string) tea.Cmd {
|
||||||
|
fields := strings.Fields(text)
|
||||||
|
cmd := fields[0]
|
||||||
|
args := fields[1:]
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "/help":
|
||||||
|
m.pushCommand(m.helpText())
|
||||||
|
return nil
|
||||||
|
case "/clear":
|
||||||
|
m.entries = nil
|
||||||
|
m.renderViewport()
|
||||||
|
return nil
|
||||||
|
case "/exit", "/quit":
|
||||||
|
// Nicht hier canceln, siehe Kommentar bei KeyCtrlC in model.go.
|
||||||
|
m.quitting = true
|
||||||
|
return tea.Quit
|
||||||
|
case "/model":
|
||||||
|
return m.cmdModel(args)
|
||||||
|
case "/context":
|
||||||
|
return m.cmdContext()
|
||||||
|
case "/skills":
|
||||||
|
return m.cmdSkills()
|
||||||
|
case "/mcp":
|
||||||
|
return m.cmdMCP()
|
||||||
|
case "/tree", "/branch":
|
||||||
|
return m.cmdOpenTree()
|
||||||
|
case "/sessions":
|
||||||
|
return m.cmdSessions()
|
||||||
|
case "/compact":
|
||||||
|
return m.cmdCompact()
|
||||||
|
case "/export":
|
||||||
|
return m.cmdExport(args)
|
||||||
|
case "/init":
|
||||||
|
return m.cmdInit()
|
||||||
|
default:
|
||||||
|
if s, ok := m.findSkill(cmd); ok {
|
||||||
|
return m.cmdInvokeSkill(s, args)
|
||||||
|
}
|
||||||
|
m.pushError("unknown command: " + cmd + " (siehe /help)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSkill matcht einen Slash-Command-Namen (führendes "/" entfernt) gegen
|
||||||
|
// die bekannten Skills. Eingebaute Kommandos gewinnen immer — findSkill wird
|
||||||
|
// nur im default-Fall von runCommand aufgerufen, also erst nachdem kein
|
||||||
|
// eingebautes Kommando gepasst hat.
|
||||||
|
func (m *Model) findSkill(cmd string) (skill.Skill, bool) {
|
||||||
|
name := strings.TrimPrefix(cmd, "/")
|
||||||
|
for _, s := range m.skills {
|
||||||
|
if s.Name == name {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skill.Skill{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdInvokeSkill schickt eine kurze Direktive statt des vollen Skill-Bodys —
|
||||||
|
// das Modell lädt den Body weiterhin selbst über read_skill nach (Progressive
|
||||||
|
// Disclosure bleibt intakt, der Slash-Command ist nur eine Abkürzung dafür,
|
||||||
|
// dem Modell zu sagen, welchen Skill es anwenden soll).
|
||||||
|
func (m *Model) cmdInvokeSkill(s skill.Skill, args []string) tea.Cmd {
|
||||||
|
instruction := fmt.Sprintf("Nutze den Skill %q (via read_skill).", s.Name)
|
||||||
|
if len(args) > 0 {
|
||||||
|
instruction += " " + strings.Join(args, " ")
|
||||||
|
}
|
||||||
|
return m.sendAsMessage(instruction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// helpCommands ist die einzige Quelle für /help — Reihenfolge und Text
|
||||||
|
// bewusst hier zentralisiert statt verstreut in runCommand.
|
||||||
|
var helpCommands = []struct{ cmd, desc string }{
|
||||||
|
{"/model [name]", "aktuelles Modell anzeigen, oder für die restliche Session wechseln"},
|
||||||
|
{"/tree", "Session-Baum anzeigen, mit ↑/↓ einen Knoten wählen"},
|
||||||
|
{"/branch", "wie /tree — von einem früheren Knoten aus weiterarbeiten (Rewind)"},
|
||||||
|
{"/sessions", "Sessions auflisten, wechseln (Enter) oder löschen (Entf)"},
|
||||||
|
{"/compact", "Session sofort zusammenfassen, unabhängig vom Token-Schwellwert"},
|
||||||
|
{"/context", "geladenen Kontext mit Herkunft und Tokenkosten anzeigen"},
|
||||||
|
{"/mcp", "verbundene MCP-Server und ihre Tools auflisten"},
|
||||||
|
{"/skills", "gefundene Skills (Name + Beschreibung) auflisten"},
|
||||||
|
{"/export [pfad]", "Transkript als Markdown-Datei exportieren"},
|
||||||
|
{"/init", "Beispiel-Config (.nub/config.toml) und AGENTS.md anlegen, falls nicht vorhanden"},
|
||||||
|
{"/clear", "nur die Anzeige leeren — die Session bleibt unverändert"},
|
||||||
|
{"/exit, /quit", "nub beenden"},
|
||||||
|
{"/help", "diese Übersicht anzeigen"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) helpText() string {
|
||||||
|
width := 0
|
||||||
|
for _, c := range helpCommands {
|
||||||
|
if len(c.cmd) > width {
|
||||||
|
width = len(c.cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range m.skills {
|
||||||
|
if l := len("/" + s.Name); l > width {
|
||||||
|
width = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("Kommandos:\n")
|
||||||
|
for _, c := range helpCommands {
|
||||||
|
fmt.Fprintf(&b, " %-*s %s\n", width, c.cmd, c.desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.skills) > 0 {
|
||||||
|
b.WriteString("\nSkills (als Kommando nutzbar):\n")
|
||||||
|
for _, s := range m.skills {
|
||||||
|
fmt.Fprintf(&b, " %-*s %s\n", width, "/"+s.Name, s.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) cmdModel(args []string) tea.Cmd {
|
||||||
|
if len(args) == 0 {
|
||||||
|
m.pushCommand("current model: " + m.modelName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.modelName = args[0]
|
||||||
|
m.loop.Model = args[0]
|
||||||
|
m.pushCommand("model set to " + args[0])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) cmdContext() tea.Cmd {
|
||||||
|
system, meta, err := ctxasm.Assemble(ctxasm.Options{
|
||||||
|
RepoRoot: m.loop.Env.RepoRoot,
|
||||||
|
Cwd: m.loop.Env.RepoRoot,
|
||||||
|
Files: m.cfg.Context.Files,
|
||||||
|
WalkUp: m.cfg.Context.WalkUp,
|
||||||
|
MaxTokens: m.cfg.Context.MaxTokens,
|
||||||
|
SkillsIndex: skill.RenderIndex(m.skills),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
m.pushError("context: " + err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "context: %d Blöcke, %d Dateitokens (Budget %d)\n", len(system), meta.TotalTokens, m.cfg.Context.MaxTokens)
|
||||||
|
for _, f := range meta.Files {
|
||||||
|
trunc := ""
|
||||||
|
if f.Truncated {
|
||||||
|
trunc = " [truncated]"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %-24s %6d tokens %s%s\n", f.Path, f.Tokens, f.Source, trunc)
|
||||||
|
}
|
||||||
|
for _, w := range meta.Warnings {
|
||||||
|
fmt.Fprintf(&b, " warning: %s\n", w)
|
||||||
|
}
|
||||||
|
m.pushCommand(strings.TrimRight(b.String(), "\n"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) cmdSkills() tea.Cmd {
|
||||||
|
if len(m.skills) == 0 {
|
||||||
|
m.pushCommand("no skills found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("skills:\n")
|
||||||
|
for _, s := range m.skills {
|
||||||
|
fmt.Fprintf(&b, " %-20s %s\n", s.Name, s.Description)
|
||||||
|
}
|
||||||
|
m.pushCommand(strings.TrimRight(b.String(), "\n"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) cmdMCP() tea.Cmd {
|
||||||
|
if len(m.mcpServers) == 0 {
|
||||||
|
m.pushCommand("no mcp servers connected")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func() tea.Msg {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("mcp servers:\n")
|
||||||
|
for _, srv := range m.mcpServers {
|
||||||
|
var names []string
|
||||||
|
for t, err := range srv.Session.Tools(m.ctx, nil) {
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
names = append(names, t.Name)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, " %s: %s\n", srv.Name, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
return asyncCommandMsg{text: strings.TrimRight(b.String(), "\n")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) cmdCompact() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
ch := make(chan tool.UIEvent, 4)
|
||||||
|
var got []tool.UIEvent
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for ev := range ch {
|
||||||
|
got = append(got, ev)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ctx, cancel := context.WithTimeout(m.ctx, 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := m.loop.Compact(ctx, ch)
|
||||||
|
close(ch)
|
||||||
|
<-done
|
||||||
|
return compactDoneMsg{events: got, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) cmdExport(args []string) tea.Cmd {
|
||||||
|
path := fmt.Sprintf("nub-export-%s.md", shortID(m.store.ID))
|
||||||
|
if len(args) > 0 {
|
||||||
|
path = args[0]
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# nub session %s\n\n", m.store.ID)
|
||||||
|
for _, e := range m.entries {
|
||||||
|
b.WriteString(renderEntry(e))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
|
||||||
|
m.pushError("export: " + err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.pushCommand("exported to " + path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdInit legt .nub/config.toml (Beispiel-Config inkl. Permissions) und ein
|
||||||
|
// minimalistisches AGENTS.md an — nur was noch fehlt, nichts wird
|
||||||
|
// überschrieben. Wirkt erst ab dem nächsten Start von nub (die laufende
|
||||||
|
// Session hat ihre Config schon geladen).
|
||||||
|
func (m *Model) cmdInit() tea.Cmd {
|
||||||
|
res, err := scaffold.Init(m.loop.Env.RepoRoot)
|
||||||
|
if err != nil {
|
||||||
|
m.pushError("init: " + err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
if res.ConfigCreated {
|
||||||
|
fmt.Fprintf(&b, "angelegt: %s\n", res.ConfigPath)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "übersprungen (existiert schon): %s\n", res.ConfigPath)
|
||||||
|
}
|
||||||
|
if res.AgentsCreated {
|
||||||
|
fmt.Fprintf(&b, "angelegt: %s\n", res.AgentsPath)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "übersprungen (existiert schon): %s\n", res.AgentsPath)
|
||||||
|
}
|
||||||
|
if res.ConfigCreated || res.AgentsCreated {
|
||||||
|
b.WriteString("\nWirkt ab dem nächsten Start von nub — diese Session läuft mit der bereits geladenen Config weiter.")
|
||||||
|
}
|
||||||
|
m.pushCommand(strings.TrimRight(b.String(), "\n"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generisches Listen-Modal: /tree, /branch, /sessions ---
|
||||||
|
|
||||||
|
// listModal ist ein wiederverwendbarer Auswahl-Dialog (↑/↓ + Enter), optional
|
||||||
|
// mit Löschen (Entf, mit Rückfrage). onSelect/onDelete laufen im Model-
|
||||||
|
// Kontext, damit sie Session/Store direkt anfassen können.
|
||||||
|
type listModal struct {
|
||||||
|
title string
|
||||||
|
items []listItem
|
||||||
|
cursor int
|
||||||
|
|
||||||
|
confirmDelete bool
|
||||||
|
|
||||||
|
onSelect func(m *Model, item listItem) tea.Cmd
|
||||||
|
onDelete func(m *Model, item listItem) tea.Cmd // nil = Löschen nicht angeboten
|
||||||
|
}
|
||||||
|
|
||||||
|
type listItem struct {
|
||||||
|
id string
|
||||||
|
label string
|
||||||
|
marker string // z.B. "(head)" oder "(aktiv)"
|
||||||
|
protect bool // z.B. aktuelle Session: nicht löschbar
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) cmdOpenTree() tea.Cmd {
|
||||||
|
type row struct {
|
||||||
|
id string
|
||||||
|
created time.Time
|
||||||
|
label string
|
||||||
|
isHead bool
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
for id, n := range m.store.Nodes {
|
||||||
|
label := firstLine(textOf(n))
|
||||||
|
if len(label) > 60 {
|
||||||
|
label = label[:60] + "…"
|
||||||
|
}
|
||||||
|
rows = append(rows, row{id: id, created: n.Meta.CreatedAt, label: label, isHead: id == m.store.Head})
|
||||||
|
}
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].created.Before(rows[j].created) })
|
||||||
|
|
||||||
|
items := make([]listItem, len(rows))
|
||||||
|
cursor := 0
|
||||||
|
for i, r := range rows {
|
||||||
|
marker := ""
|
||||||
|
if r.isHead {
|
||||||
|
marker = "(head)"
|
||||||
|
cursor = i
|
||||||
|
}
|
||||||
|
items[i] = listItem{id: r.id, label: fmt.Sprintf("%s %s", shortID(r.id), r.label), marker: marker}
|
||||||
|
}
|
||||||
|
m.modal = &listModal{
|
||||||
|
title: "Session-Baum",
|
||||||
|
items: items, cursor: cursor,
|
||||||
|
onSelect: func(m *Model, item listItem) tea.Cmd {
|
||||||
|
if err := m.store.Branch(item.id); err != nil {
|
||||||
|
m.pushError("branch: " + err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.pushCommand(fmt.Sprintf("head set to %s", shortID(item.id)))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdSessions listet alle Sessions im Projekt, erlaubt Wechseln (Enter) und
|
||||||
|
// Löschen (Entf, mit Rückfrage) — die aktive Session lässt sich nicht
|
||||||
|
// löschen.
|
||||||
|
func (m *Model) cmdSessions() tea.Cmd {
|
||||||
|
infos, err := session.List(m.loop.Env.RepoRoot)
|
||||||
|
if err != nil {
|
||||||
|
m.pushError("sessions: " + err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(infos) == 0 {
|
||||||
|
m.pushCommand("no sessions in this directory")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]listItem, len(infos))
|
||||||
|
cursor := 0
|
||||||
|
for i, info := range infos {
|
||||||
|
marker := ""
|
||||||
|
active := info.ID == m.store.ID
|
||||||
|
if active {
|
||||||
|
marker = "(aktiv)"
|
||||||
|
cursor = i
|
||||||
|
}
|
||||||
|
summary := info.Summary
|
||||||
|
if summary == "" {
|
||||||
|
summary = "(kein Titel)"
|
||||||
|
}
|
||||||
|
items[i] = listItem{
|
||||||
|
id: info.ID,
|
||||||
|
label: fmt.Sprintf("%s %-40s %d nodes %s", shortID(info.ID), summary, info.NodeCount, info.Created.Format("2006-01-02 15:04")),
|
||||||
|
marker: marker,
|
||||||
|
protect: active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.modal = &listModal{
|
||||||
|
title: "Sessions",
|
||||||
|
items: items, cursor: cursor,
|
||||||
|
onSelect: func(m *Model, item listItem) tea.Cmd {
|
||||||
|
if item.id == m.store.ID {
|
||||||
|
m.pushCommand("already on this session")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
newStore, err := session.Load(m.loop.Env.RepoRoot, item.id)
|
||||||
|
if err != nil {
|
||||||
|
m.pushError("resume: " + err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
old := m.store
|
||||||
|
cmd := m.startSession(newStore)
|
||||||
|
_ = old.Close()
|
||||||
|
m.pushCommand("switched to session " + shortID(item.id))
|
||||||
|
return cmd
|
||||||
|
},
|
||||||
|
onDelete: func(m *Model, item listItem) tea.Cmd {
|
||||||
|
if item.id == m.store.ID {
|
||||||
|
m.pushError("cannot delete the active session")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := session.Delete(m.loop.Env.RepoRoot, item.id); err != nil {
|
||||||
|
m.pushError("delete: " + err.Error())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.pushCommand("deleted session " + shortID(item.id))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textOf(n *session.Node) string {
|
||||||
|
for _, b := range n.Message.Content {
|
||||||
|
if b.Text != "" {
|
||||||
|
return b.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(n.Kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
lm := m.modal
|
||||||
|
|
||||||
|
if lm.confirmDelete {
|
||||||
|
if msg.String() == "y" || msg.String() == "Y" {
|
||||||
|
item := lm.items[lm.cursor]
|
||||||
|
lm.confirmDelete = false
|
||||||
|
m.modal = nil
|
||||||
|
return m, lm.onDelete(m, item)
|
||||||
|
}
|
||||||
|
lm.confirmDelete = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyEsc, tea.KeyCtrlC:
|
||||||
|
m.modal = nil
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyUp:
|
||||||
|
if lm.cursor > 0 {
|
||||||
|
lm.cursor--
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyDown:
|
||||||
|
if lm.cursor < len(lm.items)-1 {
|
||||||
|
lm.cursor++
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyEnter:
|
||||||
|
item := lm.items[lm.cursor]
|
||||||
|
m.modal = nil
|
||||||
|
if lm.onSelect == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, lm.onSelect(m, item)
|
||||||
|
case tea.KeyDelete:
|
||||||
|
if lm.onDelete == nil || len(lm.items) == 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if lm.items[lm.cursor].protect {
|
||||||
|
m.pushError("cannot delete the active session")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
lm.confirmDelete = true
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderModal() string {
|
||||||
|
lm := m.modal
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
if lm.confirmDelete {
|
||||||
|
fmt.Fprintf(&b, "%s wirklich löschen? [y/N]", lm.items[lm.cursor].label)
|
||||||
|
return styleModalBox.Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, "%s — ↑/↓ wählen, Enter bestätigen", lm.title)
|
||||||
|
if lm.onDelete != nil {
|
||||||
|
b.WriteString(", Entf löscht")
|
||||||
|
}
|
||||||
|
b.WriteString(", Esc abbrechen\n\n")
|
||||||
|
for i, it := range lm.items {
|
||||||
|
line := it.label
|
||||||
|
if it.marker != "" {
|
||||||
|
line += " " + it.marker
|
||||||
|
}
|
||||||
|
if i == lm.cursor {
|
||||||
|
b.WriteString(styleModalSel.Render("> " + line))
|
||||||
|
} else {
|
||||||
|
b.WriteString(" " + line)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return styleModalBox.Render(strings.TrimRight(b.String(), "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumUsage summiert die pro Node gespeicherte Token-Nutzung (NodeMeta.Usage)
|
||||||
|
// einer Session-Historie auf — Grundlage für die Statuszeile beim Start
|
||||||
|
// oder Wechsel einer Session (sonst zeigt sie fälschlich 0/den Stand der
|
||||||
|
// vorherigen Session an, obwohl die tatsächliche Nutzung längst persistiert
|
||||||
|
// ist).
|
||||||
|
func sumUsage(nodes []*session.Node) llm.Usage {
|
||||||
|
var total llm.Usage
|
||||||
|
for _, n := range nodes {
|
||||||
|
total.InputTokens += n.Meta.Usage.InputTokens
|
||||||
|
total.OutputTokens += n.Meta.Usage.OutputTokens
|
||||||
|
total.CacheReadTokens += n.Meta.Usage.CacheReadTokens
|
||||||
|
total.CacheWriteTokens += n.Meta.Usage.CacheWriteTokens
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayEntries baut das Transkript aus der Historie einer (neu geladenen
|
||||||
|
// oder gewechselten) Session nach — sonst wäre nach /sessions oder einem
|
||||||
|
// `nub resume` in der TUI der bisherige Verlauf unsichtbar, obwohl der
|
||||||
|
// Loop ihn im Kontext hat.
|
||||||
|
func replayEntries(nodes []*session.Node) []entry {
|
||||||
|
var out []entry
|
||||||
|
toolNames := map[string]string{}
|
||||||
|
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.Kind == session.NodeSummary {
|
||||||
|
out = append(out, entry{kind: entrySystem, text: "zusammengefasste Vorgeschichte:\n" + textOf(n)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, b := range n.Message.Content {
|
||||||
|
switch b.Kind {
|
||||||
|
case llm.KindText:
|
||||||
|
if b.Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.Message.Role == llm.RoleUser {
|
||||||
|
out = append(out, entry{kind: entryUser, text: b.Text})
|
||||||
|
} else {
|
||||||
|
out = append(out, entry{kind: entryAssistant, text: b.Text})
|
||||||
|
}
|
||||||
|
case llm.KindToolUse:
|
||||||
|
toolNames[b.ID] = b.Name
|
||||||
|
out = append(out, entry{kind: entryToolCall, text: renderToolCall(b.Name, b.Input)})
|
||||||
|
case llm.KindToolResult:
|
||||||
|
name := toolNames[b.ToolUseID]
|
||||||
|
if name == "" {
|
||||||
|
name = "tool"
|
||||||
|
}
|
||||||
|
var resText strings.Builder
|
||||||
|
for _, r := range b.Result {
|
||||||
|
resText.WriteString(r.Text)
|
||||||
|
}
|
||||||
|
out = append(out, entry{
|
||||||
|
kind: entryToolResult,
|
||||||
|
text: renderToolResult(name, tool.Result{ForModel: resText.String(), IsError: b.IsError}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
313
internal/ui/tui/commands_test.go
Normal file
313
internal/ui/tui/commands_test.go
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"nub/internal/agent"
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func enterKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEnter} }
|
||||||
|
func escKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyEsc} }
|
||||||
|
func deleteKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyDelete} }
|
||||||
|
func yKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")} }
|
||||||
|
func nKey() tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")} }
|
||||||
|
|
||||||
|
func newTestModelWithStore(t *testing.T) *Model {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
|
||||||
|
return &Model{
|
||||||
|
ctx: context.Background(),
|
||||||
|
store: store,
|
||||||
|
textarea: textarea.New(),
|
||||||
|
viewport: viewport.New(80, 20),
|
||||||
|
spinner: spinner.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func textMsg(role llm.Role, text string) llm.Message {
|
||||||
|
return llm.Message{Role: role, Content: []llm.Block{{Kind: llm.KindText, Text: text}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findItem(items []listItem, id string) int {
|
||||||
|
for i, it := range items {
|
||||||
|
if it.id == id {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdOpenTree_MarksCurrentHead(t *testing.T) {
|
||||||
|
m := newTestModelWithStore(t)
|
||||||
|
|
||||||
|
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
|
||||||
|
if err := m.store.Append(n1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "second"), session.NodeMeta{})
|
||||||
|
if err := m.store.Append(n2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cmdOpenTree()
|
||||||
|
if m.modal == nil {
|
||||||
|
t.Fatal("expected a modal to be opened")
|
||||||
|
}
|
||||||
|
if len(m.modal.items) != 2 {
|
||||||
|
t.Fatalf("got %d items, want 2", len(m.modal.items))
|
||||||
|
}
|
||||||
|
if m.modal.items[m.modal.cursor].id != n2.ID {
|
||||||
|
t.Errorf("cursor should default to the current head (%s), got %s", n2.ID, m.modal.items[m.modal.cursor].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleModalKey_EnterBranchesToSelectedNode(t *testing.T) {
|
||||||
|
m := newTestModelWithStore(t)
|
||||||
|
|
||||||
|
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
|
||||||
|
if err := m.store.Append(n1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "second"), session.NodeMeta{})
|
||||||
|
if err := m.store.Append(n2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cmdOpenTree()
|
||||||
|
m.modal.cursor = findItem(m.modal.items, n1.ID) // n1 auswählen, nicht den aktuellen Head n2
|
||||||
|
|
||||||
|
m.handleModalKey(enterKey())
|
||||||
|
|
||||||
|
if m.modal != nil {
|
||||||
|
t.Error("expected modal to close after selection")
|
||||||
|
}
|
||||||
|
if m.store.Head != n1.ID {
|
||||||
|
t.Errorf("head = %s, want %s (branch to selected node)", m.store.Head, n1.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleModalKey_EscClosesWithoutBranching(t *testing.T) {
|
||||||
|
m := newTestModelWithStore(t)
|
||||||
|
n1 := session.NewNode("", textMsg(llm.RoleUser, "first"), session.NodeMeta{})
|
||||||
|
if err := m.store.Append(n1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
originalHead := m.store.Head
|
||||||
|
m.cmdOpenTree()
|
||||||
|
|
||||||
|
m.handleModalKey(escKey())
|
||||||
|
|
||||||
|
if m.modal != nil {
|
||||||
|
t.Error("expected modal to close on esc")
|
||||||
|
}
|
||||||
|
if m.store.Head != originalHead {
|
||||||
|
t.Error("esc must not change the head")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestModelForSessions(t *testing.T, dir string, store *session.Store) *Model {
|
||||||
|
t.Helper()
|
||||||
|
return &Model{
|
||||||
|
ctx: context.Background(),
|
||||||
|
store: store,
|
||||||
|
loop: &agent.Loop{Env: tool.Env{RepoRoot: dir}},
|
||||||
|
textarea: textarea.New(),
|
||||||
|
viewport: viewport.New(80, 20),
|
||||||
|
spinner: spinner.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdSessions_ListsAllAndMarksActive(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
active, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { active.Close() })
|
||||||
|
other, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other.Close()
|
||||||
|
|
||||||
|
m := newTestModelForSessions(t, dir, active)
|
||||||
|
m.cmdSessions()
|
||||||
|
|
||||||
|
if m.modal == nil {
|
||||||
|
t.Fatal("expected a modal to be opened")
|
||||||
|
}
|
||||||
|
if len(m.modal.items) != 2 {
|
||||||
|
t.Fatalf("got %d items, want 2", len(m.modal.items))
|
||||||
|
}
|
||||||
|
idx := findItem(m.modal.items, active.ID)
|
||||||
|
if idx == -1 {
|
||||||
|
t.Fatal("active session missing from list")
|
||||||
|
}
|
||||||
|
if !m.modal.items[idx].protect || m.modal.items[idx].marker != "(aktiv)" {
|
||||||
|
t.Errorf("active session item = %+v, want protected and marked", m.modal.items[idx])
|
||||||
|
}
|
||||||
|
if m.modal.cursor != idx {
|
||||||
|
t.Errorf("cursor = %d, want %d (default to active session)", m.modal.cursor, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleModalKey_DeleteRequiresConfirmation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
active, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { active.Close() })
|
||||||
|
other, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other.Close()
|
||||||
|
|
||||||
|
m := newTestModelForSessions(t, dir, active)
|
||||||
|
m.cmdSessions()
|
||||||
|
m.modal.cursor = findItem(m.modal.items, other.ID)
|
||||||
|
|
||||||
|
m.handleModalKey(deleteKey())
|
||||||
|
if !m.modal.confirmDelete {
|
||||||
|
t.Fatal("expected Del to arm a confirmation, not delete immediately")
|
||||||
|
}
|
||||||
|
if _, err := session.List(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.handleModalKey(nKey())
|
||||||
|
if m.modal == nil || m.modal.confirmDelete {
|
||||||
|
t.Error("expected 'n' to cancel the confirmation without closing the modal")
|
||||||
|
}
|
||||||
|
infos, err := session.List(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(infos) != 2 {
|
||||||
|
t.Errorf("declining the confirmation must not delete anything, got %d sessions", len(infos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleModalKey_DeleteConfirmedRemovesSession(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
active, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { active.Close() })
|
||||||
|
other, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other.Close()
|
||||||
|
|
||||||
|
m := newTestModelForSessions(t, dir, active)
|
||||||
|
m.cmdSessions()
|
||||||
|
m.modal.cursor = findItem(m.modal.items, other.ID)
|
||||||
|
m.handleModalKey(deleteKey())
|
||||||
|
m.handleModalKey(yKey())
|
||||||
|
|
||||||
|
if m.modal != nil {
|
||||||
|
t.Error("expected modal to close after a confirmed delete")
|
||||||
|
}
|
||||||
|
infos, err := session.List(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(infos) != 1 || infos[0].ID != active.ID {
|
||||||
|
t.Errorf("expected only the active session left, got %+v", infos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleModalKey_CannotDeleteActiveSession(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
active, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { active.Close() })
|
||||||
|
|
||||||
|
m := newTestModelForSessions(t, dir, active)
|
||||||
|
m.cmdSessions()
|
||||||
|
// Nur eine Session vorhanden -> Cursor steht zwangsläufig auf der aktiven.
|
||||||
|
|
||||||
|
m.handleModalKey(deleteKey())
|
||||||
|
if m.modal.confirmDelete {
|
||||||
|
t.Error("expected the active session to be protected from even arming a delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
infos, err := session.List(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(infos) != 1 {
|
||||||
|
t.Error("active session must not be deletable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdInit_CreatesFilesAndReportsResult(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
|
||||||
|
m := newTestModelForSessions(t, dir, store)
|
||||||
|
m.runCommand("/init")
|
||||||
|
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
|
||||||
|
t.Fatalf("entries = %+v, want a single entryCommand", m.entries)
|
||||||
|
}
|
||||||
|
text := m.entries[0].text
|
||||||
|
if !strings.Contains(text, "angelegt") {
|
||||||
|
t.Errorf("expected the report to mention what was created, got: %q", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".nub", "config.toml")); err != nil {
|
||||||
|
t.Errorf(".nub/config.toml missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); err != nil {
|
||||||
|
t.Errorf("AGENTS.md missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdInit_SecondRunSkipsExistingFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
|
||||||
|
m := newTestModelForSessions(t, dir, store)
|
||||||
|
m.runCommand("/init")
|
||||||
|
m.entries = nil
|
||||||
|
m.runCommand("/init")
|
||||||
|
|
||||||
|
if len(m.entries) != 1 {
|
||||||
|
t.Fatalf("entries = %+v", m.entries)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.entries[0].text, "übersprungen") {
|
||||||
|
t.Errorf("expected the second run to report skipped files, got: %q", m.entries[0].text)
|
||||||
|
}
|
||||||
|
}
|
||||||
560
internal/ui/tui/model.go
Normal file
560
internal/ui/tui/model.go
Normal file
|
|
@ -0,0 +1,560 @@
|
||||||
|
// Package tui ist die Bubbletea-Oberfläche (M6). Reiner Consumer von
|
||||||
|
// tool.UIEvent — kein anderes Paket importiert dieses hier, das Entfernen
|
||||||
|
// von internal/ui/tui lässt den Rest kompilieren und -p weiterhin
|
||||||
|
// funktionieren.
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"nub/internal/agent"
|
||||||
|
"nub/internal/config"
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/mcpc"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/skill"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
ctx context.Context
|
||||||
|
|
||||||
|
loop *agent.Loop
|
||||||
|
store *session.Store
|
||||||
|
cfg config.Config
|
||||||
|
skills []skill.Skill
|
||||||
|
mcpServers []*mcpc.Server
|
||||||
|
|
||||||
|
// sessionGen wird bei jedem Session-Wechsel (startSession) erhöht.
|
||||||
|
// Events aus einem vorherigen out-Channel tragen die alte Generation
|
||||||
|
// und werden ignoriert, statt fälschlich die ganze TUI zu beenden.
|
||||||
|
sessionGen int
|
||||||
|
sessionCancel context.CancelFunc
|
||||||
|
|
||||||
|
in chan agent.Input
|
||||||
|
out <-chan tool.UIEvent
|
||||||
|
|
||||||
|
viewport viewport.Model
|
||||||
|
textarea textarea.Model
|
||||||
|
spinner spinner.Model
|
||||||
|
|
||||||
|
entries []entry
|
||||||
|
liveText strings.Builder
|
||||||
|
liveThink strings.Builder
|
||||||
|
hasLiveText bool
|
||||||
|
hasLiveThink bool
|
||||||
|
|
||||||
|
history []string
|
||||||
|
historyIdx int
|
||||||
|
|
||||||
|
followupQueue []string
|
||||||
|
|
||||||
|
modelName string
|
||||||
|
tokensUsed int
|
||||||
|
cacheReadTokens int // kumulierte cache_read_tokens, Nachweis für Prompt-Caching (M7)
|
||||||
|
turnActive bool
|
||||||
|
statusMsg string
|
||||||
|
|
||||||
|
modal *listModal
|
||||||
|
|
||||||
|
// permCh liefert "ask"-Rückfragen des Backend-Loops (E-11); permReq ist
|
||||||
|
// die gerade angezeigte, noch unbeantwortete Anfrage.
|
||||||
|
permCh chan permissionRequest
|
||||||
|
permReq *permissionRequest
|
||||||
|
|
||||||
|
// askCh liefert Freitext-Rückfragen des question-Tools (E-11-Erweiterung);
|
||||||
|
// askReq ist die gerade angezeigte, noch unbeantwortete Anfrage. Antworten
|
||||||
|
// laufen über das reguläre Eingabefeld, siehe submit() und question.go.
|
||||||
|
askCh chan askRequest
|
||||||
|
askReq *askRequest
|
||||||
|
|
||||||
|
width, height int
|
||||||
|
ready bool
|
||||||
|
|
||||||
|
quitting bool
|
||||||
|
fatalErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultInputPlaceholder ist der Standardtext des Eingabefelds. Wird
|
||||||
|
// während einer offenen question-Tool-Rückfrage kurzzeitig überschrieben
|
||||||
|
// (siehe askUser/answerQuestion in question.go).
|
||||||
|
const defaultInputPlaceholder = "Nachricht eingeben — Enter sendet, Alt+Enter queued als Follow-up, /help für Kommandos"
|
||||||
|
|
||||||
|
const askInputPlaceholder = "Antwort eingeben — Enter sendet"
|
||||||
|
|
||||||
|
type uiEventMsg struct {
|
||||||
|
gen int
|
||||||
|
ev tool.UIEvent
|
||||||
|
}
|
||||||
|
type eventsClosedMsg struct{ gen int }
|
||||||
|
type compactDoneMsg struct {
|
||||||
|
events []tool.UIEvent
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
type asyncCommandMsg struct{ text string }
|
||||||
|
|
||||||
|
func New(ctx context.Context, loop *agent.Loop, store *session.Store, cfg config.Config, skills []skill.Skill, mcpServers []*mcpc.Server) *Model {
|
||||||
|
ta := textarea.New()
|
||||||
|
ta.Placeholder = defaultInputPlaceholder
|
||||||
|
ta.ShowLineNumbers = false
|
||||||
|
ta.SetHeight(3)
|
||||||
|
|
||||||
|
vp := viewport.New(80, 20)
|
||||||
|
vp.MouseWheelEnabled = true
|
||||||
|
|
||||||
|
sp := spinner.New(spinner.WithSpinner(spinner.Dot))
|
||||||
|
|
||||||
|
m := &Model{
|
||||||
|
ctx: ctx,
|
||||||
|
loop: loop,
|
||||||
|
cfg: cfg,
|
||||||
|
skills: skills,
|
||||||
|
mcpServers: mcpServers,
|
||||||
|
viewport: vp,
|
||||||
|
textarea: ta,
|
||||||
|
spinner: sp,
|
||||||
|
modelName: loop.Model,
|
||||||
|
permCh: make(chan permissionRequest),
|
||||||
|
askCh: make(chan askRequest),
|
||||||
|
}
|
||||||
|
loop.RequestPermission = m.requestPermission
|
||||||
|
loop.AskUser = m.askUser
|
||||||
|
m.startSession(store)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
m.textarea.Focus(),
|
||||||
|
m.spinner.Tick,
|
||||||
|
listenEvents(m.sessionGen, m.out),
|
||||||
|
listenPermissionRequests(m.permCh),
|
||||||
|
listenAskRequests(m.askCh),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenEvents(gen int, out <-chan tool.UIEvent) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
ev, ok := <-out
|
||||||
|
if !ok {
|
||||||
|
return eventsClosedMsg{gen: gen}
|
||||||
|
}
|
||||||
|
return uiEventMsg{gen: gen, ev: ev}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSession bindet den Loop an eine (neue oder bestehende) Session:
|
||||||
|
// stoppt den vorherigen Backend-Loop sauber (falls einer läuft), öffnet
|
||||||
|
// frische in/out-Channels, und baut das Transkript aus der Historie der
|
||||||
|
// Session neu auf. Wird sowohl beim ersten Start als auch bei /sessions
|
||||||
|
// (Wechsel) verwendet.
|
||||||
|
func (m *Model) startSession(store *session.Store) tea.Cmd {
|
||||||
|
if m.sessionCancel != nil {
|
||||||
|
m.sessionCancel()
|
||||||
|
}
|
||||||
|
sessCtx, cancel := context.WithCancel(m.ctx)
|
||||||
|
m.sessionCancel = cancel
|
||||||
|
m.sessionGen++
|
||||||
|
|
||||||
|
m.store = store
|
||||||
|
m.loop.Session = store
|
||||||
|
m.in = make(chan agent.Input)
|
||||||
|
m.out = m.loop.Run(sessCtx, m.in)
|
||||||
|
|
||||||
|
nodes := store.PathNodes()
|
||||||
|
m.entries = replayEntries(nodes)
|
||||||
|
usage := sumUsage(nodes)
|
||||||
|
m.tokensUsed = usage.InputTokens + usage.OutputTokens
|
||||||
|
m.cacheReadTokens = usage.CacheReadTokens
|
||||||
|
m.turnActive = false
|
||||||
|
m.statusMsg = ""
|
||||||
|
m.followupQueue = nil
|
||||||
|
m.liveText.Reset()
|
||||||
|
m.liveThink.Reset()
|
||||||
|
m.hasLiveText = false
|
||||||
|
m.hasLiveThink = false
|
||||||
|
m.renderViewport()
|
||||||
|
|
||||||
|
return listenEvents(m.sessionGen, m.out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
m.ready = true
|
||||||
|
taHeight := 3
|
||||||
|
statusHeight := 1
|
||||||
|
gapHeight := 1 // Leerzeile zwischen Nachrichtenliste und Eingabefeld
|
||||||
|
vpHeight := msg.Height - taHeight - statusHeight - gapHeight - 1
|
||||||
|
if vpHeight < 3 {
|
||||||
|
vpHeight = 3
|
||||||
|
}
|
||||||
|
m.viewport.Width = msg.Width
|
||||||
|
m.viewport.Height = vpHeight
|
||||||
|
m.textarea.SetWidth(msg.Width)
|
||||||
|
m.renderViewport()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
return m.handleKey(msg)
|
||||||
|
|
||||||
|
case spinner.TickMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case tea.MouseMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.viewport, cmd = m.viewport.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case uiEventMsg:
|
||||||
|
if msg.gen != m.sessionGen {
|
||||||
|
return m, nil // Event aus einer inzwischen gewechselten Session
|
||||||
|
}
|
||||||
|
cmd := m.handleUIEvent(msg.ev)
|
||||||
|
return m, tea.Batch(cmd, listenEvents(msg.gen, m.out))
|
||||||
|
|
||||||
|
case eventsClosedMsg:
|
||||||
|
if msg.gen != m.sessionGen {
|
||||||
|
return m, nil // alter Loop wurde bewusst wegen Session-Wechsel beendet
|
||||||
|
}
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case compactDoneMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
m.pushError("compact: " + msg.err.Error())
|
||||||
|
}
|
||||||
|
for _, ev := range msg.events {
|
||||||
|
m.handleUIEvent(ev)
|
||||||
|
}
|
||||||
|
if len(msg.events) == 0 && msg.err == nil {
|
||||||
|
m.pushCommand("nothing to compact (session too short)")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case asyncCommandMsg:
|
||||||
|
m.pushCommand(msg.text)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case permissionRequestMsg:
|
||||||
|
m.permReq = &msg.req
|
||||||
|
m.entries = append(m.entries, entry{kind: entryPermission, text: renderPermissionRequest(msg.req.tool, msg.req.input)})
|
||||||
|
m.renderViewport()
|
||||||
|
return m, listenPermissionRequests(m.permCh)
|
||||||
|
|
||||||
|
case askRequestMsg:
|
||||||
|
m.askReq = &msg.req
|
||||||
|
m.textarea.Placeholder = askInputPlaceholder
|
||||||
|
m.entries = append(m.entries, entry{kind: entryQuestion, text: renderAskRequest(msg.req.question, msg.req.options)})
|
||||||
|
m.renderViewport()
|
||||||
|
return m, listenAskRequests(m.askCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.textarea, cmd = m.textarea.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.permReq != nil {
|
||||||
|
return m.handlePermissionKey(msg)
|
||||||
|
}
|
||||||
|
if m.modal != nil {
|
||||||
|
return m.handleModalKey(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyCtrlC:
|
||||||
|
// Nicht hier canceln: runCtx läuft auch in tea.WithContext ein, ein
|
||||||
|
// Cancel von innen lässt Program.Run() mit "program was killed"
|
||||||
|
// zurückkehren statt sauber über tea.Quit zu beenden. run.go
|
||||||
|
// canceled den Kontext, nachdem Program.Run() zurückgekehrt ist.
|
||||||
|
m.quitting = true
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case tea.KeyEnter:
|
||||||
|
if msg.Alt {
|
||||||
|
return m, m.submit(m.textarea.Value(), true)
|
||||||
|
}
|
||||||
|
return m, m.submit(m.textarea.Value(), false)
|
||||||
|
|
||||||
|
case tea.KeyUp:
|
||||||
|
if strings.TrimSpace(m.textarea.Value()) == "" && len(m.history) > 0 {
|
||||||
|
if m.historyIdx > 0 {
|
||||||
|
m.historyIdx--
|
||||||
|
}
|
||||||
|
m.textarea.SetValue(m.history[m.historyIdx])
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case tea.KeyDown:
|
||||||
|
if len(m.history) > 0 && m.historyIdx < len(m.history) {
|
||||||
|
m.historyIdx++
|
||||||
|
if m.historyIdx == len(m.history) {
|
||||||
|
m.textarea.SetValue("")
|
||||||
|
} else {
|
||||||
|
m.textarea.SetValue(m.history[m.historyIdx])
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case tea.KeyPgUp:
|
||||||
|
m.viewport.PageUp()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyPgDown:
|
||||||
|
m.viewport.PageDown()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyCtrlU:
|
||||||
|
m.viewport.HalfPageUp()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case tea.KeyCtrlD:
|
||||||
|
m.viewport.HalfPageDown()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.textarea, cmd = m.textarea.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) View() string {
|
||||||
|
if !m.ready {
|
||||||
|
return "initializing…"
|
||||||
|
}
|
||||||
|
if m.quitting {
|
||||||
|
return "bye\n"
|
||||||
|
}
|
||||||
|
// Eine offene Permission-Rückfrage überlagert den Bildschirm bewusst
|
||||||
|
// NICHT (mehr) — sie steht als eigener Block im Transkript (siehe
|
||||||
|
// permissionRequestMsg in Update), damit der bisherige Verlauf für die
|
||||||
|
// Entscheidung sichtbar bleibt. Nur ein /tree- o.ä. Modal ist noch ein
|
||||||
|
// echtes Vollbild-Overlay, weil es kein Teil des Gesprächsflusses ist.
|
||||||
|
if m.modal != nil {
|
||||||
|
return m.renderModal()
|
||||||
|
}
|
||||||
|
return m.viewport.View() + "\n\n" + m.textarea.View() + "\n" + m.renderStatusLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderStatusLine() string {
|
||||||
|
state := "idle"
|
||||||
|
if m.turnActive {
|
||||||
|
state = m.spinner.View() + " running"
|
||||||
|
if m.statusMsg != "" {
|
||||||
|
state += ": " + m.statusMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.permReq != nil {
|
||||||
|
state = stylePermissionPending.Render("⚠ permission required — y=allow, other=deny")
|
||||||
|
} else if m.askReq != nil {
|
||||||
|
state = styleAskPending.Render("❓ question pending — type your answer and press Enter")
|
||||||
|
}
|
||||||
|
queued := ""
|
||||||
|
if n := len(m.followupQueue); n > 0 {
|
||||||
|
queued = fmt.Sprintf(" | queued: %d", n)
|
||||||
|
}
|
||||||
|
cache := ""
|
||||||
|
if m.cacheReadTokens > 0 {
|
||||||
|
cache = fmt.Sprintf(" (cached: %d)", m.cacheReadTokens)
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf(" %s | model: %s | tokens: %d%s | session: %s%s ",
|
||||||
|
state, m.modelName, m.tokensUsed, cache, shortID(m.store.ID), queued)
|
||||||
|
return styleStatusBar.Width(m.width).Render(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortID(id string) string {
|
||||||
|
if len(id) > 12 {
|
||||||
|
return id[:12]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushSystem meldet ein Hintergrund-Ereignis, das nicht vom Nutzer
|
||||||
|
// angestoßen wurde (z.B. Auto-Compaction).
|
||||||
|
func (m *Model) pushSystem(text string) {
|
||||||
|
m.entries = append(m.entries, entry{kind: entrySystem, text: text})
|
||||||
|
m.renderViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushCommand zeigt die Ausgabe eines vom Nutzer getippten Slash-Kommandos.
|
||||||
|
func (m *Model) pushCommand(text string) {
|
||||||
|
m.entries = append(m.entries, entry{kind: entryCommand, text: text})
|
||||||
|
m.renderViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushError meldet einen echten Fehler (Loop-/Provider-Fehler oder ein
|
||||||
|
// fehlgeschlagenes Kommando).
|
||||||
|
func (m *Model) pushError(text string) {
|
||||||
|
m.entries = append(m.entries, entry{kind: entryError, text: text})
|
||||||
|
m.renderViewport()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderViewport baut den sichtbaren Transkript-Text aus allen committeten
|
||||||
|
// Entries plus dem gerade laufenden Streaming-Text neu zusammen.
|
||||||
|
func (m *Model) renderViewport() {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, e := range m.entries {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString(renderEntry(e))
|
||||||
|
}
|
||||||
|
if m.hasLiveThink {
|
||||||
|
if len(m.entries) > 0 {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString(styleThinking.Render(m.liveThink.String()))
|
||||||
|
}
|
||||||
|
if m.hasLiveText {
|
||||||
|
if len(m.entries) > 0 || m.hasLiveThink {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
b.WriteString(styleAssistant.Render(m.liveText.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
content := wrapForViewport(b.String(), m.viewport.Width)
|
||||||
|
|
||||||
|
atBottom := m.viewport.AtBottom()
|
||||||
|
m.viewport.SetContent(content)
|
||||||
|
if atBottom {
|
||||||
|
m.viewport.GotoBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// commitLive schließt den aktuellen Streaming-Text als fertigen Eintrag ab
|
||||||
|
// (mit Markdown-Rendering) und leert den Live-Puffer.
|
||||||
|
func (m *Model) commitLive() {
|
||||||
|
if m.hasLiveThink {
|
||||||
|
m.entries = append(m.entries, entry{kind: entryThinking, text: m.liveThink.String()})
|
||||||
|
m.liveThink.Reset()
|
||||||
|
m.hasLiveThink = false
|
||||||
|
}
|
||||||
|
if m.hasLiveText {
|
||||||
|
m.entries = append(m.entries, entry{kind: entryAssistant, text: m.liveText.String()})
|
||||||
|
m.liveText.Reset()
|
||||||
|
m.hasLiveText = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) submit(text string, alt bool) tea.Cmd {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.textarea.Reset()
|
||||||
|
|
||||||
|
if m.askReq != nil {
|
||||||
|
m.answerQuestion(text)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.history = append(m.history, text)
|
||||||
|
m.historyIdx = len(m.history)
|
||||||
|
|
||||||
|
if strings.HasPrefix(text, "/") {
|
||||||
|
return m.runCommand(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if alt && m.turnActive {
|
||||||
|
m.followupQueue = append(m.followupQueue, text)
|
||||||
|
m.entries = append(m.entries, entry{kind: entryQueued, text: text})
|
||||||
|
m.renderViewport()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.sendAsMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAsMessage schickt text als echte Chat-Nachricht ab: sichtbar im
|
||||||
|
// Transkript und an den Backend-Loop. Wird sowohl für normale Eingaben als
|
||||||
|
// auch für Skill-Slash-Commands genutzt (die eine synthetische Direktive
|
||||||
|
// statt der Roheingabe senden).
|
||||||
|
func (m *Model) sendAsMessage(text string) tea.Cmd {
|
||||||
|
m.entries = append(m.entries, entry{kind: entryUser, text: text})
|
||||||
|
m.turnActive = true
|
||||||
|
m.renderViewport()
|
||||||
|
return m.sendInput(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) sendInput(text string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
select {
|
||||||
|
case m.in <- agent.Input{Text: text}:
|
||||||
|
case <-m.ctx.Done():
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleUIEvent(ev tool.UIEvent) tea.Cmd {
|
||||||
|
switch e := ev.(type) {
|
||||||
|
case tool.TextDelta:
|
||||||
|
m.turnActive = true
|
||||||
|
m.hasLiveText = true
|
||||||
|
m.liveText.WriteString(e.Text)
|
||||||
|
m.renderViewport()
|
||||||
|
|
||||||
|
case tool.ThinkingDelta:
|
||||||
|
m.turnActive = true
|
||||||
|
m.hasLiveThink = true
|
||||||
|
m.liveThink.WriteString(e.Text)
|
||||||
|
m.renderViewport()
|
||||||
|
|
||||||
|
case tool.ToolCallStart:
|
||||||
|
m.commitLive()
|
||||||
|
m.statusMsg = e.Name
|
||||||
|
m.entries = append(m.entries, entry{kind: entryToolCall, text: renderToolCall(e.Name, e.Input)})
|
||||||
|
m.renderViewport()
|
||||||
|
|
||||||
|
case tool.ToolStream:
|
||||||
|
// Kompaktes Live-Log für bash & Co.; nicht Zeile für Zeile ins
|
||||||
|
// Transkript, das würde es bei langem Output unlesbar machen.
|
||||||
|
m.statusMsg = e.ID + "…"
|
||||||
|
|
||||||
|
case tool.ToolCallOutput:
|
||||||
|
m.statusMsg = ""
|
||||||
|
m.entries = append(m.entries, entry{kind: entryToolResult, text: renderToolResult(e.Name, e.Result)})
|
||||||
|
m.renderViewport()
|
||||||
|
|
||||||
|
case tool.TurnDone:
|
||||||
|
m.commitLive()
|
||||||
|
m.tokensUsed += e.Usage.InputTokens + e.Usage.OutputTokens
|
||||||
|
m.cacheReadTokens += e.Usage.CacheReadTokens
|
||||||
|
if e.Stop != llm.StopToolUse {
|
||||||
|
m.turnActive = false
|
||||||
|
m.statusMsg = ""
|
||||||
|
if len(m.followupQueue) > 0 {
|
||||||
|
next := m.followupQueue[0]
|
||||||
|
m.followupQueue = m.followupQueue[1:]
|
||||||
|
return m.sendInput(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case tool.ErrorEvent:
|
||||||
|
m.commitLive()
|
||||||
|
m.turnActive = false
|
||||||
|
m.statusMsg = ""
|
||||||
|
m.entries = append(m.entries, entry{kind: entryError, text: e.Err.Error()})
|
||||||
|
m.renderViewport()
|
||||||
|
|
||||||
|
case tool.CompactionEvent:
|
||||||
|
m.entries = append(m.entries, entry{
|
||||||
|
kind: entrySystem,
|
||||||
|
text: fmt.Sprintf("compacted %d nodes into a %d-token summary", e.ReplacedNodes, e.SummaryTokens),
|
||||||
|
})
|
||||||
|
m.renderViewport()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
276
internal/ui/tui/model_test.go
Normal file
276
internal/ui/tui/model_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
|
||||||
|
"nub/internal/agent"
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/skill"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestModel(t *testing.T) *Model {
|
||||||
|
t.Helper()
|
||||||
|
return &Model{
|
||||||
|
ctx: context.Background(),
|
||||||
|
in: make(chan agent.Input, 1),
|
||||||
|
textarea: textarea.New(),
|
||||||
|
viewport: viewport.New(80, 20),
|
||||||
|
spinner: spinner.New(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmit_SlashCommandDoesNotTouchInChannel(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.entries = []entry{{kind: entryUser, text: "old"}}
|
||||||
|
|
||||||
|
cmd := m.submit("/clear", false)
|
||||||
|
if cmd != nil {
|
||||||
|
t.Error("expected /clear to return a nil cmd (no async work)")
|
||||||
|
}
|
||||||
|
if len(m.entries) != 0 {
|
||||||
|
t.Errorf("expected entries cleared, got %d", len(m.entries))
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case v := <-m.in:
|
||||||
|
t.Errorf("slash command must not send on in channel, got %+v", v)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmit_AltEnterDuringActiveTurn_Queues(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.turnActive = true
|
||||||
|
|
||||||
|
cmd := m.submit("do this next", true)
|
||||||
|
if cmd != nil {
|
||||||
|
t.Error("expected queuing to return a nil cmd (nothing sent yet)")
|
||||||
|
}
|
||||||
|
if len(m.followupQueue) != 1 || m.followupQueue[0] != "do this next" {
|
||||||
|
t.Errorf("followupQueue = %+v", m.followupQueue)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case v := <-m.in:
|
||||||
|
t.Errorf("queued follow-up must not be sent immediately, got %+v", v)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryQueued || m.entries[0].text != "do this next" {
|
||||||
|
t.Errorf("entries = %+v", m.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmit_PlainMessage_SendsOnInChannel(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
|
||||||
|
cmd := m.submit("hello", false)
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected a cmd that sends the message")
|
||||||
|
}
|
||||||
|
cmd() // tea.Cmd ist nur func() tea.Msg
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-m.in:
|
||||||
|
if got.Text != "hello" {
|
||||||
|
t.Errorf("sent text = %q, want hello", got.Text)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for input to be sent")
|
||||||
|
}
|
||||||
|
if !m.turnActive {
|
||||||
|
t.Error("expected turnActive=true after sending a message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUIEvent_TurnDoneFlushesFollowupQueue(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.turnActive = true
|
||||||
|
m.followupQueue = []string{"queued message"}
|
||||||
|
|
||||||
|
cmd := m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd})
|
||||||
|
if m.turnActive {
|
||||||
|
t.Error("expected turnActive=false after a terminal TurnDone")
|
||||||
|
}
|
||||||
|
if len(m.followupQueue) != 0 {
|
||||||
|
t.Errorf("expected queue drained, got %+v", m.followupQueue)
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected a cmd flushing the queued follow-up")
|
||||||
|
}
|
||||||
|
cmd()
|
||||||
|
select {
|
||||||
|
case got := <-m.in:
|
||||||
|
if got.Text != "queued message" {
|
||||||
|
t.Errorf("flushed text = %q", got.Text)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for queued follow-up to be sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUIEvent_ToolUseTurnDoneDoesNotFlushQueue(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.turnActive = true
|
||||||
|
m.followupQueue = []string{"queued message"}
|
||||||
|
|
||||||
|
m.handleUIEvent(tool.TurnDone{Stop: llm.StopToolUse})
|
||||||
|
if !m.turnActive {
|
||||||
|
t.Error("turn should still be active while tool_use continues")
|
||||||
|
}
|
||||||
|
if len(m.followupQueue) != 1 {
|
||||||
|
t.Errorf("queue should stay intact until the turn actually ends, got %+v", m.followupQueue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCommand_ExitReturnsQuitWithoutCancelingContext(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
|
||||||
|
cmd := m.runCommand("/exit")
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected /exit to return tea.Quit")
|
||||||
|
}
|
||||||
|
if !m.quitting {
|
||||||
|
t.Error("expected quitting=true")
|
||||||
|
}
|
||||||
|
// /exit darf den Kontext NICHT selbst canceln: der ist auch an
|
||||||
|
// tea.WithContext gebunden, ein Cancel von innen lässt Program.Run()
|
||||||
|
// mit einem "program was killed"-Fehler zurückkehren statt sauber über
|
||||||
|
// tea.Quit zu beenden (siehe run.go).
|
||||||
|
select {
|
||||||
|
case <-m.ctx.Done():
|
||||||
|
t.Error("/exit must not cancel the context itself, run.go does that after Program.Run() returns")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCommand_UnknownCommandIsAnError(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.runCommand("/bogus")
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryError {
|
||||||
|
t.Errorf("entries = %+v, want a single entryError", m.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCommand_HelpListsOneCommandPerLineWithDescription(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.runCommand("/help")
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
|
||||||
|
t.Fatalf("entries = %+v, want a single entryCommand", m.entries)
|
||||||
|
}
|
||||||
|
text := m.entries[0].text
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
if len(lines) != len(helpCommands)+1 { // +1 für die "Kommandos:"-Kopfzeile
|
||||||
|
t.Fatalf("got %d lines, want %d (one per command + header):\n%s", len(lines), len(helpCommands)+1, text)
|
||||||
|
}
|
||||||
|
for _, c := range helpCommands {
|
||||||
|
if !strings.Contains(text, c.cmd) || !strings.Contains(text, c.desc) {
|
||||||
|
t.Errorf("help text missing %q / %q:\n%s", c.cmd, c.desc, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCommand_SkillNameInvokesSkillAsDirective(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.skills = []skill.Skill{{Name: "refactoring", Description: "d"}}
|
||||||
|
|
||||||
|
cmd := m.runCommand("/refactoring focus on error handling")
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected a cmd sending the skill directive")
|
||||||
|
}
|
||||||
|
cmd()
|
||||||
|
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryUser {
|
||||||
|
t.Fatalf("entries = %+v, want a single entryUser", m.entries)
|
||||||
|
}
|
||||||
|
got := m.entries[0].text
|
||||||
|
if !strings.Contains(got, `"refactoring"`) || !strings.Contains(got, "read_skill") {
|
||||||
|
t.Errorf("directive should name the skill and read_skill, got: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "focus on error handling") {
|
||||||
|
t.Errorf("directive should include the trailing args, got: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case sent := <-m.in:
|
||||||
|
if sent.Text != got {
|
||||||
|
t.Errorf("sent text = %q, want it to match the displayed directive %q", sent.Text, got)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for the skill directive to be sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCommand_BuiltinCommandWinsOverSameNamedSkill(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.skills = []skill.Skill{{Name: "clear", Description: "a skill that happens to be named like a builtin"}}
|
||||||
|
|
||||||
|
m.entries = []entry{{kind: entryUser, text: "old"}}
|
||||||
|
m.runCommand("/clear")
|
||||||
|
|
||||||
|
if len(m.entries) != 0 {
|
||||||
|
t.Errorf("expected the builtin /clear to win and wipe entries, got %+v", m.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpText_ListsSkillsWhenPresent(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.skills = []skill.Skill{{Name: "refactoring", Description: "Vorgehen für Refactorings"}}
|
||||||
|
|
||||||
|
text := m.helpText()
|
||||||
|
if !strings.Contains(text, "/refactoring") || !strings.Contains(text, "Vorgehen für Refactorings") {
|
||||||
|
t.Errorf("help text should list the skill as a command, got: %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunCommand_ClearIsCommandNotError(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.runCommand("/help")
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
|
||||||
|
t.Errorf("entries = %+v, want a single entryCommand", m.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUIEvent_TurnDoneAccumulatesCacheReadTokens(t *testing.T) {
|
||||||
|
m := newTestModelWithStore(t) // renderStatusLine liest m.store.ID
|
||||||
|
|
||||||
|
m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd, Usage: llm.Usage{InputTokens: 1200, OutputTokens: 5, CacheReadTokens: 896}})
|
||||||
|
if m.cacheReadTokens != 896 {
|
||||||
|
t.Errorf("cacheReadTokens = %d, want 896", m.cacheReadTokens)
|
||||||
|
}
|
||||||
|
if m.tokensUsed != 1205 {
|
||||||
|
t.Errorf("tokensUsed = %d, want 1205", m.tokensUsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.handleUIEvent(tool.TurnDone{Stop: llm.StopEnd, Usage: llm.Usage{InputTokens: 1300, OutputTokens: 8, CacheReadTokens: 1100}})
|
||||||
|
if m.cacheReadTokens != 1996 {
|
||||||
|
t.Errorf("cacheReadTokens after second turn = %d, want 1996 (cumulative)", m.cacheReadTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(m.renderStatusLine(), "cached: 1996") {
|
||||||
|
t.Errorf("status line should surface cumulative cache_read_tokens, got: %q", m.renderStatusLine())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleUIEvent_StreamingTextAccumulatesAndCommits(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
|
||||||
|
m.handleUIEvent(tool.TextDelta{Text: "hello "})
|
||||||
|
m.handleUIEvent(tool.TextDelta{Text: "world"})
|
||||||
|
if !m.hasLiveText || m.liveText.String() != "hello world" {
|
||||||
|
t.Errorf("live text = %q, hasLiveText=%v", m.liveText.String(), m.hasLiveText)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.commitLive()
|
||||||
|
if m.hasLiveText {
|
||||||
|
t.Error("expected live text cleared after commit")
|
||||||
|
}
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryAssistant || m.entries[0].text != "hello world" {
|
||||||
|
t.Errorf("entries = %+v", m.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
82
internal/ui/tui/permission.go
Normal file
82
internal/ui/tui/permission.go
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// permissionRequest kommt vom Backend-Loop (E-11: "ask"-Modus) über
|
||||||
|
// Model.permCh. resp erwartet genau eine Antwort; requestPermission blockiert
|
||||||
|
// darauf im Loop-Goroutine, bis die TUI eine Taste verarbeitet hat.
|
||||||
|
type permissionRequest struct {
|
||||||
|
tool string
|
||||||
|
input json.RawMessage
|
||||||
|
resp chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type permissionRequestMsg struct{ req permissionRequest }
|
||||||
|
|
||||||
|
func listenPermissionRequests(ch <-chan permissionRequest) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
req, ok := <-ch
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return permissionRequestMsg{req: req}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestPermission ist der Hook, den loop.RequestPermission aufruft (siehe
|
||||||
|
// New()). Läuft auf dem Goroutine des Backend-Loops, nicht dem der TUI —
|
||||||
|
// deshalb reine Channel-Kommunikation statt direktem Feldzugriff auf m.
|
||||||
|
func (m *Model) requestPermission(ctx context.Context, toolName string, input json.RawMessage) bool {
|
||||||
|
resp := make(chan bool, 1)
|
||||||
|
req := permissionRequest{tool: toolName, input: input, resp: resp}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case m.permCh <- req:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case decision := <-resp:
|
||||||
|
return decision
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
req := m.permReq
|
||||||
|
m.permReq = nil
|
||||||
|
|
||||||
|
allow := msg.String() == "y" || msg.String() == "Y"
|
||||||
|
req.resp <- allow
|
||||||
|
|
||||||
|
verb := "denied"
|
||||||
|
if allow {
|
||||||
|
verb = "allowed"
|
||||||
|
}
|
||||||
|
m.pushCommand(fmt.Sprintf("permission %s: %s", verb, req.tool))
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPermissionRequest baut den Transkript-Block für eine offene
|
||||||
|
// Rückfrage — bewusst kein Vollbild-Overlay (das hat vorher den Kontext
|
||||||
|
// verdeckt, den man für die Entscheidung braucht), sondern ein eigener,
|
||||||
|
// umrandeter Eintrag im normalen Nachrichtenfluss. Viewport, Eingabefeld und
|
||||||
|
// Statuszeile bleiben sichtbar; die Statuszeile zeigt zusätzlich einen
|
||||||
|
// Hinweis (siehe renderStatusLine).
|
||||||
|
func renderPermissionRequest(toolName string, input json.RawMessage) string {
|
||||||
|
in := strings.TrimSpace(prettyJSON(input))
|
||||||
|
if len(in) > 300 {
|
||||||
|
in = in[:300] + "…"
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("⚠ Rückfrage: %s\n%s\n\n[y] erlauben [n / Esc / beliebige andere Taste] ablehnen", toolName, in)
|
||||||
|
return stylePermissionBox.Render(body)
|
||||||
|
}
|
||||||
171
internal/ui/tui/permission_test.go
Normal file
171
internal/ui/tui/permission_test.go
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestPermission_BlocksUntilAnswered(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.permCh = make(chan permissionRequest)
|
||||||
|
|
||||||
|
resultCh := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
resultCh <- m.requestPermission(context.Background(), "edit", json.RawMessage(`{"path":"main.go"}`))
|
||||||
|
}()
|
||||||
|
|
||||||
|
var req permissionRequest
|
||||||
|
select {
|
||||||
|
case req = <-m.permCh:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for the request on permCh")
|
||||||
|
}
|
||||||
|
if req.tool != "edit" {
|
||||||
|
t.Errorf("req.tool = %q, want edit", req.tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-resultCh:
|
||||||
|
t.Fatal("requestPermission returned before the response was sent")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
req.resp <- true
|
||||||
|
select {
|
||||||
|
case got := <-resultCh:
|
||||||
|
if !got {
|
||||||
|
t.Error("expected requestPermission to return true after resp<-true")
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for requestPermission to return")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestPermission_ContextCancelDeniesWithoutHanging(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.permCh = make(chan permissionRequest) // niemand liest daraus
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
resultCh := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
resultCh <- m.requestPermission(ctx, "bash", json.RawMessage(`{"command":"ls"}`))
|
||||||
|
}()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case got := <-resultCh:
|
||||||
|
if got {
|
||||||
|
t.Error("expected a cancelled context to deny, got true")
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("requestPermission did not return after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePermissionKey_YesAllows(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
resp := make(chan bool, 1)
|
||||||
|
m.permReq = &permissionRequest{tool: "edit", input: json.RawMessage(`{}`), resp: resp}
|
||||||
|
|
||||||
|
m.handlePermissionKey(yKey())
|
||||||
|
|
||||||
|
if m.permReq != nil {
|
||||||
|
t.Error("expected permReq to be cleared")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-resp:
|
||||||
|
if !got {
|
||||||
|
t.Error("expected 'y' to allow")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("expected a response to be sent on resp")
|
||||||
|
}
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryCommand {
|
||||||
|
t.Errorf("expected a recorded command entry, got %+v", m.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePermissionKey_AnyOtherKeyDenies(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
resp := make(chan bool, 1)
|
||||||
|
m.permReq = &permissionRequest{tool: "bash", input: json.RawMessage(`{}`), resp: resp}
|
||||||
|
|
||||||
|
m.handlePermissionKey(escKey())
|
||||||
|
|
||||||
|
if m.permReq != nil {
|
||||||
|
t.Error("expected permReq to be cleared")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-resp:
|
||||||
|
if got {
|
||||||
|
t.Error("expected esc to deny")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("expected a response to be sent on resp")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdate_PermissionRequestMsgOpensPromptAndReListens(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.permCh = make(chan permissionRequest, 1)
|
||||||
|
|
||||||
|
req := permissionRequest{tool: "write", input: json.RawMessage(`{"path":"x"}`), resp: make(chan bool, 1)}
|
||||||
|
_, cmd := m.Update(permissionRequestMsg{req: req})
|
||||||
|
|
||||||
|
if m.permReq == nil || m.permReq.tool != "write" {
|
||||||
|
t.Fatalf("expected permReq to be set to the incoming request, got %+v", m.permReq)
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected Update to re-arm listenPermissionRequests")
|
||||||
|
}
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryPermission {
|
||||||
|
t.Fatalf("expected the request to appear inline in the transcript, got %+v", m.entries)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.entries[0].text, "write") {
|
||||||
|
t.Errorf("permission entry should name the tool, got: %q", m.entries[0].text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestView_PermissionRequestDoesNotTakeOverTheScreen ist der eigentliche
|
||||||
|
// Regressionstest für das Overlay-Problem: eine offene Rückfrage darf die
|
||||||
|
// Nachrichtenliste/Eingabe/Statuszeile nicht verdecken, sonst sieht man
|
||||||
|
// nicht mehr, was vorher im Transkript passiert ist.
|
||||||
|
func TestView_PermissionRequestDoesNotTakeOverTheScreen(t *testing.T) {
|
||||||
|
m := newTestModelWithStore(t) // renderStatusLine liest m.store.ID
|
||||||
|
m.ready = true
|
||||||
|
m.width, m.height = 80, 24
|
||||||
|
m.viewport.Width, m.viewport.Height = 80, 20
|
||||||
|
m.entries = []entry{{kind: entryUser, text: "hello from before"}}
|
||||||
|
m.renderViewport()
|
||||||
|
m.permReq = &permissionRequest{tool: "edit", input: json.RawMessage(`{}`), resp: make(chan bool, 1)}
|
||||||
|
|
||||||
|
view := m.View()
|
||||||
|
if !strings.Contains(view, m.textarea.View()) {
|
||||||
|
t.Error("expected the input box to remain visible while a permission request is pending")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stripANSI(view), "hello from before") {
|
||||||
|
t.Error("expected prior transcript content to remain visible, not be replaced by a full-screen overlay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripANSI(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
inEscape := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\x1b' {
|
||||||
|
inEscape = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inEscape {
|
||||||
|
if r == 'm' {
|
||||||
|
inEscape = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
77
internal/ui/tui/question.go
Normal file
77
internal/ui/tui/question.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
)
|
||||||
|
|
||||||
|
// askRequest kommt vom question-Tool über Model.askCh. Anders als eine
|
||||||
|
// Permission-Rückfrage (bool, Tastendruck) ist die Antwort hier Freitext —
|
||||||
|
// die Antwort läuft deshalb über das normale Eingabefeld: submit() prüft
|
||||||
|
// zuerst, ob eine Frage offen ist, und behandelt den getippten Text als
|
||||||
|
// Antwort statt als neue Chat-Nachricht/Slash-Command (siehe submit in
|
||||||
|
// model.go).
|
||||||
|
type askRequest struct {
|
||||||
|
question string
|
||||||
|
options []string
|
||||||
|
resp chan string
|
||||||
|
}
|
||||||
|
|
||||||
|
type askRequestMsg struct{ req askRequest }
|
||||||
|
|
||||||
|
func listenAskRequests(ch <-chan askRequest) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
req, ok := <-ch
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return askRequestMsg{req: req}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// askUser ist der Hook, den loop.AskUser aufruft (siehe New()). Läuft auf
|
||||||
|
// dem Goroutine des Backend-Loops, nicht dem der TUI.
|
||||||
|
func (m *Model) askUser(ctx context.Context, question string, options []string) (string, error) {
|
||||||
|
resp := make(chan string, 1)
|
||||||
|
req := askRequest{question: question, options: options, resp: resp}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case m.askCh <- req:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case answer := <-resp:
|
||||||
|
return answer, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerQuestion schließt die offene Frage ab und schickt answer an den
|
||||||
|
// wartenden Loop-Goroutine zurück. resp ist mit Cap 1 gepuffert, der Send
|
||||||
|
// blockiert also nie. Die Bestätigung im Transkript kommt bewusst nicht von
|
||||||
|
// hier, sondern aus dem regulären ToolCallOutput-Event (renderToolResult,
|
||||||
|
// builtin.QuestionResult) — sonst erschiene die Antwort doppelt.
|
||||||
|
func (m *Model) answerQuestion(answer string) {
|
||||||
|
req := m.askReq
|
||||||
|
m.askReq = nil
|
||||||
|
m.textarea.Placeholder = defaultInputPlaceholder
|
||||||
|
req.resp <- answer
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAskRequest baut den Transkript-Block für eine offene Frage — wie
|
||||||
|
// bei Permissions kein Vollbild-Overlay, sondern ein Eintrag im normalen
|
||||||
|
// Nachrichtenfluss (siehe renderPermissionRequest).
|
||||||
|
func renderAskRequest(question string, options []string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("❓ Rückfrage: " + question)
|
||||||
|
if len(options) > 0 {
|
||||||
|
b.WriteString("\nVorschläge: " + strings.Join(options, " · "))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\nAntwort eingeben und Enter drücken.")
|
||||||
|
return styleAskBox.Render(b.String())
|
||||||
|
}
|
||||||
146
internal/ui/tui/question_test.go
Normal file
146
internal/ui/tui/question_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAskUser_BlocksUntilAnswered(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.askCh = make(chan askRequest)
|
||||||
|
|
||||||
|
resultCh := make(chan string, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
answer, err := m.askUser(context.Background(), "which format?", []string{"json", "yaml"})
|
||||||
|
resultCh <- answer
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
var req askRequest
|
||||||
|
select {
|
||||||
|
case req = <-m.askCh:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for the request on askCh")
|
||||||
|
}
|
||||||
|
if req.question != "which format?" {
|
||||||
|
t.Errorf("req.question = %q, want %q", req.question, "which format?")
|
||||||
|
}
|
||||||
|
if len(req.options) != 2 || req.options[0] != "json" || req.options[1] != "yaml" {
|
||||||
|
t.Errorf("req.options = %v", req.options)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-resultCh:
|
||||||
|
t.Fatal("askUser returned before the response was sent")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
req.resp <- "json please"
|
||||||
|
select {
|
||||||
|
case got := <-resultCh:
|
||||||
|
if got != "json please" {
|
||||||
|
t.Errorf("askUser returned %q, want %q", got, "json please")
|
||||||
|
}
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for askUser to return")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAskUser_ContextCancelReturnsErrorWithoutHanging(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.askCh = make(chan askRequest) // niemand liest daraus
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := m.askUser(ctx, "irrelevant", nil)
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected a cancelled context to return an error")
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("askUser did not return after context cancellation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdate_AskRequestMsgOpensPromptAndReListens(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.askCh = make(chan askRequest, 1)
|
||||||
|
|
||||||
|
req := askRequest{question: "which format?", options: []string{"json", "yaml"}, resp: make(chan string, 1)}
|
||||||
|
_, cmd := m.Update(askRequestMsg{req: req})
|
||||||
|
|
||||||
|
if m.askReq == nil || m.askReq.question != "which format?" {
|
||||||
|
t.Fatalf("expected askReq to be set to the incoming request, got %+v", m.askReq)
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected Update to re-arm listenAskRequests")
|
||||||
|
}
|
||||||
|
if len(m.entries) != 1 || m.entries[0].kind != entryQuestion {
|
||||||
|
t.Fatalf("expected the request to appear inline in the transcript, got %+v", m.entries)
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.entries[0].text, "which format?") {
|
||||||
|
t.Errorf("question entry should contain the question, got: %q", m.entries[0].text)
|
||||||
|
}
|
||||||
|
if m.textarea.Placeholder != askInputPlaceholder {
|
||||||
|
t.Errorf("expected the placeholder to switch to askInputPlaceholder, got %q", m.textarea.Placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmit_WithOpenAskRequestAnswersInsteadOfSendingAMessage(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
resp := make(chan string, 1)
|
||||||
|
m.askReq = &askRequest{question: "which format?", resp: resp}
|
||||||
|
|
||||||
|
cmd := m.submit("json please", false)
|
||||||
|
|
||||||
|
if cmd != nil {
|
||||||
|
t.Error("expected submit to not return a chat/command cmd while answering a question")
|
||||||
|
}
|
||||||
|
if m.askReq != nil {
|
||||||
|
t.Error("expected askReq to be cleared")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-resp:
|
||||||
|
if got != "json please" {
|
||||||
|
t.Errorf("resp received %q, want %q", got, "json please")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Fatal("expected the answer to be sent on resp")
|
||||||
|
}
|
||||||
|
if m.textarea.Placeholder != defaultInputPlaceholder {
|
||||||
|
t.Errorf("expected the placeholder to reset to defaultInputPlaceholder, got %q", m.textarea.Placeholder)
|
||||||
|
}
|
||||||
|
if len(m.history) != 0 {
|
||||||
|
t.Error("expected the answer to not be recorded as chat history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestView_AskRequestDoesNotTakeOverTheScreen(t *testing.T) {
|
||||||
|
m := newTestModelWithStore(t) // renderStatusLine liest m.store.ID
|
||||||
|
m.ready = true
|
||||||
|
m.width, m.height = 80, 24
|
||||||
|
m.viewport.Width, m.viewport.Height = 80, 20
|
||||||
|
m.entries = []entry{{kind: entryUser, text: "hello from before"}}
|
||||||
|
m.renderViewport()
|
||||||
|
m.askReq = &askRequest{question: "which format?", resp: make(chan string, 1)}
|
||||||
|
|
||||||
|
view := m.View()
|
||||||
|
if !strings.Contains(view, m.textarea.View()) {
|
||||||
|
t.Error("expected the input box to remain visible while a question is pending")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stripANSI(view), "hello from before") {
|
||||||
|
t.Error("expected prior transcript content to remain visible, not be replaced by a full-screen overlay")
|
||||||
|
}
|
||||||
|
}
|
||||||
200
internal/ui/tui/render.go
Normal file
200
internal/ui/tui/render.go
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/glamour"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"nub/internal/mcpc"
|
||||||
|
"nub/internal/tool"
|
||||||
|
"nub/internal/tool/builtin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wrapForViewport bricht Text auf die gegebene Breite um. bubbles/viewport
|
||||||
|
// bricht Inhalt nicht selbst um — es zeigt ihn nur an und lässt lange
|
||||||
|
// Zeilen horizontal überlaufen. lipgloss übernimmt den (ANSI-bewussten)
|
||||||
|
// Zeilenumbruch. width<=0 (z.B. vor dem ersten WindowSizeMsg) lässt den
|
||||||
|
// Text unverändert.
|
||||||
|
func wrapForViewport(content string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Width(width).Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
type entryKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
entryUser entryKind = iota
|
||||||
|
entryQueued
|
||||||
|
entryAssistant
|
||||||
|
entryThinking
|
||||||
|
entryToolCall
|
||||||
|
entryToolResult
|
||||||
|
entrySystem // Hintergrund-Ereignisse, nicht vom Nutzer angestoßen (z.B. Auto-Compaction)
|
||||||
|
entryCommand // Ausgabe eines Slash-Kommandos, das der Nutzer selbst getippt hat
|
||||||
|
entryError // echte Fehler (Loop-/Provider-Fehler, fehlgeschlagene Kommandos)
|
||||||
|
entryPermission // offene "ask"-Rückfrage, siehe permission.go
|
||||||
|
entryQuestion // offene Rückfrage des question-Tools, siehe question.go
|
||||||
|
)
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
kind entryKind
|
||||||
|
text string // bereits gestylter, fertiger Text
|
||||||
|
}
|
||||||
|
|
||||||
|
var markdownRenderer, _ = glamour.NewTermRenderer(
|
||||||
|
glamour.WithAutoStyle(),
|
||||||
|
glamour.WithWordWrap(0), // kein Wrap hier — Model.renderViewport bricht den gesamten Inhalt einmalig auf die aktuelle Breite um
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderMarkdown(s string) string {
|
||||||
|
if markdownRenderer == nil || strings.TrimSpace(s) == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
out, err := markdownRenderer.Render(s)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimRight(out, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderEntry(e entry) string {
|
||||||
|
switch e.kind {
|
||||||
|
case entryUser:
|
||||||
|
return styleUser.Render("› "+firstLine(e.text)) + tailLines(e.text)
|
||||||
|
case entryQueued:
|
||||||
|
return styleQueued.Render("⏳ "+firstLine(e.text)) + tailLines(e.text)
|
||||||
|
case entryAssistant:
|
||||||
|
return styleAssistant.Render(renderMarkdown(e.text))
|
||||||
|
case entryThinking:
|
||||||
|
return styleThinking.Render(e.text)
|
||||||
|
case entryToolCall:
|
||||||
|
return styleToolCall.Render(e.text)
|
||||||
|
case entryToolResult:
|
||||||
|
return e.text // bereits gestylt (ok/error-Farbe je nach Ergebnis)
|
||||||
|
case entrySystem:
|
||||||
|
return styleSystem.Render("· " + e.text)
|
||||||
|
case entryCommand:
|
||||||
|
return styleCommand.Render(e.text)
|
||||||
|
case entryError:
|
||||||
|
return styleError.Render("✗ " + e.text)
|
||||||
|
case entryPermission:
|
||||||
|
return e.text // bereits als umrandeter Block gerendert (renderPermissionRequest)
|
||||||
|
case entryQuestion:
|
||||||
|
return e.text // bereits als umrandeter Block gerendert (renderAskRequest)
|
||||||
|
default:
|
||||||
|
return e.text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tailLines(s string) string {
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return "\n" + s[i+1:]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderToolCall zeigt Name + kompakte Eingabe.
|
||||||
|
func renderToolCall(name string, input []byte) string {
|
||||||
|
in := strings.TrimSpace(prettyJSON(input))
|
||||||
|
if len(in) > 300 {
|
||||||
|
in = in[:300] + "…"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("→ %s %s", name, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderToolResult wählt je nach ForUI-Typ eine spezifische Darstellung
|
||||||
|
// (Diff-Overlay für write/edit, kompakte Zusammenfassung sonst).
|
||||||
|
func renderToolResult(name string, res tool.Result) string {
|
||||||
|
status := styleToolOK.Render("✓")
|
||||||
|
if res.IsError {
|
||||||
|
status = styleToolErr.Render("✗")
|
||||||
|
}
|
||||||
|
header := fmt.Sprintf("%s %s", status, name)
|
||||||
|
|
||||||
|
switch ui := res.ForUI.(type) {
|
||||||
|
case builtin.DiffResult:
|
||||||
|
return header + "\n" + renderDiff(ui)
|
||||||
|
case builtin.BashResult:
|
||||||
|
out := res.ForModel
|
||||||
|
if len(out) > 2000 {
|
||||||
|
out = out[:2000] + "\n… [truncated in TUI, see ForModel für vollen Output]"
|
||||||
|
}
|
||||||
|
return header + fmt.Sprintf(" (exit %d)\n%s", ui.ExitCode, out)
|
||||||
|
case builtin.FileResult:
|
||||||
|
return header + fmt.Sprintf(" %s (%d Zeilen)", ui.Path, ui.Lines)
|
||||||
|
case builtin.GlobResult:
|
||||||
|
return header + fmt.Sprintf(" %d Treffer", len(ui.Paths))
|
||||||
|
case builtin.GrepResult:
|
||||||
|
return header + fmt.Sprintf(" %d Treffer", ui.Count)
|
||||||
|
case builtin.TodoResult:
|
||||||
|
return header + "\n" + renderTodos(ui)
|
||||||
|
case builtin.SkillResult:
|
||||||
|
return header + fmt.Sprintf(" %s geladen (%s)", ui.Name, ui.Dir)
|
||||||
|
case mcpc.Result:
|
||||||
|
return header + " " + res.ForModel
|
||||||
|
case builtin.QuestionResult:
|
||||||
|
return fmt.Sprintf("%s\nFrage: %s\nAntwort: %s", header, ui.Question, ui.Answer)
|
||||||
|
default:
|
||||||
|
out := res.ForModel
|
||||||
|
if len(out) > 1000 {
|
||||||
|
out = out[:1000] + "…"
|
||||||
|
}
|
||||||
|
return header + "\n" + out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderDiff(d builtin.DiffResult) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "%s\n", d.Path)
|
||||||
|
for _, line := range d.Lines {
|
||||||
|
switch line.Op {
|
||||||
|
case builtin.DiffAdd:
|
||||||
|
b.WriteString(styleDiffAdd.Render("+ " + line.Text))
|
||||||
|
case builtin.DiffRemove:
|
||||||
|
b.WriteString(styleDiffDel.Render("- " + line.Text))
|
||||||
|
default:
|
||||||
|
b.WriteString(" " + line.Text)
|
||||||
|
}
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTodos(t builtin.TodoResult) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, it := range t.Items {
|
||||||
|
mark := " "
|
||||||
|
switch it.Status {
|
||||||
|
case "in_progress":
|
||||||
|
mark = "~"
|
||||||
|
case "completed":
|
||||||
|
mark = "x"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "[%s] %s\n", mark, it.Content)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func prettyJSON(raw json.RawMessage) string {
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
out, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
74
internal/ui/tui/render_test.go
Normal file
74
internal/ui/tui/render_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"nub/internal/tool"
|
||||||
|
"nub/internal/tool/builtin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderToolResult_DiffShowsAddAndRemove(t *testing.T) {
|
||||||
|
res := tool.Result{
|
||||||
|
ForModel: "1 replacement(s)",
|
||||||
|
ForUI: builtin.DiffResult{
|
||||||
|
Path: "f.go",
|
||||||
|
Lines: []builtin.DiffLine{
|
||||||
|
{Op: builtin.DiffRemove, Text: "old line"},
|
||||||
|
{Op: builtin.DiffAdd, Text: "new line"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderToolResult("edit", res)
|
||||||
|
if !strings.Contains(out, "- old line") {
|
||||||
|
t.Errorf("missing removed line in: %q", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "+ new line") {
|
||||||
|
t.Errorf("missing added line in: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderToolResult_ErrorUsesErrorMarker(t *testing.T) {
|
||||||
|
out := renderToolResult("bash", tool.Result{ForModel: "boom", IsError: true})
|
||||||
|
if !strings.Contains(out, "✗") {
|
||||||
|
t.Errorf("expected error marker in: %q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "✓") {
|
||||||
|
t.Errorf("did not expect success marker in: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderToolResult_OKUsesSuccessMarker(t *testing.T) {
|
||||||
|
out := renderToolResult("bash", tool.Result{ForModel: "ok"})
|
||||||
|
if !strings.Contains(out, "✓") {
|
||||||
|
t.Errorf("expected success marker in: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapForViewport_WrapsLongLinesToWidth(t *testing.T) {
|
||||||
|
long := strings.Repeat("word ", 40) // deutlich länger als 20 Spalten
|
||||||
|
wrapped := wrapForViewport(long, 20)
|
||||||
|
|
||||||
|
for _, line := range strings.Split(wrapped, "\n") {
|
||||||
|
if len([]rune(line)) > 20 {
|
||||||
|
t.Errorf("line exceeds width 20 (%d runes): %q", len([]rune(line)), line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wrapped == long {
|
||||||
|
t.Error("expected wrapping to actually change the content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapForViewport_ZeroWidthIsNoop(t *testing.T) {
|
||||||
|
s := "some text that would otherwise wrap"
|
||||||
|
if got := wrapForViewport(s, 0); got != s {
|
||||||
|
t.Errorf("width<=0 should leave content untouched, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderToolCall_PrettyPrintsJSONInput(t *testing.T) {
|
||||||
|
out := renderToolCall("read", []byte(`{"path":"a.go"}`))
|
||||||
|
if !strings.Contains(out, "read") || !strings.Contains(out, "a.go") {
|
||||||
|
t.Errorf("unexpected render: %q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/ui/tui/run.go
Normal file
41
internal/ui/tui/run.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"nub/internal/agent"
|
||||||
|
"nub/internal/config"
|
||||||
|
"nub/internal/mcpc"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/skill"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Run startet die TUI und blockiert, bis der Nutzer beendet oder der
|
||||||
|
// Kontext abbricht. loop.Run wird intern gestartet (auch erneut bei einem
|
||||||
|
// Session-Wechsel über /sessions); die TUI ist ein reiner Consumer des
|
||||||
|
// jeweils aktuellen UIEvent-Channels (E-04).
|
||||||
|
func Run(ctx context.Context, loop *agent.Loop, store *session.Store, cfg config.Config, skills []skill.Skill, mcpServers []*mcpc.Server) error {
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m := New(runCtx, loop, store, cfg, skills, mcpServers)
|
||||||
|
|
||||||
|
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithContext(runCtx), tea.WithMouseCellMotion())
|
||||||
|
finalModel, err := p.Run()
|
||||||
|
|
||||||
|
// `in` bewusst nicht schließen: ein noch laufender sendInput-Cmd könnte
|
||||||
|
// gerade darauf senden; Close+Send racet in einen "send on closed
|
||||||
|
// channel"-Panic. ctx.Done() reicht, damit loop.Run() sauber zurückkehrt.
|
||||||
|
cancel()
|
||||||
|
if fm, ok := finalModel.(*Model); ok && fm.out != nil {
|
||||||
|
for range fm.out {
|
||||||
|
// Drainen, damit loop.Run() nicht an einem blockierenden Send hängen bleibt.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ tool.UIEvent // Import-Anker: tool wird über model.go/render.go transitiv gebraucht
|
||||||
226
internal/ui/tui/session_test.go
Normal file
226
internal/ui/tui/session_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"iter"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textarea"
|
||||||
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
|
|
||||||
|
"nub/internal/agent"
|
||||||
|
"nub/internal/llm"
|
||||||
|
"nub/internal/session"
|
||||||
|
"nub/internal/tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// idleProvider wird in diesen Tests nie tatsächlich gestreamt — der Loop
|
||||||
|
// läuft nur, um startSession realistisch zu testen (in/out-Channel-Handling),
|
||||||
|
// ohne dass eine Nachricht gesendet wird.
|
||||||
|
type idleProvider struct{}
|
||||||
|
|
||||||
|
func (idleProvider) Name() string { return "idle" }
|
||||||
|
func (idleProvider) Caps() llm.Caps { return llm.Caps{} }
|
||||||
|
func (idleProvider) Stream(ctx context.Context, req llm.Request) (iter.Seq2[llm.Event, error], error) {
|
||||||
|
return func(yield func(llm.Event, error) bool) {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSwitchableModel(t *testing.T, dir string, store *session.Store) *Model {
|
||||||
|
t.Helper()
|
||||||
|
loop := &agent.Loop{
|
||||||
|
Provider: idleProvider{},
|
||||||
|
Tools: tool.NewRegistry(),
|
||||||
|
Model: "test-model",
|
||||||
|
Env: tool.Env{Cwd: dir, RepoRoot: dir},
|
||||||
|
}
|
||||||
|
return &Model{
|
||||||
|
ctx: context.Background(),
|
||||||
|
loop: loop,
|
||||||
|
textarea: textarea.New(),
|
||||||
|
viewport: viewport.New(80, 20),
|
||||||
|
spinner: spinner.New(),
|
||||||
|
modelName: loop.Model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartSession_ReplaysHistoryAndBumpsGeneration(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
|
||||||
|
n1 := session.NewNode("", textMsg(llm.RoleUser, "hello from history"), session.NodeMeta{})
|
||||||
|
if err := store.Append(n1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "hi there"), session.NodeMeta{})
|
||||||
|
if err := store.Append(n2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newSwitchableModel(t, dir, store)
|
||||||
|
if m.sessionGen != 0 {
|
||||||
|
t.Fatalf("sessionGen = %d before any startSession call, want 0", m.sessionGen)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := m.startSession(store)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if m.sessionCancel != nil {
|
||||||
|
m.sessionCancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if m.sessionGen != 1 {
|
||||||
|
t.Errorf("sessionGen = %d, want 1 after first startSession", m.sessionGen)
|
||||||
|
}
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("expected startSession to return a listen cmd")
|
||||||
|
}
|
||||||
|
if len(m.entries) != 2 {
|
||||||
|
t.Fatalf("entries = %+v, want 2 replayed messages", m.entries)
|
||||||
|
}
|
||||||
|
if m.entries[0].kind != entryUser || m.entries[0].text != "hello from history" {
|
||||||
|
t.Errorf("entries[0] = %+v", m.entries[0])
|
||||||
|
}
|
||||||
|
if m.entries[1].kind != entryAssistant || m.entries[1].text != "hi there" {
|
||||||
|
t.Errorf("entries[1] = %+v", m.entries[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der zurückgegebene Cmd muss tatsächlich auf dem neuen out-Channel
|
||||||
|
// lauschen (gen=1) — daran hängt die Stale-Event-Erkennung beim Wechsel.
|
||||||
|
// cmd() blockiert, bis out etwas liefert oder schließt; also im
|
||||||
|
// Hintergrund aufrufen und den Kontext canceln, um out zu schließen.
|
||||||
|
resultCh := make(chan interface{}, 1)
|
||||||
|
go func() { resultCh <- cmd() }()
|
||||||
|
m.sessionCancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case msg := <-resultCh:
|
||||||
|
evMsg, ok := msg.(eventsClosedMsg)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected eventsClosedMsg, got %T", msg)
|
||||||
|
}
|
||||||
|
if evMsg.gen != 1 {
|
||||||
|
t.Errorf("listen cmd bound to gen %d, want 1", evMsg.gen)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for the listen cmd to observe channel closure")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartSession_RestoresHistoricalTokenUsage(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
|
||||||
|
n1 := session.NewNode("", textMsg(llm.RoleUser, "hi"), session.NodeMeta{})
|
||||||
|
if err := store.Append(n1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n2 := session.NewNode(n1.ID, textMsg(llm.RoleAssistant, "hello"),
|
||||||
|
session.NodeMeta{Usage: llm.Usage{InputTokens: 1200, OutputTokens: 30, CacheReadTokens: 900}})
|
||||||
|
if err := store.Append(n2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n3 := session.NewNode(n2.ID, textMsg(llm.RoleUser, "again"), session.NodeMeta{})
|
||||||
|
if err := store.Append(n3); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n4 := session.NewNode(n3.ID, textMsg(llm.RoleAssistant, "again reply"),
|
||||||
|
session.NodeMeta{Usage: llm.Usage{InputTokens: 1400, OutputTokens: 20, CacheReadTokens: 1300}})
|
||||||
|
if err := store.Append(n4); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := newSwitchableModel(t, dir, store)
|
||||||
|
m.tokensUsed = 999999 // Altlast einer vorherigen Session, muss überschrieben werden
|
||||||
|
m.cacheReadTokens = 999999
|
||||||
|
|
||||||
|
m.startSession(store)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if m.sessionCancel != nil {
|
||||||
|
m.sessionCancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
wantTokens := 1200 + 30 + 1400 + 20
|
||||||
|
if m.tokensUsed != wantTokens {
|
||||||
|
t.Errorf("tokensUsed = %d, want %d (sum of persisted NodeMeta.Usage)", m.tokensUsed, wantTokens)
|
||||||
|
}
|
||||||
|
wantCache := 900 + 1300
|
||||||
|
if m.cacheReadTokens != wantCache {
|
||||||
|
t.Errorf("cacheReadTokens = %d, want %d", m.cacheReadTokens, wantCache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartSession_SwitchingCancelsPreviousLoop(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
storeA, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { storeA.Close() })
|
||||||
|
storeB, err := session.Create(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { storeB.Close() })
|
||||||
|
|
||||||
|
m := newSwitchableModel(t, dir, storeA)
|
||||||
|
m.startSession(storeA)
|
||||||
|
oldOut := m.out
|
||||||
|
oldGen := m.sessionGen
|
||||||
|
|
||||||
|
m.startSession(storeB)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if m.sessionCancel != nil {
|
||||||
|
m.sessionCancel()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if m.sessionGen != oldGen+1 {
|
||||||
|
t.Errorf("sessionGen = %d, want %d after switching", m.sessionGen, oldGen+1)
|
||||||
|
}
|
||||||
|
if m.store != storeB {
|
||||||
|
t.Error("expected store to point at the new session")
|
||||||
|
}
|
||||||
|
if m.loop.Session != storeB {
|
||||||
|
t.Error("expected loop.Session to be rebound to the new store")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case _, ok := <-oldOut:
|
||||||
|
if ok {
|
||||||
|
t.Error("expected the old session's out channel to close, not emit an event")
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for the previous loop to shut down after cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdate_StaleSessionEventsAreIgnored(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
m.sessionGen = 5
|
||||||
|
m.turnActive = true
|
||||||
|
|
||||||
|
_, cmd := m.Update(uiEventMsg{gen: 3, ev: tool.TextDelta{Text: "should be ignored"}})
|
||||||
|
if cmd != nil {
|
||||||
|
t.Error("stale uiEventMsg should not schedule any follow-up cmd")
|
||||||
|
}
|
||||||
|
if m.hasLiveText {
|
||||||
|
t.Error("stale uiEventMsg must not be applied to model state")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, cmd = m.Update(eventsClosedMsg{gen: 3})
|
||||||
|
if m.quitting {
|
||||||
|
t.Error("stale eventsClosedMsg from a switched-away session must not quit the TUI")
|
||||||
|
}
|
||||||
|
_ = cmd
|
||||||
|
}
|
||||||
66
internal/ui/tui/styles.go
Normal file
66
internal/ui/tui/styles.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
// Alle Farben als AdaptiveColor: lipgloss erkennt Light/Dark-Terminals
|
||||||
|
// (über termenv) und wählt automatisch die passende Variante. Feste
|
||||||
|
// ANSI-Codes wie "15" (fast weiß) oder "221" (Gelb) sind auf hellem
|
||||||
|
// Hintergrund praktisch unlesbar — das war der ursprüngliche Bug hier.
|
||||||
|
var (
|
||||||
|
styleUser = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "17", Dark: "15"}).
|
||||||
|
Bold(true)
|
||||||
|
styleAssistant = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "234", Dark: "252"})
|
||||||
|
styleThinking = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "243", Dark: "245"}).
|
||||||
|
Italic(true)
|
||||||
|
styleToolCall = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "111"})
|
||||||
|
styleToolOK = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "108"})
|
||||||
|
styleToolErr = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "203"})
|
||||||
|
styleSystem = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "221"})
|
||||||
|
styleError = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "203"}).
|
||||||
|
Bold(true)
|
||||||
|
styleCommand = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "30", Dark: "116"})
|
||||||
|
styleDiffAdd = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "108"})
|
||||||
|
styleDiffDel = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "203"})
|
||||||
|
styleStatusBar = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "15", Dark: "15"}).
|
||||||
|
Background(lipgloss.AdaptiveColor{Light: "24", Dark: "237"}).
|
||||||
|
Padding(0, 1)
|
||||||
|
styleModalBox = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.AdaptiveColor{Light: "243", Dark: "245"}).
|
||||||
|
Padding(1, 2)
|
||||||
|
styleModalSel = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "15", Dark: "0"}).
|
||||||
|
Background(lipgloss.AdaptiveColor{Light: "25", Dark: "111"}).
|
||||||
|
Bold(true)
|
||||||
|
styleQueued = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "243", Dark: "245"}).
|
||||||
|
Italic(true)
|
||||||
|
stylePermissionBox = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.AdaptiveColor{Light: "130", Dark: "221"}).
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "221"}).
|
||||||
|
Padding(0, 1)
|
||||||
|
stylePermissionPending = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "221"}).
|
||||||
|
Bold(true)
|
||||||
|
styleAskBox = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.AdaptiveColor{Light: "25", Dark: "111"}).
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "111"}).
|
||||||
|
Padding(0, 1)
|
||||||
|
styleAskPending = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "111"}).
|
||||||
|
Bold(true)
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue