ajhahn.de
← eeco
Go 128 lines
package prompts

// Package prompts is eeco's versioned canonical prompt library: one named,
// embedded text/template per AI-using workflow, the single reviewable source
// of truth for the instruction strings eeco sends to a model. The bodies live
// in templates/*.tmpl; the prompt name is the file name without the extension.
//
// At v1.0.0 the library ships two prompts: GetProjectType (Layer 4 init
// detection) and ManifestSummary (per-directory .ai.json body generation).
// Existing scattered prompts (evolve, understand, ...) migrate as additive
// v1.x slices.

import (
	"bytes"
	"embed"
	"fmt"
	"io/fs"
	"sort"
	"strings"
	"text/template"
)

//go:embed templates/*.tmpl
var templatesFS embed.FS

// Prompt names. Each MUST match a templates/<name>.tmpl file.
const (
	GetProjectType  = "get-project-type"
	ManifestSummary = "manifest-summary"
)

// funcs are the template helpers available to every prompt template.
var funcs = template.FuncMap{"join": strings.Join}

type entry struct {
	raw  string
	tmpl *template.Template
}

// registry is built once at package load. A malformed shipped template panics
// here on purpose — a prompt template is a build-time artifact, not runtime
// input, so a parse failure must surface immediately, not at first render.
var registry = mustLoad()

func mustLoad() map[string]entry {
	files, err := fs.ReadDir(templatesFS, "templates")
	if err != nil {
		panic("prompts: read templates dir: " + err.Error())
	}
	reg := make(map[string]entry, len(files))
	for _, f := range files {
		if f.IsDir() || !strings.HasSuffix(f.Name(), ".tmpl") {
			continue
		}
		body, err := templatesFS.ReadFile("templates/" + f.Name())
		if err != nil {
			panic("prompts: read template " + f.Name() + ": " + err.Error())
		}
		name := strings.TrimSuffix(f.Name(), ".tmpl")
		t, err := template.New(name).Funcs(funcs).Parse(string(body))
		if err != nil {
			panic(fmt.Sprintf("prompts: parse %s: %v", name, err))
		}
		reg[name] = entry{raw: string(body), tmpl: t}
	}
	return reg
}

// Names returns every available prompt name, sorted.
func Names() []string {
	out := make([]string, 0, len(registry))
	for n := range registry {
		out = append(out, n)
	}
	sort.Strings(out)
	return out
}

// Get returns the raw template body for a prompt — the canonical text an
// operator audits via `eeco show prompt <name>`.
func Get(name string) (string, error) {
	e, ok := registry[name]
	if !ok {
		return "", fmt.Errorf("unknown prompt %q", name)
	}
	return e.raw, nil
}

// Render executes a prompt template against data and returns the result.
func Render(name string, data any) (string, error) {
	e, ok := registry[name]
	if !ok {
		return "", fmt.Errorf("unknown prompt %q", name)
	}
	var b bytes.Buffer
	if err := e.tmpl.Execute(&b, data); err != nil {
		return "", fmt.Errorf("render %s: %w", name, err)
	}
	return b.String(), nil
}

// GetProjectTypeData is the render input for the GetProjectType prompt.
type GetProjectTypeData struct {
	Categories  []Category
	Tree        []string
	Description string
}

// Category is one catalog entry rendered into the GetProjectType prompt.
type Category struct {
	Category    string
	Description string
	PickWhen    string
	Dirs        []string
}

// ManifestSummaryData is the render input for the ManifestSummary prompt.
type ManifestSummaryData struct {
	Dir   string
	Items []ManifestItem
}

// ManifestItem is one directory entry rendered into the ManifestSummary prompt.
type ManifestItem struct {
	Path string
	Kind string
}