commit a97013d8766b617d864145b6fd432ccc52a9d122 Author: Tom Date: Sat Jul 25 11:08:01 2026 +0200 initial commit - v 0.1.0 siehe CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d84d8dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/nub +*.test +*.out +.DS_Store +.claude +commit.txt +.nub/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bb5aa61 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c763f05 --- /dev/null +++ b/CHANGELOG.md @@ -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 (`/`) aufrufbar. +- `/sessions`: Sessions auflisten, wechseln, löschen. + +[Feature-Übersicht](docs/FEATURES.md) · [Test-Rezepte](docs/TESTING.md) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f74ac4d --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba65c73 --- /dev/null +++ b/README.md @@ -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 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) → `/.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 `/ [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/.jsonl` (automatisch von Git ausgeschlossen via +`.git/info/exclude`). + +```bash +nub sessions # Sessions im aktuellen Verzeichnis auflisten +nub resume # 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/.jsonl | jq -r 'select(.node) | .node.id + " " + (.node.message.content[0].text // "" | .[0:60])' +nub resume --branch +``` + +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 # 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//SKILL.md` (projektlokal) oder +`~/.nub/skills//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:** `/ [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 +`__`-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 --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 fortsetzen +nub resume --branch + von einem früheren Knoten fortsetzen (Rewind) +nub sessions Sessions auflisten +nub map REPOMAP.md erzeugen/aktualisieren +nub context geladenen Kontext anzeigen +nub compact Compaction erzwingen +nub skills gefundene Skills auflisten +nub mcp MCP-Server verbinden, Tools auflisten +nub [resume ...] --profile

nur MCP-Server aus profiles.

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). diff --git a/cmd/nub/main.go b/cmd/nub/main.go new file mode 100644 index 0000000..708e378 --- /dev/null +++ b/cmd/nub/main.go @@ -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 bestehende Session fortsetzen + nub resume --branch + 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 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

nur die MCP-Server aus profiles.

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 [--branch ] [--profile ]") + 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 ") + 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 + } + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..f618b0e --- /dev/null +++ b/docs/FEATURES.md @@ -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 ` 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 `. +- 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//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 `/ + [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 `__`-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. diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md new file mode 100644 index 0000000..7235ab6 --- /dev/null +++ b/docs/KONZEPT.md @@ -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/.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` → +`/.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 `__` 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. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..8938176 --- /dev/null +++ b/docs/TESTING.md @@ -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 ` 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 --branch +Mach stattdessen etwas anderes: ... +``` + +Erwartet: `nub` schreibt ab hier einen zweiten Ast. `cat +.nub/sessions/.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 --branch +``` + +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 ` 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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0f53549 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b9d76e2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/agent/arch_test.go b/internal/agent/arch_test.go new file mode 100644 index 0000000..806ba7a --- /dev/null +++ b/internal/agent/arch_test.go @@ -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) + } + } + } +} diff --git a/internal/agent/ask_test.go b/internal/agent/ask_test.go new file mode 100644 index 0000000..e229f01 --- /dev/null +++ b/internal/agent/ask_test.go @@ -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) + } +} diff --git a/internal/agent/compact.go b/internal/agent/compact.go new file mode 100644 index 0000000..1b2318b --- /dev/null +++ b/internal/agent/compact.go @@ -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() +} diff --git a/internal/agent/compact_test.go b/internal/agent/compact_test.go new file mode 100644 index 0000000..0f67334 --- /dev/null +++ b/internal/agent/compact_test.go @@ -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) + } +} diff --git a/internal/agent/consume.go b/internal/agent/consume.go new file mode 100644 index 0000000..6753c3f --- /dev/null +++ b/internal/agent/consume.go @@ -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 +} diff --git a/internal/agent/loop.go b/internal/agent/loop.go new file mode 100644 index 0000000..f44a69f --- /dev/null +++ b/internal/agent/loop.go @@ -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] +} diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go new file mode 100644 index 0000000..28e7516 --- /dev/null +++ b/internal/agent/loop_test.go @@ -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 +} diff --git a/internal/agent/permission_test.go b/internal/agent/permission_test.go new file mode 100644 index 0000000..fe7587b --- /dev/null +++ b/internal/agent/permission_test.go @@ -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) + } +} diff --git a/internal/agent/tools.go b/internal/agent/tools.go new file mode 100644 index 0000000..968e72c --- /dev/null +++ b/internal/agent/tools.go @@ -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) +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..e5d8702 --- /dev/null +++ b/internal/config/load.go @@ -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, /.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 "" +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..5262832 --- /dev/null +++ b/internal/config/load_test.go @@ -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") + } +} diff --git a/internal/config/resolve.go b/internal/config/resolve.go new file mode 100644 index 0000000..7917aa0 --- /dev/null +++ b/internal/config/resolve.go @@ -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://") +} diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..4289f8f --- /dev/null +++ b/internal/config/schema.go @@ -0,0 +1,82 @@ +// Package config implementiert die geschichtete Konfiguration aus +// Abschnitt 4.7: Defaults -> ~/.config/nub/config.toml -> /.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*"}, + }, + } +} diff --git a/internal/ctxasm/assemble.go b/internal/ctxasm/assemble.go new file mode 100644 index 0000000..9168b0d --- /dev/null +++ b/internal/ctxasm/assemble.go @@ -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() +} diff --git a/internal/ctxasm/assemble_test.go b/internal/ctxasm/assemble_test.go new file mode 100644 index 0000000..fa8f156 --- /dev/null +++ b/internal/ctxasm/assemble_test.go @@ -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() +} diff --git a/internal/ctxasm/default_system.md b/internal/ctxasm/default_system.md new file mode 100644 index 0000000..a828006 --- /dev/null +++ b/internal/ctxasm/default_system.md @@ -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. diff --git a/internal/ctxasm/repomap.go b/internal/ctxasm/repomap.go new file mode 100644 index 0000000..df5760e --- /dev/null +++ b/internal/ctxasm/repomap.go @@ -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 +} diff --git a/internal/llm/event.go b/internal/llm/event.go new file mode 100644 index 0000000..f3cf1a8 --- /dev/null +++ b/internal/llm/event.go @@ -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"` +} diff --git a/internal/llm/message.go b/internal/llm/message.go new file mode 100644 index 0000000..c6bdffc --- /dev/null +++ b/internal/llm/message.go @@ -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 +} diff --git a/internal/llm/openai/adapter.go b/internal/llm/openai/adapter.go new file mode 100644 index 0000000..5237c03 --- /dev/null +++ b/internal/llm/openai/adapter.go @@ -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) +} diff --git a/internal/llm/openai/request.go b/internal/llm/openai/request.go new file mode 100644 index 0000000..43835b5 --- /dev/null +++ b/internal/llm/openai/request.go @@ -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...) +} diff --git a/internal/llm/openai/stream.go b/internal/llm/openai/stream.go new file mode 100644 index 0000000..8629dee --- /dev/null +++ b/internal/llm/openai/stream.go @@ -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 +} diff --git a/internal/llm/openai/stream_test.go b/internal/llm/openai/stream_test.go new file mode 100644 index 0000000..dc9b7e1 --- /dev/null +++ b/internal/llm/openai/stream_test.go @@ -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") + } +} diff --git a/internal/llm/openai/testdata/cached_usage.sse b/internal/llm/openai/testdata/cached_usage.sse new file mode 100644 index 0000000..ab4c065 --- /dev/null +++ b/internal/llm/openai/testdata/cached_usage.sse @@ -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] diff --git a/internal/llm/openai/testdata/tool_call_fragmented.sse b/internal/llm/openai/testdata/tool_call_fragmented.sse new file mode 100644 index 0000000..6ebb528 --- /dev/null +++ b/internal/llm/openai/testdata/tool_call_fragmented.sse @@ -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] diff --git a/internal/llm/openai/wire.go b/internal/llm/openai/wire.go new file mode 100644 index 0000000..0f64c35 --- /dev/null +++ b/internal/llm/openai/wire.go @@ -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"` +} diff --git a/internal/llm/provider.go b/internal/llm/provider.go new file mode 100644 index 0000000..4ff956d --- /dev/null +++ b/internal/llm/provider.go @@ -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 +} diff --git a/internal/llm/registry/registry.go b/internal/llm/registry/registry.go new file mode 100644 index 0000000..34f9030 --- /dev/null +++ b/internal/llm/registry/registry.go @@ -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 +} diff --git a/internal/llm/registry/registry_test.go b/internal/llm/registry/registry_test.go new file mode 100644 index 0000000..ed4e4db --- /dev/null +++ b/internal/llm/registry/registry_test.go @@ -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") + } +} diff --git a/internal/mcpc/bridge.go b/internal/mcpc/bridge.go new file mode 100644 index 0000000..2f5e5bc --- /dev/null +++ b/internal/mcpc/bridge.go @@ -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 +// __-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 +} diff --git a/internal/mcpc/bridge_test.go b/internal/mcpc/bridge_test.go new file mode 100644 index 0000000..039528c --- /dev/null +++ b/internal/mcpc/bridge_test.go @@ -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) + } +} diff --git a/internal/mcpc/connect.go b/internal/mcpc/connect.go new file mode 100644 index 0000000..7c2810c --- /dev/null +++ b/internal/mcpc/connect.go @@ -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() + } +} diff --git a/internal/mcpc/profile.go b/internal/mcpc/profile.go new file mode 100644 index 0000000..b030657 --- /dev/null +++ b/internal/mcpc/profile.go @@ -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 +} diff --git a/internal/mcpc/profile_test.go b/internal/mcpc/profile_test.go new file mode 100644 index 0000000..0ee524b --- /dev/null +++ b/internal/mcpc/profile_test.go @@ -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") + } +} diff --git a/internal/permission/policy.go b/internal/permission/policy.go new file mode 100644 index 0000000..81ad1c5 --- /dev/null +++ b/internal/permission/policy.go @@ -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 +} diff --git a/internal/permission/policy_test.go b/internal/permission/policy_test.go new file mode 100644 index 0000000..3c61310 --- /dev/null +++ b/internal/permission/policy_test.go @@ -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) + } +} diff --git a/internal/scaffold/init.go b/internal/scaffold/init.go new file mode 100644 index 0000000..07a4b56 --- /dev/null +++ b/internal/scaffold/init.go @@ -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 + + + +## Konventionen + +- + +## Vorsicht bei + +- +` diff --git a/internal/scaffold/init_test.go b/internal/scaffold/init_test.go new file mode 100644 index 0000000..f0267fc --- /dev/null +++ b/internal/scaffold/init_test.go @@ -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 + })() +} diff --git a/internal/session/gitexclude.go b/internal/session/gitexclude.go new file mode 100644 index 0000000..053df66 --- /dev/null +++ b/internal/session/gitexclude.go @@ -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 +} diff --git a/internal/session/node.go b/internal/session/node.go new file mode 100644 index 0000000..06516c9 --- /dev/null +++ b/internal/session/node.go @@ -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, + } +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..352d415 --- /dev/null +++ b/internal/session/session.go @@ -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 +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..73b38b4 --- /dev/null +++ b/internal/session/session_test.go @@ -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) + } +} diff --git a/internal/session/store.go b/internal/session/store.go new file mode 100644 index 0000000..db16dd7 --- /dev/null +++ b/internal/session/store.go @@ -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/.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] + "…" +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go new file mode 100644 index 0000000..2530db0 --- /dev/null +++ b/internal/session/store_test.go @@ -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) + } +} diff --git a/internal/skill/index.go b/internal/skill/index.go new file mode 100644 index 0000000..c4e690b --- /dev/null +++ b/internal/skill/index.go @@ -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() +} diff --git a/internal/skill/skill.go b/internal/skill/skill.go new file mode 100644 index 0000000..5cf77e2 --- /dev/null +++ b/internal/skill/skill.go @@ -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, "~")) +} diff --git a/internal/skill/skill_test.go b/internal/skill/skill_test.go new file mode 100644 index 0000000..3635f4b --- /dev/null +++ b/internal/skill/skill_test.go @@ -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)) + } +} diff --git a/internal/tokens/blocks.go b/internal/tokens/blocks.go new file mode 100644 index 0000000..c5d08ea --- /dev/null +++ b/internal/tokens/blocks.go @@ -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 +} diff --git a/internal/tokens/calibrate.go b/internal/tokens/calibrate.go new file mode 100644 index 0000000..8153ad8 --- /dev/null +++ b/internal/tokens/calibrate.go @@ -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) +} diff --git a/internal/tokens/calibrate_test.go b/internal/tokens/calibrate_test.go new file mode 100644 index 0000000..940f0a4 --- /dev/null +++ b/internal/tokens/calibrate_test.go @@ -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") + } +} diff --git a/internal/tokens/estimate.go b/internal/tokens/estimate.go new file mode 100644 index 0000000..56d55a2 --- /dev/null +++ b/internal/tokens/estimate.go @@ -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) +} diff --git a/internal/tool/builtin/bash.go b/internal/tool/builtin/bash.go new file mode 100644 index 0000000..96113f7 --- /dev/null +++ b/internal/tool/builtin/bash.go @@ -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 +} diff --git a/internal/tool/builtin/builtin_test.go b/internal/tool/builtin/builtin_test.go new file mode 100644 index 0000000..f86127e --- /dev/null +++ b/internal/tool/builtin/builtin_test.go @@ -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 + })() +} diff --git a/internal/tool/builtin/diff.go b/internal/tool/builtin/diff.go new file mode 100644 index 0000000..333ec0d --- /dev/null +++ b/internal/tool/builtin/diff.go @@ -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") +} diff --git a/internal/tool/builtin/edit.go b/internal/tool/builtin/edit.go new file mode 100644 index 0000000..df5084e --- /dev/null +++ b/internal/tool/builtin/edit.go @@ -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 +} diff --git a/internal/tool/builtin/gitignore.go b/internal/tool/builtin/gitignore.go new file mode 100644 index 0000000..ae3d96f --- /dev/null +++ b/internal/tool/builtin/gitignore.go @@ -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 +} diff --git a/internal/tool/builtin/glob.go b/internal/tool/builtin/glob.go new file mode 100644 index 0000000..ee72de0 --- /dev/null +++ b/internal/tool/builtin/glob.go @@ -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 +} diff --git a/internal/tool/builtin/grep.go b/internal/tool/builtin/grep.go new file mode 100644 index 0000000..441ee05 --- /dev/null +++ b/internal/tool/builtin/grep.go @@ -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 +} diff --git a/internal/tool/builtin/path.go b/internal/tool/builtin/path.go new file mode 100644 index 0000000..97130cc --- /dev/null +++ b/internal/tool/builtin/path.go @@ -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 +} diff --git a/internal/tool/builtin/question.go b/internal/tool/builtin/question.go new file mode 100644 index 0000000..500fcb8 --- /dev/null +++ b/internal/tool/builtin/question.go @@ -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 +} diff --git a/internal/tool/builtin/question_test.go b/internal/tool/builtin/question_test.go new file mode 100644 index 0000000..fc59493 --- /dev/null +++ b/internal/tool/builtin/question_test.go @@ -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") + } +} diff --git a/internal/tool/builtin/read.go b/internal/tool/builtin/read.go new file mode 100644 index 0000000..b0750a5 --- /dev/null +++ b/internal/tool/builtin/read.go @@ -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 +} diff --git a/internal/tool/builtin/read_skill.go b/internal/tool/builtin/read_skill.go new file mode 100644 index 0000000..13db7c4 --- /dev/null +++ b/internal/tool/builtin/read_skill.go @@ -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 +} diff --git a/internal/tool/builtin/read_skill_test.go b/internal/tool/builtin/read_skill_test.go new file mode 100644 index 0000000..7d68ffe --- /dev/null +++ b/internal/tool/builtin/read_skill_test.go @@ -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) + } +} diff --git a/internal/tool/builtin/todo.go b/internal/tool/builtin/todo.go new file mode 100644 index 0000000..631bec3 --- /dev/null +++ b/internal/tool/builtin/todo.go @@ -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() +} diff --git a/internal/tool/builtin/todo_test.go b/internal/tool/builtin/todo_test.go new file mode 100644 index 0000000..e4d8800 --- /dev/null +++ b/internal/tool/builtin/todo_test.go @@ -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) + } +} diff --git a/internal/tool/builtin/write.go b/internal/tool/builtin/write.go new file mode 100644 index 0000000..de6acc1 --- /dev/null +++ b/internal/tool/builtin/write.go @@ -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 +} diff --git a/internal/tool/tool.go b/internal/tool/tool.go new file mode 100644 index 0000000..4ad0ef4 --- /dev/null +++ b/internal/tool/tool.go @@ -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) +} diff --git a/internal/tool/tool_test.go b/internal/tool/tool_test.go new file mode 100644 index 0000000..ec711b8 --- /dev/null +++ b/internal/tool/tool_test.go @@ -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) + } + } +} diff --git a/internal/tool/uievent.go b/internal/tool/uievent.go new file mode 100644 index 0000000..9884b63 --- /dev/null +++ b/internal/tool/uievent.go @@ -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() {} diff --git a/internal/ui/plain/plain.go b/internal/ui/plain/plain.go new file mode 100644 index 0000000..8903044 --- /dev/null +++ b/internal/ui/plain/plain.go @@ -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 +} diff --git a/internal/ui/tui/commands.go b/internal/ui/tui/commands.go new file mode 100644 index 0000000..e8e021c --- /dev/null +++ b/internal/ui/tui/commands.go @@ -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 +} diff --git a/internal/ui/tui/commands_test.go b/internal/ui/tui/commands_test.go new file mode 100644 index 0000000..ed6f7de --- /dev/null +++ b/internal/ui/tui/commands_test.go @@ -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) + } +} diff --git a/internal/ui/tui/model.go b/internal/ui/tui/model.go new file mode 100644 index 0000000..030c8b2 --- /dev/null +++ b/internal/ui/tui/model.go @@ -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 +} diff --git a/internal/ui/tui/model_test.go b/internal/ui/tui/model_test.go new file mode 100644 index 0000000..2699e5a --- /dev/null +++ b/internal/ui/tui/model_test.go @@ -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) + } +} diff --git a/internal/ui/tui/permission.go b/internal/ui/tui/permission.go new file mode 100644 index 0000000..e3e967a --- /dev/null +++ b/internal/ui/tui/permission.go @@ -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) +} diff --git a/internal/ui/tui/permission_test.go b/internal/ui/tui/permission_test.go new file mode 100644 index 0000000..80f6006 --- /dev/null +++ b/internal/ui/tui/permission_test.go @@ -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() +} diff --git a/internal/ui/tui/question.go b/internal/ui/tui/question.go new file mode 100644 index 0000000..f608f1f --- /dev/null +++ b/internal/ui/tui/question.go @@ -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()) +} diff --git a/internal/ui/tui/question_test.go b/internal/ui/tui/question_test.go new file mode 100644 index 0000000..d3efd08 --- /dev/null +++ b/internal/ui/tui/question_test.go @@ -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") + } +} diff --git a/internal/ui/tui/render.go b/internal/ui/tui/render.go new file mode 100644 index 0000000..5e01122 --- /dev/null +++ b/internal/ui/tui/render.go @@ -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) +} diff --git a/internal/ui/tui/render_test.go b/internal/ui/tui/render_test.go new file mode 100644 index 0000000..87c709c --- /dev/null +++ b/internal/ui/tui/render_test.go @@ -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) + } +} diff --git a/internal/ui/tui/run.go b/internal/ui/tui/run.go new file mode 100644 index 0000000..92b8576 --- /dev/null +++ b/internal/ui/tui/run.go @@ -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 diff --git a/internal/ui/tui/session_test.go b/internal/ui/tui/session_test.go new file mode 100644 index 0000000..04d454b --- /dev/null +++ b/internal/ui/tui/session_test.go @@ -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 +} diff --git a/internal/ui/tui/styles.go b/internal/ui/tui/styles.go new file mode 100644 index 0000000..b9e87bb --- /dev/null +++ b/internal/ui/tui/styles.go @@ -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) +)