nub/internal/ctxasm/assemble.go
Tom a97013d876 initial commit
- v 0.1.0 siehe CHANGELOG.md
2026-07-25 11:08:01 +02:00

188 lines
5 KiB
Go

// 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()
}