mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
270 lines
7.0 KiB
Go
270 lines
7.0 KiB
Go
|
|
//go:build ignore
|
||
|
|
|
||
|
|
// prompt-templates.go - Frontmatter-driven prompt templates with model switching.
|
||
|
|
// This extension demonstrates the new bridged SDK APIs:
|
||
|
|
// - Tree navigation for conversation management
|
||
|
|
// - Template parsing with {{variable}} substitution
|
||
|
|
// - Model resolution with fallback chains
|
||
|
|
// - Skill injection
|
||
|
|
//
|
||
|
|
// Usage:
|
||
|
|
// 1. Create ~/.config/kit/prompts/debug.md with frontmatter:
|
||
|
|
// ---
|
||
|
|
// description: Debug Python code
|
||
|
|
// model: claude-sonnet-4-20250514
|
||
|
|
// skill: python
|
||
|
|
// ---
|
||
|
|
// Help me debug this Python code: {{input}}
|
||
|
|
//
|
||
|
|
// 2. In Kit: /debug my_script.py
|
||
|
|
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"kit/ext"
|
||
|
|
)
|
||
|
|
|
||
|
|
// PromptTemplate represents a loaded template with frontmatter
|
||
|
|
type PromptTemplate struct {
|
||
|
|
Name string
|
||
|
|
Description string
|
||
|
|
Model string
|
||
|
|
Skill string
|
||
|
|
Content string
|
||
|
|
Variables []string
|
||
|
|
Path string
|
||
|
|
}
|
||
|
|
|
||
|
|
var (
|
||
|
|
templates = make(map[string]PromptTemplate)
|
||
|
|
templateDir string
|
||
|
|
)
|
||
|
|
|
||
|
|
func Init(api ext.API) {
|
||
|
|
// Determine template directory
|
||
|
|
home, _ := os.UserHomeDir()
|
||
|
|
templateDir = filepath.Join(home, ".config", "kit", "prompts")
|
||
|
|
|
||
|
|
// Ensure directory exists
|
||
|
|
os.MkdirAll(templateDir, 0755)
|
||
|
|
|
||
|
|
// Register commands
|
||
|
|
api.RegisterCommand(ext.CommandDef{
|
||
|
|
Name: "reload-templates",
|
||
|
|
Description: "Reload prompt templates from disk",
|
||
|
|
Execute: func(args string, ctx ext.Context) (string, error) {
|
||
|
|
loadTemplates(ctx)
|
||
|
|
ctx.PrintInfo(fmt.Sprintf("Loaded %d templates from %s", len(templates), templateDir))
|
||
|
|
return "", nil
|
||
|
|
},
|
||
|
|
})
|
||
|
|
|
||
|
|
// Dynamic template commands are registered after loading
|
||
|
|
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||
|
|
loadTemplates(ctx)
|
||
|
|
registerTemplateCommands(api, ctx)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// loadTemplates discovers and loads all template files
|
||
|
|
func loadTemplates(ctx ext.Context) {
|
||
|
|
templates = make(map[string]PromptTemplate)
|
||
|
|
|
||
|
|
entries, err := os.ReadDir(templateDir)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, entry := range entries {
|
||
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
path := filepath.Join(templateDir, entry.Name())
|
||
|
|
tpl, err := loadTemplateFile(path)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
name := strings.TrimSuffix(entry.Name(), ".md")
|
||
|
|
templates[name] = tpl
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// loadTemplateFile parses a template with YAML frontmatter
|
||
|
|
func loadTemplateFile(path string) (PromptTemplate, error) {
|
||
|
|
data, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
return PromptTemplate{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
content := string(data)
|
||
|
|
tpl := PromptTemplate{Path: path}
|
||
|
|
|
||
|
|
// Parse frontmatter
|
||
|
|
if strings.HasPrefix(content, "---") {
|
||
|
|
parts := strings.SplitN(content[3:], "---", 2)
|
||
|
|
if len(parts) == 2 {
|
||
|
|
frontmatter := strings.TrimSpace(parts[0])
|
||
|
|
body := strings.TrimSpace(parts[1])
|
||
|
|
|
||
|
|
// Simple line-by-line frontmatter parsing
|
||
|
|
for _, line := range strings.Split(frontmatter, "\n") {
|
||
|
|
line = strings.TrimSpace(line)
|
||
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
key, value, found := strings.Cut(line, ":")
|
||
|
|
if found {
|
||
|
|
key = strings.TrimSpace(key)
|
||
|
|
value = strings.TrimSpace(value)
|
||
|
|
switch key {
|
||
|
|
case "description":
|
||
|
|
tpl.Description = value
|
||
|
|
case "model":
|
||
|
|
tpl.Model = value
|
||
|
|
case "skill":
|
||
|
|
tpl.Skill = value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
tpl.Content = body
|
||
|
|
} else {
|
||
|
|
tpl.Content = content
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
tpl.Content = content
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse {{variables}} using simple string parsing
|
||
|
|
// (Can't use ctx.ParseTemplate here since we're in Init, not a handler)
|
||
|
|
var vars []string
|
||
|
|
for {
|
||
|
|
start := strings.Index(tpl.Content, "{{")
|
||
|
|
if start == -1 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
end := strings.Index(tpl.Content[start:], "}}")
|
||
|
|
if end == -1 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
varName := strings.TrimSpace(tpl.Content[start+2 : start+end])
|
||
|
|
vars = append(vars, varName)
|
||
|
|
tpl.Content = tpl.Content[:start] + "{{" + varName + "}}" + tpl.Content[start+end+2:]
|
||
|
|
}
|
||
|
|
tpl.Variables = vars
|
||
|
|
|
||
|
|
return tpl, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// registerTemplateCommands dynamically registers commands for each template
|
||
|
|
func registerTemplateCommands(api ext.API, ctx ext.Context) {
|
||
|
|
for name, tpl := range templates {
|
||
|
|
// Skip if already registered (we'd need to track this)
|
||
|
|
tplCopy := tpl // Capture for closure
|
||
|
|
nameCopy := name
|
||
|
|
|
||
|
|
// Build description with metadata
|
||
|
|
desc := tplCopy.Description
|
||
|
|
if desc == "" {
|
||
|
|
desc = fmt.Sprintf("Run %s template", nameCopy)
|
||
|
|
}
|
||
|
|
if tplCopy.Model != "" {
|
||
|
|
desc += fmt.Sprintf(" [%s", tplCopy.Model)
|
||
|
|
if tplCopy.Skill != "" {
|
||
|
|
desc += fmt.Sprintf(" +%s", tplCopy.Skill)
|
||
|
|
}
|
||
|
|
desc += "]"
|
||
|
|
}
|
||
|
|
|
||
|
|
api.RegisterCommand(ext.CommandDef{
|
||
|
|
Name: nameCopy,
|
||
|
|
Description: desc,
|
||
|
|
Execute: func(args string, ctx ext.Context) (string, error) {
|
||
|
|
return executeTemplate(ctx, tplCopy, args)
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// executeTemplate runs a template with the given arguments
|
||
|
|
func executeTemplate(ctx ext.Context, tpl PromptTemplate, args string) (string, error) {
|
||
|
|
// Store original model for restoration
|
||
|
|
originalModel := ctx.Model
|
||
|
|
|
||
|
|
// 1. Resolve and switch model if specified
|
||
|
|
if tpl.Model != "" {
|
||
|
|
// Parse model chain (comma-separated)
|
||
|
|
preferences := strings.Split(tpl.Model, ",")
|
||
|
|
for i := range preferences {
|
||
|
|
preferences[i] = strings.TrimSpace(preferences[i])
|
||
|
|
}
|
||
|
|
|
||
|
|
result := ctx.ResolveModelChain(preferences)
|
||
|
|
if result.Error != "" {
|
||
|
|
ctx.PrintError(fmt.Sprintf("Model resolution failed: %s", result.Error))
|
||
|
|
// Continue with current model
|
||
|
|
} else {
|
||
|
|
ctx.PrintInfo(fmt.Sprintf("Switching to model: %s", result.Model))
|
||
|
|
if err := ctx.SetModel(result.Model); err != nil {
|
||
|
|
ctx.PrintError(fmt.Sprintf("Failed to switch model: %s", err.Error()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Inject skill if specified
|
||
|
|
if tpl.Skill != "" {
|
||
|
|
err := ctx.InjectSkillAsContext(tpl.Skill)
|
||
|
|
if err != "" {
|
||
|
|
ctx.PrintError(fmt.Sprintf("Skill injection failed: %s", err))
|
||
|
|
} else {
|
||
|
|
ctx.PrintInfo(fmt.Sprintf("Injected skill: %s", tpl.Skill))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Parse and render template
|
||
|
|
parsed := ctx.ParseTemplate(tpl.Name, tpl.Content)
|
||
|
|
|
||
|
|
// Build variable map
|
||
|
|
vars := make(map[string]string)
|
||
|
|
|
||
|
|
// Simple argument parsing: first arg is $1 (input), rest is $@
|
||
|
|
if len(parsed.Variables) > 0 {
|
||
|
|
argsList := ctx.SimpleParseArguments(args, len(parsed.Variables))
|
||
|
|
for i, varName := range parsed.Variables {
|
||
|
|
if i < len(parsed.Variables) && i+1 < len(argsList) {
|
||
|
|
vars[varName] = argsList[i+1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// If single variable, use full args
|
||
|
|
if len(parsed.Variables) == 1 && vars[parsed.Variables[0]] == "" {
|
||
|
|
vars[parsed.Variables[0]] = args
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Render with model conditionals
|
||
|
|
content := ctx.RenderWithModelConditionals(tpl.Content)
|
||
|
|
rendered := ctx.RenderTemplate(ext.PromptTemplate{Name: tpl.Name, Content: content, Variables: parsed.Variables}, vars)
|
||
|
|
|
||
|
|
// 4. Send the rendered prompt
|
||
|
|
ctx.SendMessage(rendered)
|
||
|
|
|
||
|
|
// 5. Schedule model restoration after turn completes
|
||
|
|
// We use a goroutine to wait and restore
|
||
|
|
if tpl.Model != "" && originalModel != "" {
|
||
|
|
go func() {
|
||
|
|
// Note: In a real implementation, we'd use OnAgentEnd event
|
||
|
|
// For now, the user can manually switch back
|
||
|
|
ctx.SetStatus("template-mode", fmt.Sprintf("Template: %s (model will restore)", tpl.Name), 20)
|
||
|
|
}()
|
||
|
|
}
|
||
|
|
|
||
|
|
return fmt.Sprintf("Executing template: %s", tpl.Name), nil
|
||
|
|
}
|