188 lines
5 KiB
Go
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()
|
|
}
|