nub/docs/KONZEPT.md
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

857 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 2040k Tokens permanent.
*Konsequenz:* Zusätzlich Server-Profile pro Projekt (`profiles.review = ["github"]`),
damit die Last nur dort anfällt, wo sie gebraucht wird.
### E-08 — Compaction ist ein Knoten im Baum
Ein `Summary`-Node mit `Replaces []NodeID`. `PathToHead()` läuft von Head Richtung Wurzel
und stoppt am ersten Summary-Node.
*Begründung:* Kein destruktives Löschen, das Original bleibt für Rewind erhalten, und die
Compaction selbst ist branchbar.
*Konsequenz:* Der Pfad-Walk braucht diese Sonderbehandlung. Trivial, aber muss getestet
sein.
### E-09 — Kein echter Tokenizer in v1
Schätzung über `len(text)/4` (bzw. `/3` für CJK), kalibriert gegen die tatsächliche
`usage` aus jeder API-Antwort.
*Begründung:* Tokenizer sind pro Modell verschieden und die exakte Zahl ist nur für den
Compaction-Trigger relevant. Ein 15%-Sicherheitsabstand ist billiger als die Abhängigkeit.
*Konsequenz:* Compaction-Schwelle konservativ (Default: 75% des Fensters).
### E-10 — Capabilities pro Endpoint, nicht pro Protokoll
„OpenAI-kompatibel" ist ein Sammelbegriff. Groq, Cerebras, vLLM, Ollama, LM Studio und
OpenRouter verhalten sich unterschiedlich.
*Konsequenz:* `Caps`-Struct (4.4), befüllt aus Modell-Registry + Config-Overrides.
### E-11 — Permissions: statisch konfiguriert, keine interaktiven Popups in v1
Modus pro Tool (`ask`/`auto`/`deny`) plus Pfad-Regeln. `ask` ist im Print-Modus ein
harter Fehler.
*Begründung:* Interaktive Rückfragen erzwingen eine UI-Abhängigkeit im Kern (verletzt
E-04) und sind im Print-Modus sinnlos.
*Konsequenz:* v1 setzt darauf, dass sicherheitskritische Nutzung im Container läuft.
Interaktive Bestätigung kann in Meilenstein 6 als UI-Event nachgerüstet werden.
### E-12 — Retry gehört in den Provider-Layer, nicht in den Loop
Wiederholungsversuche passieren unterhalb von `Provider.Stream`. Der Agent-Loop sieht
entweder einen erfolgreichen Stream oder einen endgültigen Fehler.
*Begründung:* Der Loop kennt keine HTTP-Semantik, und Retry-Logik an zwei Stellen führt
zu multiplikativen Wartezeiten.
*Konsequenz:* Ein Retry ist nur zulässig, solange noch keine Blöcke an den Consumer
emittiert wurden. Reißt der Stream mitten in einer Antwort ab, wird der Turn als Fehler
beendet — ein halb ausgegebener Text darf nicht doppelt erscheinen. Details in 5.9.
---
## 4. Datenmodelle
### 4.1 Messages & Blöcke (`internal/llm`)
```go
type Role string
const (
RoleUser Role = "user"
RoleAssistant Role = "assistant"
)
type BlockKind string
const (
KindText BlockKind = "text"
KindThinking BlockKind = "thinking"
KindToolUse BlockKind = "tool_use"
KindToolResult BlockKind = "tool_result"
KindImage BlockKind = "image"
)
type Block struct {
Kind BlockKind
// text / thinking
Text string
// tool_use
ID string
Name string
Input json.RawMessage
// tool_result
ToolUseID string
Result []Block
IsError bool
// image
MediaType string
Data []byte
// Opaker Provider-Ballast (Anthropic-Signatures, reasoning-IDs).
// Muss unverändert zurückgesendet werden können.
Raw json.RawMessage
// Cache-Breakpoint. Adapter ohne Cache-Steuerung ignorieren das Feld.
CacheMark bool
}
type Message struct {
Role Role
Content []Block
}
type Request struct {
Model string
System []Block // eigenes Feld, NICHT als Message in der Historie
Messages []Message
Tools []ToolDef
MaxTokens int
Temp *float64
}
type ToolDef struct {
Name string
Description string
Schema json.RawMessage // kanonisch: JSON Schema
}
```
**Wichtig:** `System` ist ein Request-Feld, keine Message. Sonst landet es im
Session-Baum und wird bei jedem Rewind mitgeschleppt.
### 4.2 Stream-Events (`internal/llm`)
Modelliert nach dem expliziteren Anthropic-Schema; der OpenAI-Adapter synthetisiert.
```go
type Event interface{ isEvent() }
type BlockStart struct { Index int; Block Block } // Block ohne Inhalt, nur Kind/ID/Name
type BlockDelta struct { Index int; Text string; PartialJSON string }
type BlockStop struct { Index int }
type Done struct { Stop StopReason; Usage Usage }
type StopReason string
const (
StopEnd StopReason = "end_turn"
StopToolUse StopReason = "tool_use"
StopMaxTokens StopReason = "max_tokens"
)
type Usage struct{ InputTokens, OutputTokens, CacheReadTokens, CacheWriteTokens int }
```
### 4.3 Provider-Interface
```go
type Provider interface {
Stream(ctx context.Context, req Request) (iter.Seq2[Event, error], error)
Caps() Caps
Name() string
}
```
### 4.4 Capabilities
```go
type Caps struct {
ParallelToolCalls bool
UsageInStream bool
SystemRole string // "system" | "developer" | "" = eigenes Request-Feld
ExplicitCache bool
Reasoning bool
StrictSchemas bool
MaxContext int
SupportsImages bool
}
```
### 4.5 Session-Baum (`internal/session`)
```go
type NodeKind string
const (
NodeMessage NodeKind = "message"
NodeSummary NodeKind = "summary"
)
type Node struct {
ID string // ULID
ParentID string // "" = Wurzel
Kind NodeKind
Message llm.Message
Replaces []string // nur bei NodeSummary
Meta NodeMeta
}
type NodeMeta struct {
Model string
Usage llm.Usage
CreatedAt time.Time
Label string
Bookmark bool
}
type Session struct {
ID string
Nodes map[string]*Node
Head string
}
func (s *Session) PathToHead() []llm.Message // Wurzel→Head, stoppt am Summary
func (s *Session) Branch(from string) error // setzt Head auf einen früheren Knoten
func (s *Session) Append(n *Node) error
```
**Persistenz:** Append-only JSONL unter `.nub/sessions/<id>.jsonl`, eine Zeile pro Node.
Der Head wird als eigener Record-Typ (`{"head":"..."}`) angehängt; beim Laden gewinnt der
letzte. Crash-sicher, branchbar, ohne DB-Abhängigkeit. SQLite bleibt eine spätere Option,
falls Suche über Sessions gebraucht wird.
**Jeder Record beginnt mit `"v": 1`.** Ein Feld, kostet jetzt nichts, und das Format wird
sich ändern. Beim Laden: unbekannte höhere Version → Fehler mit klarer Meldung, nicht
stillschweigend halb parsen. Dasselbe gilt für die Config (`version = 1`).
**Dateirechte und Git.** Sessiondateien enthalten Tool-Ausgaben und damit potenziell
Umgebungsvariablen, Tokens und Dateiinhalte. Deshalb:
- `.nub/` mit `0700`, Sessiondateien mit `0600`
- beim ersten Start `.nub/` nach `.git/info/exclude` schreiben, **nicht** in die
`.gitignore` — die wird committet und gehört dem Repo, nicht dem Tool
### 4.6 Tool-Interface (`internal/tool`)
```go
type Tool interface {
Name() string
Description() string
Schema() json.RawMessage
Run(ctx context.Context, input json.RawMessage, env Env) (Result, error)
}
type Result struct {
ForModel string // was in den tool_result-Block geht
ForUI any // strukturiert; z.B. DiffResult, FileResult
IsError bool
}
type Env struct {
Cwd string
RepoRoot string
Emit func(UIEvent) // für streamende Tools (bash)
}
```
### 4.7 Config (`internal/config`)
Schichtung, spätere gewinnt: Defaults → `~/.config/nub/config.toml`
`<repo>/.nub/config.toml` → Umgebungsvariablen (`NUB_*`) → Flags.
```toml
[model]
default = "gpt-4o"
endpoint = "openai" # Verweis auf [[endpoint]]
[[endpoint]]
name = "openai"
base_url = "https://api.openai.com/v1"
api_key = "env:OPENAI_API_KEY"
[[endpoint]]
name = "local"
base_url = "http://localhost:11434/v1"
api_key = "none"
[endpoint.caps] # Overrides gegen die Registry
parallel_tool_calls = false
usage_in_stream = false
[context]
files = ["AGENTS.md", "REPOMAP.md"]
walk_up = true
max_tokens = 20000
[skills]
paths = ["~/.nub/skills", ".nub/skills"]
[[mcp]]
name = "github"
command = "gh-mcp-server"
args = []
tools = ["create_issue", "get_pull_request"]
[[mcp]]
name = "linear"
url = "https://mcp.linear.app/mcp"
[profiles]
review = ["github"]
default = []
[permissions]
read = "auto"
glob = "auto"
grep = "auto"
write = "auto"
edit = "auto"
bash = "auto"
deny_paths = [".git/**", "**/.env", "**/id_rsa*"]
deny_bash = ["rm -rf /", "git push --force*"]
```
---
## 5. Komponenten im Detail
### 5.1 OpenAI-Adapter
**Aufgaben:** Request-Mapping, SSE-Parsing, Tool-Call-Akkumulation, Event-Synthese.
Kritische Punkte:
1. **Tool-Call-Akkumulation.** Argumente kommen als String-Fragmente über
`choices[0].delta.tool_calls[i]`. `id` und `name` kommen typischerweise nur im ersten
Chunk; `index` ist die einzige verlässliche Korrelation. Zustand in
`map[int]*partialCall` halten. Tolerant sein: manche Server senden den kompletten
Call in einem Chunk, manche zählen `index` falsch, manche liefern `name` nachträglich.
2. **`tools` weglassen statt leer senden.** `"tools": []` führt bei mehreren
Implementierungen zu Fehlern.
3. **Usage.** Nur mit `stream_options: {include_usage: true}` und nur bei Endpoints mit
`UsageInStream`. Sonst über die Schätzung aus `internal/tokens`.
4. **System-Rolle.** Je nach `Caps.SystemRole` als `system`, `developer` oder gar nicht
(dann als erste User-Message mit Trennmarker).
5. **`reasoning_content`.** Nicht-standardisiertes Feld (DeepSeek u.a.). Wenn vorhanden
und `Caps.Reasoning`, als `KindThinking`-Block mappen.
6. **`[DONE]`-Sentinel** ist kein JSON — vor dem Unmarshal abfangen.
7. **Fehler mitten im Stream.** Manche Endpoints senden ein `error`-Objekt als
SSE-Event statt eines HTTP-Fehlers. Behandeln.
### 5.2 Agent-Loop
```go
func (l *Loop) Run(ctx context.Context, in <-chan Input) <-chan UIEvent
```
Ablauf pro Turn:
1. Kontext zusammenbauen, Historie holen, Provider streamen.
2. Deltas als UIEvents emittieren, parallel Blöcke akkumulieren.
3. Bei `StopToolUse`: alle Tool-Calls parallel starten (Errgroup, abgeleiteter ctx).
4. Ergebnisse einsammeln, als `tool_result`-Blöcke in **eine** User-Message packen.
5. Zurück zu 1, bis `StopEnd`.
**Steering:** Der `in`-Channel wird auch während eines laufenden Turns gelesen.
Eine Steering-Nachricht wird nach Abschluss des *aktuell laufenden* Tools eingeschleust;
noch nicht gestartete Tools werden abgebrochen und bekommen einen `tool_result` mit
`IsError` und Hinweistext (das Protokoll verlangt für jeden `tool_use` ein Ergebnis —
das Auslassen ist ein häufiger Bug). Follow-up-Nachrichten werden gequeued.
**Cancellation:** Zwei Ebenen. Abbruch des Turns (Stream + Tools beenden, Session
konsistent lassen) und Programmende. Ein abgebrochener Turn muss einen gültigen
Baumzustand hinterlassen — d.h. entweder alle `tool_use`-Blöcke bekommen ein Ergebnis,
oder der unvollständige Assistant-Node wird nicht committet.
**Safety-Limits:** `max_turns` pro Anfrage (Default 50), `max_tool_time` pro Tool
(Default 120s), Wiederholungserkennung (identischer Tool-Call 3× hintereinander → Abbruch
mit Hinweis).
### 5.3 Eingebaute Tools
| Tool | Anmerkungen |
|---|---|
| `read` | Zeilennummern voranstellen, Offset/Limit-Parameter, Default-Limit ~2000 Zeilen, Binärerkennung |
| `write` | Vollständiger Dateiinhalt, Parent-Dirs anlegen, `ForUI` = Diff gegen alt |
| `edit` | Exakter String-Replace. Bei mehreren Treffern Fehler mit Trefferzahl, nicht raten. `replace_all`-Flag |
| `bash` | Timeout, **Prozessgruppen-Kill** (`Setpgid`, `syscall.Kill(-pgid)`), Output-Truncation bei ~30k Zeichen (Mitte kürzen, Anfang+Ende behalten), streamt über `Env.Emit` |
| `glob` | Doublestar-Pattern, `.gitignore` respektieren, nach mtime sortiert |
| `grep` | Regex, in-process (`regexp` + Walker) oder `rg` falls vorhanden. Kontext-Zeilen-Parameter |
| `todo` | In-Memory-Liste im Session-State. Messbarer Nutzen bei langen Tasks |
| `read_skill` | Lädt Skill-Body nach Name (5.5) |
Pfad-Regel für alle Datei-Tools: nur unterhalb von `RepoRoot`, Symlinks auflösen und
erneut prüfen, `deny_paths` anwenden.
### 5.4 Context Assembler (`internal/ctxasm`)
Baut `Request.System` in dieser Reihenfolge — von stabil nach volatil:
1. Basis-System-Prompt (eingebettet via `go:embed`, überschreibbar durch `SYSTEM.md`)
2. Umgebung: OS, Shell, Repo-Root, Branch — **statisch für die Session**, keine
Zeitstempel, kein Git-Status
3. Konfigurierte Kontext-Dateien (`AGENTS.md`, `REPOMAP.md`, …)
4. Skills-Index (nur Name + Description)
5.`CacheMark = true` auf dem letzten Block ←
Alles Dynamische (Git-Status, Datum, Tool-Ausgaben) gehört in Messages, nie in `System`.
**Datei-Auflösung bei `walk_up = true`:** Von `cwd` bis `RepoRoot` hochlaufen, alle
Treffer sammeln, in der Reihenfolge Repo-Root → cwd einfügen (näher am
Arbeitsverzeichnis gewinnt inhaltlich, weil später gelesen). Herkunft jeder Datei
protokollieren und im `/context`-Kommando anzeigen.
**Budget:** `context.max_tokens` wird geprüft. Bei Überschreitung wird die *letzte* Datei
gekürzt und eine Warnung emittiert — nicht stillschweigend abgeschnitten.
**REPOMAP.md:** Es gibt ein `nub map`-Kommando, das eine Repomap generiert
(Verzeichnisbaum + exportierte Symbole; für Go über `go/ast`, sprachagnostisch als
Fallback der Dateikopf). Erzeugt wird ein Header mit dem Git-Commit, gegen den die Map
gebaut wurde. Beim Start wird gewarnt, wenn der aktuelle HEAD abweicht — eine falsche
Repomap ist schlechter als keine.
### 5.5 Skills (`internal/skill`)
Struktur:
```
.nub/skills/
refactoring/
SKILL.md
checklist.md
```
`SKILL.md` mit YAML-Frontmatter:
```markdown
---
name: refactoring
description: Vorgehen für größere Refactorings in diesem Repo. Nutzen, wenn mehr als 3 Dateien betroffen sind.
---
# Refactoring
...Body...
```
**Mechanik:** Beim Start werden alle Skills gefunden, aber nur `name` + `description` in
den Prompt geschrieben (~30 Tokens pro Skill). Das Tool `read_skill(name)` liefert den
Body. Weitere Dateien im Skill-Verzeichnis findet das Modell selbst über `read`.
Das ist der Kostenunterschied zu MCP: ein Skill kostet 30 Tokens bis zur Nutzung, ein
MCP-Tool 300800 permanent.
### 5.6 MCP-Client (`internal/mcpc`)
Basis: `github.com/modelcontextprotocol/go-sdk` (v1.5+, stabil, stdio + Streamable HTTP,
Client-OAuth).
- Verbindungsaufbau parallel beim Start, Timeout 5s pro Server (E-06)
- Tool-Namen werden mit `<server>__` prefixt
- Allowlist aus der Config filtert vor der Registrierung
- Bridge: MCP-Tool → `tool.Tool`. `ForModel` = textuelle Content-Blöcke,
`ForUI` = strukturierter Content
- Server-Ausfall zur Laufzeit: Tool-Aufruf liefert `IsError`, kein Panic, kein
Loop-Abbruch
- Reconnect wird in v1 **nicht** versucht (würde die Tool-Liste ändern, siehe E-06)
### 5.7 Compaction
Trigger: geschätzte Prompt-Tokens > `compact_at` (Default 75%) von `Caps.MaxContext`.
Ablauf:
1. Ältere Hälfte des Pfads auswählen; die letzten N Turns (Default 4) bleiben immer roh.
2. Zusammenfassung mit separatem Prompt erzeugen (gleiches Modell, ggf. konfigurierbar
günstigeres).
3. `NodeSummary` mit `Replaces` = IDs der zusammengefassten Knoten anhängen.
4. Head verschieben.
Die Zusammenfassung muss enthalten: bearbeitete Dateien, getroffene Entscheidungen,
offene Aufgaben, wiederherstellbarer Zustand. Nicht: Prosa über den Gesprächsverlauf.
### 5.8 UI
**Print-Modus (`-p`):** Textdeltas nach stdout, Tool-Aktivität nach stderr, Exit-Code
≠ 0 bei Fehler. `--mode json` gibt stattdessen einen NDJSON-Event-Stream aus — das ist
die Integrationsschnittstelle.
**TUI (Meilenstein 6):** Bubbletea + Lipgloss + Bubbles, `glamour` für Markdown,
`chroma` für Syntax-Highlighting. Komponenten: Message-Liste (virtualisiert), Editor mit
Historie, Statuszeile (Modell, Tokens, Kosten), Modal-Stack für `/model` und `/tree`,
Diff-Overlay.
Kommandos: `/model`, `/tree`, `/branch`, `/compact`, `/context`, `/mcp`, `/skills`,
`/export`, `/clear`.
### 5.9 Fehlerbehandlung & Retry
Angesiedelt im Provider-Layer (E-12). Die Klassifikation ist wichtiger als der Backoff:
| Situation | Verhalten |
|---|---|
| 429 mit `Retry-After` | Header respektieren, bis 3 Versuche |
| 429 ohne Header | Exponentiell 1s/2s/4s + Jitter |
| 500, 502, 503, 504 | Exponentiell, bis 3 Versuche |
| Netzwerk-/Timeout-Fehler vor dem ersten Byte | Retry |
| Stream reißt nach emittierten Blöcken ab | **Kein Retry** — Turn als Fehler beenden |
| Context-Length überschritten | **Kein Retry** — Compaction auslösen, dann einmalig neu |
| 400 (ungültiges Schema o.ä.) | Kein Retry, Fehler durchreichen |
| 401/403 | Kein Retry, Hinweis auf Konfiguration |
Context-Length-Fehler zuverlässig zu erkennen ist unschön, weil jeder Endpoint sie anders
formuliert. Pragmatisch: HTTP 400 plus Substring-Match auf `context length`,
`context_length_exceeded`, `too many tokens`, `maximum context`. Bei Unsicherheit lieber
als normalen Fehler behandeln, als in eine Compaction-Schleife zu laufen — deshalb nach
einer ausgelösten Compaction **genau ein** weiterer Versuch, danach Abbruch.
Alle Retries werden über `slog` protokolliert (2.3) und als UIEvent gemeldet, damit im
interaktiven Betrieb sichtbar ist, warum es hängt.
**Exit-Codes im Print-Modus:** 0 Erfolg, 1 Agent-/Provider-Fehler, 2 Konfigurationsfehler,
3 Abbruch durch Nutzer, 4 Limit erreicht (`max_turns`, Budget).
---
## 6. Ablaufplan
Jeder Meilenstein ist eigenständig lauffähig und testbar.
### M1 — Kern-Loop (Grundlage)
Umfang: `internal/llm` (Typen + Interface), OpenAI-Adapter mit SSE und
Tool-Call-Akkumulation, `internal/tool` mit `read`/`write`/`edit`/`bash`/`glob`/`grep`,
minimaler Loop, Ausgabe über simplen stdout-Renderer, Konfiguration nur über
Umgebungsvariablen.
*Fertig, wenn:* Ein mehrstufiger Task („lies X, ändere Y, führe die Tests aus") läuft
gegen einen echten Endpoint durch. `Ctrl+C` bricht mitten in einem `bash`-Aufruf sauber
ab und tötet die Prozessgruppe. Golden-File-Tests für den Adapter grün.
### M2 — Session-Baum & Persistenz
Umfang: Baum-Modell, JSONL-Store, `PathToHead`, `nub resume`, `nub sessions`,
`/branch`-Äquivalent als CLI-Flag.
*Fertig, wenn:* Eine Session lässt sich fortsetzen; ein Rewind auf einen früheren Knoten
und Weiterarbeit erzeugen einen zweiten Ast, beide bleiben ladbar.
### M3 — Config & Kontext
Umfang: Layered Config (TOML), Endpoint-/Caps-Auflösung, Context Assembler mit
`AGENTS.md`/`REPOMAP.md`/frei konfigurierten Dateien, `walk_up`, Budget-Prüfung,
`nub map`.
*Fertig, wenn:* `/context` zeigt jede geladene Datei mit Herkunft und Token-Kosten.
Budget-Überschreitung erzeugt eine sichtbare Warnung.
### M4 — Tokens & Compaction
Umfang: Schätzung + Kalibrierung, `todo`-Tool, Auto-Compaction als Summary-Node,
`/compact` manuell.
*Fertig, wenn:* Eine künstlich verlängerte Session compactet automatisch, läuft weiter,
und ein Rewind auf einen Knoten *vor* der Compaction funktioniert weiterhin.
### M5 — Skills & MCP
Umfang: Skill-Discovery + Index + `read_skill`, MCP-Client mit Allowlist, Profile,
`/skills` und `/mcp`.
*Fertig, wenn:* Ein Skill wird nachweislich erst bei Bedarf geladen (Token-Delta
messbar). Ein absichtlich kaputter MCP-Server verhindert den Start nicht.
### M6 — TUI
Umfang: Bubbletea-Oberfläche, Streaming-Rendering, Modals, Diff-Overlay, Steering
(`Enter`) und Follow-up (`Alt+Enter`), Kommandos.
*Fertig, wenn:* Die TUI ist ein reiner Consumer des Event-Channels; das Entfernen von
`internal/ui/tui` lässt den Rest kompilieren und `-p` weiterhin funktionieren.
### M7 — Politur
Prompt-Cache gegen echte `usage`-Zahlen verifizieren (Cache-Read-Tokens müssen ab dem
zweiten Turn deutlich steigen), Token-Anzeige in der Statuszeile, `/export`, README.
Distribution bleibt `go install` (Abschnitt 9).
**Reihenfolge-Begründung:** Die TUI ist der teuerste und der einzige vollständig
wegwerfbare Teil. Sie kommt zuletzt, damit die Kern-Schnittstellen vorher unter echter
Nutzung validiert werden — M1M5 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.