mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
Bridge SDK features to extension system: tree navigation, skills, templates, model resolution
This commit bridges 4 categories of internal SDK capabilities to the extension
system, enabling extensions like pi-prompt-template-model to be built with
minimal custom code.
New Extension APIs:
Tree Navigation (Phase 1):
- GetTreeNode, GetCurrentBranch, GetChildren - Navigate conversation tree
- NavigateTo - Branch/fork to specific entries
- SummarizeBranch - LLM-based branch summarization
- CollapseBranch - Fresh context primitive for context management
Skill Loading (Phase 2):
- LoadSkill, LoadSkillsFromDir - Load skill files with YAML frontmatter
- DiscoverSkills - Auto-discover from standard locations
- InjectSkillAsContext, InjectRawSkillAsContext - Pre-load skills
Template Parsing (Phase 3):
- ParseTemplate, RenderTemplate - {{variable}} substitution
- ParseArguments, SimpleParseArguments - CLI-style arg parsing (, , )
- EvaluateModelConditional, RenderWithModelConditionals - Model conditionals
Model Resolution (Phase 4):
- ResolveModelChain - Fallback chain resolution
- GetModelCapabilities - Query model specs
- CheckModelAvailable, GetCurrentProvider, GetCurrentModelID
Files Modified:
- internal/extensions/api.go - New types and Context methods
- internal/extensions/symbols.go - Export to Yaegi
- internal/extensions/runner.go - No-op stubs
- pkg/kit/sessions.go - Tree navigation bridge
- pkg/kit/skills.go - Skill loading bridge
- pkg/kit/template_bridge.go - NEW - Template & model resolution
- cmd/root.go - Wire to extension Context
Examples Added:
- conversation-manager.go - Tree nav, branch collapse, fresh context loops
- prompt-templates.go - Frontmatter templates with model switching
- bridge_demo.go - All new APIs demonstration
Documentation Updated:
- README.md - New capabilities and examples
- www/pages/extensions/capabilities.md - Full API docs
- www/pages/extensions/examples.md - New example category
- skills/kit-extensions/SKILL.md - Extension developer docs
This commit is contained in:
@@ -307,6 +307,12 @@ kit -e examples/extensions/minimal.go
|
||||
- **Themes**: Register and switch color themes via `RegisterTheme`, `SetTheme`, `ListThemes`
|
||||
- **Custom Events**: Inter-extension communication via `EmitCustomEvent`
|
||||
|
||||
**Bridged SDK APIs** (NEW): Extensions can now access internal SDK capabilities:
|
||||
- **Tree Navigation**: Navigate conversation history (`GetTreeNode`, `GetCurrentBranch`, `NavigateTo`), summarize branches (`SummarizeBranch`), and implement fresh context loops (`CollapseBranch`)
|
||||
- **Skill Loading**: Dynamically load and inject skills at runtime (`LoadSkill`, `DiscoverSkills`, `InjectSkillAsContext`)
|
||||
- **Template Parsing**: Parse and render templates with `{{variables}}` (`ParseTemplate`, `RenderTemplate`), parse CLI-style arguments (`ParseArguments`, `SimpleParseArguments`), and evaluate model conditionals (`EvaluateModelConditional`, `RenderWithModelConditionals`)
|
||||
- **Model Resolution**: Resolve model fallback chains (`ResolveModelChain`), query model capabilities (`GetModelCapabilities`, `CheckModelAvailable`), and extract provider/model ID (`GetCurrentProvider`, `GetCurrentModelID`)
|
||||
|
||||
### Extension Examples
|
||||
|
||||
See the `examples/extensions/` directory:
|
||||
@@ -318,6 +324,7 @@ See the `examples/extensions/` directory:
|
||||
- `compact-notify.go` - Notification on compaction
|
||||
- `confirm-destructive.go` - Confirm destructive operations
|
||||
- `context-inject.go` - Inject context into conversations
|
||||
- `conversation-manager.go` - **NEW** Tree navigation, branch summarization, and fresh context loops
|
||||
- `custom-editor-demo.go` - Vim-like modal editor
|
||||
- `dev-reload.go` - Development live-reload
|
||||
- `header-footer-demo.go` - Custom headers and footers
|
||||
@@ -332,6 +339,7 @@ See the `examples/extensions/` directory:
|
||||
- `plan-mode.go` - Read-only planning mode
|
||||
- `project-rules.go` - Project-specific rules
|
||||
- `prompt-demo.go` - Interactive prompts (select/confirm/input)
|
||||
- `prompt-templates.go` - **NEW** Frontmatter-driven templates with model switching and skill injection
|
||||
- `protected-paths.go` - Path protection for sensitive files
|
||||
- `subagent-widget.go` - Multi-agent orchestration with status widget
|
||||
- `subagent-test.go` - Subagent testing utilities
|
||||
|
||||
+240
@@ -1107,6 +1107,126 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tree Navigation API (Phase 1 Bridge)
|
||||
// -------------------------------------------------------------------------
|
||||
GetTreeNode: func(entryID string) *extensions.TreeNode {
|
||||
node := kitInstance.GetTreeNode(entryID)
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
return &extensions.TreeNode{
|
||||
ID: node.ID,
|
||||
ParentID: node.ParentID,
|
||||
Type: node.Type,
|
||||
Role: node.Role,
|
||||
Content: node.Content,
|
||||
Model: node.Model,
|
||||
Provider: node.Provider,
|
||||
Timestamp: node.Timestamp,
|
||||
Children: node.Children,
|
||||
}
|
||||
},
|
||||
GetCurrentBranch: func() []extensions.TreeNode {
|
||||
nodes := kitInstance.GetCurrentBranch()
|
||||
result := make([]extensions.TreeNode, len(nodes))
|
||||
for i, n := range nodes {
|
||||
result[i] = extensions.TreeNode{
|
||||
ID: n.ID,
|
||||
ParentID: n.ParentID,
|
||||
Type: n.Type,
|
||||
Role: n.Role,
|
||||
Content: n.Content,
|
||||
Model: n.Model,
|
||||
Provider: n.Provider,
|
||||
Timestamp: n.Timestamp,
|
||||
Children: n.Children,
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
GetChildren: kitInstance.GetChildren,
|
||||
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
|
||||
err := kitInstance.NavigateTo(entryID)
|
||||
if err != "" {
|
||||
return extensions.TreeNavigationResult{Success: false, Error: err}
|
||||
}
|
||||
return extensions.TreeNavigationResult{Success: true}
|
||||
},
|
||||
SummarizeBranch: kitInstance.SummarizeBranch,
|
||||
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
|
||||
err := kitInstance.CollapseBranch(fromID, toID, summary)
|
||||
if err != "" {
|
||||
return extensions.TreeNavigationResult{Success: false, Error: err}
|
||||
}
|
||||
return extensions.TreeNavigationResult{Success: true}
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Skill Loading API (Phase 2 Bridge)
|
||||
// -------------------------------------------------------------------------
|
||||
LoadSkill: func(path string) (*extensions.Skill, string) {
|
||||
s, err := kitInstance.LoadSkillForExtension(path)
|
||||
return s, err
|
||||
},
|
||||
LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult {
|
||||
return kitInstance.LoadSkillsFromDirForExtension(dir)
|
||||
},
|
||||
DiscoverSkills: func() extensions.SkillLoadResult {
|
||||
skills := kitInstance.DiscoverSkillsForExtension()
|
||||
return extensions.SkillLoadResult{Skills: skills}
|
||||
},
|
||||
InjectSkillAsContext: func(skillName string) string {
|
||||
// Find skill by name
|
||||
skills := kitInstance.DiscoverSkillsForExtension()
|
||||
for _, s := range skills {
|
||||
if s.Name == skillName {
|
||||
// Inject via SendMessage as a system context message
|
||||
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("skill not found: %s", skillName)
|
||||
},
|
||||
InjectRawSkillAsContext: func(path string) string {
|
||||
s, err := kitInstance.LoadSkillForExtension(path)
|
||||
if err != "" {
|
||||
return err
|
||||
}
|
||||
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
|
||||
return ""
|
||||
},
|
||||
GetAvailableSkills: kitInstance.DiscoverSkillsForExtension,
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Template Parsing API (Phase 3 Bridge)
|
||||
// -------------------------------------------------------------------------
|
||||
ParseTemplate: kit.ParseTemplate,
|
||||
RenderTemplate: kit.RenderTemplate,
|
||||
ParseArguments: kit.ParseArguments,
|
||||
SimpleParseArguments: kit.SimpleParseArguments,
|
||||
EvaluateModelConditional: func(condition string) bool {
|
||||
return kit.EvaluateModelConditional(kitInstance.GetExtensionContext().Model, condition)
|
||||
},
|
||||
RenderWithModelConditionals: func(content string) string {
|
||||
return kit.RenderWithModelConditionals(content, kitInstance.GetExtensionContext().Model)
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Model Resolution API (Phase 4 Bridge)
|
||||
// -------------------------------------------------------------------------
|
||||
ResolveModelChain: kit.ResolveModelChain,
|
||||
GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) {
|
||||
return kit.GetModelCapabilities(model)
|
||||
},
|
||||
CheckModelAvailable: kit.CheckModelAvailable,
|
||||
GetCurrentProvider: func() string {
|
||||
return kit.GetCurrentProvider(kitInstance.GetExtensionContext().Model)
|
||||
},
|
||||
GetCurrentModelID: func() string {
|
||||
return kit.GetCurrentModelID(kitInstance.GetExtensionContext().Model)
|
||||
},
|
||||
})
|
||||
kitInstance.EmitSessionStart()
|
||||
|
||||
@@ -1252,6 +1372,126 @@ func runNormalMode(ctx context.Context) error {
|
||||
}
|
||||
return nil, extResult, err
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tree Navigation API (Phase 1 Bridge) - Second Context
|
||||
// -------------------------------------------------------------------------
|
||||
GetTreeNode: func(entryID string) *extensions.TreeNode {
|
||||
node := kitInstance.GetTreeNode(entryID)
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
return &extensions.TreeNode{
|
||||
ID: node.ID,
|
||||
ParentID: node.ParentID,
|
||||
Type: node.Type,
|
||||
Role: node.Role,
|
||||
Content: node.Content,
|
||||
Model: node.Model,
|
||||
Provider: node.Provider,
|
||||
Timestamp: node.Timestamp,
|
||||
Children: node.Children,
|
||||
}
|
||||
},
|
||||
GetCurrentBranch: func() []extensions.TreeNode {
|
||||
nodes := kitInstance.GetCurrentBranch()
|
||||
result := make([]extensions.TreeNode, len(nodes))
|
||||
for i, n := range nodes {
|
||||
result[i] = extensions.TreeNode{
|
||||
ID: n.ID,
|
||||
ParentID: n.ParentID,
|
||||
Type: n.Type,
|
||||
Role: n.Role,
|
||||
Content: n.Content,
|
||||
Model: n.Model,
|
||||
Provider: n.Provider,
|
||||
Timestamp: n.Timestamp,
|
||||
Children: n.Children,
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
GetChildren: kitInstance.GetChildren,
|
||||
NavigateTo: func(entryID string) extensions.TreeNavigationResult {
|
||||
err := kitInstance.NavigateTo(entryID)
|
||||
if err != "" {
|
||||
return extensions.TreeNavigationResult{Success: false, Error: err}
|
||||
}
|
||||
return extensions.TreeNavigationResult{Success: true}
|
||||
},
|
||||
SummarizeBranch: kitInstance.SummarizeBranch,
|
||||
CollapseBranch: func(fromID, toID, summary string) extensions.TreeNavigationResult {
|
||||
err := kitInstance.CollapseBranch(fromID, toID, summary)
|
||||
if err != "" {
|
||||
return extensions.TreeNavigationResult{Success: false, Error: err}
|
||||
}
|
||||
return extensions.TreeNavigationResult{Success: true}
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Skill Loading API (Phase 2 Bridge) - Second Context
|
||||
// -------------------------------------------------------------------------
|
||||
LoadSkill: func(path string) (*extensions.Skill, string) {
|
||||
s, err := kitInstance.LoadSkillForExtension(path)
|
||||
return s, err
|
||||
},
|
||||
LoadSkillsFromDir: func(dir string) extensions.SkillLoadResult {
|
||||
return kitInstance.LoadSkillsFromDirForExtension(dir)
|
||||
},
|
||||
DiscoverSkills: func() extensions.SkillLoadResult {
|
||||
skills := kitInstance.DiscoverSkillsForExtension()
|
||||
return extensions.SkillLoadResult{Skills: skills}
|
||||
},
|
||||
InjectSkillAsContext: func(skillName string) string {
|
||||
skills := kitInstance.DiscoverSkillsForExtension()
|
||||
for _, s := range skills {
|
||||
if s.Name == skillName {
|
||||
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("skill not found: %s", skillName)
|
||||
},
|
||||
InjectRawSkillAsContext: func(path string) string {
|
||||
s, err := kitInstance.LoadSkillForExtension(path)
|
||||
if err != "" {
|
||||
return err
|
||||
}
|
||||
appInstance.Run(fmt.Sprintf("<skill name=%q>\n%s\n</skill>", s.Name, s.Content))
|
||||
return ""
|
||||
},
|
||||
GetAvailableSkills: func() []extensions.Skill {
|
||||
return kitInstance.DiscoverSkillsForExtension()
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Template Parsing API (Phase 3 Bridge) - Second Context
|
||||
// -------------------------------------------------------------------------
|
||||
ParseTemplate: kit.ParseTemplate,
|
||||
RenderTemplate: kit.RenderTemplate,
|
||||
ParseArguments: kit.ParseArguments,
|
||||
SimpleParseArguments: kit.SimpleParseArguments,
|
||||
EvaluateModelConditional: func(condition string) bool {
|
||||
return kit.EvaluateModelConditional(kitInstance.GetExtensionContext().Model, condition)
|
||||
},
|
||||
RenderWithModelConditionals: func(content string) string {
|
||||
return kit.RenderWithModelConditionals(content, kitInstance.GetExtensionContext().Model)
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Model Resolution API (Phase 4 Bridge) - Second Context
|
||||
// -------------------------------------------------------------------------
|
||||
ResolveModelChain: kit.ResolveModelChain,
|
||||
GetModelCapabilities: func(model string) (extensions.ModelCapabilities, string) {
|
||||
return kit.GetModelCapabilities(model)
|
||||
},
|
||||
CheckModelAvailable: kit.CheckModelAvailable,
|
||||
GetCurrentProvider: func() string {
|
||||
return kit.GetCurrentProvider(kitInstance.GetExtensionContext().Model)
|
||||
},
|
||||
GetCurrentModelID: func() string {
|
||||
return kit.GetCurrentModelID(kitInstance.GetExtensionContext().Model)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//go:build ignore
|
||||
|
||||
// bridge_demo.go - Demonstrates the new bridged SDK APIs for extensions.
|
||||
// This extension showcases tree navigation, skill loading, template parsing,
|
||||
// and model resolution capabilities.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
var (
|
||||
discoveredSkills []ext.Skill
|
||||
currentBranch []ext.TreeNode
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// Register /tree-info command to demonstrate tree navigation
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "tree-info",
|
||||
Description: "Show current conversation tree information",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
branch := ctx.GetCurrentBranch()
|
||||
info := fmt.Sprintf("Current branch has %d nodes:\n", len(branch))
|
||||
for i, node := range branch {
|
||||
info += fmt.Sprintf(" [%d] %s (%s): %s...\n", i, node.Type, node.ID[:8], truncate(node.Content, 40))
|
||||
}
|
||||
ctx.PrintInfo(info)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Register /discover-skills command
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "discover-skills",
|
||||
Description: "Discover and list available skills",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
result := ctx.DiscoverSkills()
|
||||
if result.Error != "" {
|
||||
return "", fmt.Errorf("discovery failed: %s", result.Error)
|
||||
}
|
||||
discoveredSkills = result.Skills
|
||||
|
||||
info := fmt.Sprintf("Discovered %d skills:\n", len(result.Skills))
|
||||
for _, s := range result.Skills {
|
||||
info += fmt.Sprintf(" - %s: %s\n", s.Name, s.Description)
|
||||
}
|
||||
ctx.PrintInfo(info)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Register /parse-template command
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "parse-template",
|
||||
Description: "Parse a template and show extracted variables",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if args == "" {
|
||||
args = "Hello {{name}}, welcome to {{place}}!"
|
||||
}
|
||||
tpl := ctx.ParseTemplate("demo", args)
|
||||
info := fmt.Sprintf("Template: %s\nVariables: %v", tpl.Content, tpl.Variables)
|
||||
ctx.PrintInfo(info)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Register /render-template command
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "render-template",
|
||||
Description: "Render a template with variables (usage: /render-template name=John place=Kit)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
tpl := ctx.ParseTemplate("demo", "Hello {{name}}, welcome to {{place}}!")
|
||||
vars := ctx.ParseArguments(args, ext.ArgumentPattern{
|
||||
Flags: map[string]string{"name": "name", "place": "place"},
|
||||
})
|
||||
rendered := ctx.RenderTemplate(tpl, vars.Vars)
|
||||
ctx.PrintInfo("Rendered: " + rendered)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Register /check-model command
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "check-model",
|
||||
Description: "Check model capabilities and availability",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
model := args
|
||||
if model == "" {
|
||||
model = ctx.Model
|
||||
}
|
||||
|
||||
available := ctx.CheckModelAvailable(model)
|
||||
caps, err := ctx.GetModelCapabilities(model)
|
||||
|
||||
info := fmt.Sprintf("Model: %s\n", model)
|
||||
info += fmt.Sprintf("Available: %v\n", available)
|
||||
if err == "" {
|
||||
info += fmt.Sprintf("Provider: %s\n", caps.Provider)
|
||||
info += fmt.Sprintf("Context Limit: %d\n", caps.ContextLimit)
|
||||
info += fmt.Sprintf("Reasoning: %v\n", caps.Reasoning)
|
||||
} else {
|
||||
info += fmt.Sprintf("Error: %s\n", err)
|
||||
}
|
||||
ctx.PrintInfo(info)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Register /resolve-chain command
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "resolve-chain",
|
||||
Description: "Resolve a model chain (usage: /resolve-chain claude-opus,gpt-4o,claude-sonnet)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if args == "" {
|
||||
args = "anthropic/claude-opus-4,anthropic/claude-sonnet-4,openai/gpt-4o"
|
||||
}
|
||||
prefs := ctx.SimpleParseArguments(args, 1)
|
||||
chain := []string{}
|
||||
if len(prefs) > 1 {
|
||||
// Split the first arg by comma
|
||||
for _, p := range strings.Split(prefs[1], ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
chain = append(chain, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := ctx.ResolveModelChain(chain)
|
||||
info, _ := json.MarshalIndent(result, "", " ")
|
||||
ctx.PrintInfo("Resolution Result:\n" + string(info))
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// Register /test-conditional command
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "test-conditional",
|
||||
Description: "Test model conditional rendering",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
content := `<if-model is="claude-*">This is for Claude models<else>This is for other models</if-model>`
|
||||
rendered := ctx.RenderWithModelConditionals(content)
|
||||
ctx.PrintInfo("Input: " + content)
|
||||
ctx.PrintInfo("Output: " + rendered)
|
||||
ctx.PrintInfo(fmt.Sprintf("Current model matches 'claude-*': %v", ctx.EvaluateModelConditional("claude-*")))
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// OnSessionStart: discover skills automatically
|
||||
api.OnSessionStart(func(e ext.SessionStartEvent, ctx ext.Context) {
|
||||
result := ctx.DiscoverSkills()
|
||||
if result.Error == "" && len(result.Skills) > 0 {
|
||||
discoveredSkills = result.Skills
|
||||
ctx.SetStatus("bridge-demo", fmt.Sprintf("%d skills", len(result.Skills)), 50)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//go:build ignore
|
||||
|
||||
// conversation-manager.go - Advanced conversation tree navigation and management.
|
||||
// This extension demonstrates:
|
||||
// - Tree navigation (GetTreeNode, GetCurrentBranch, NavigateTo)
|
||||
// - Branch summarization and collapsing
|
||||
// - Interactive tree exploration
|
||||
//
|
||||
// Commands:
|
||||
// /tree - Show conversation tree structure
|
||||
// /branch - Show current branch path
|
||||
// /goto <entry-id> - Navigate to a specific entry
|
||||
// /summarize <n> - Summarize last N messages
|
||||
// /fresh-context - Collapse branch and start fresh
|
||||
// /loop <n> <prompt> - Execute prompt N times with fresh context each iteration
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kit/ext"
|
||||
)
|
||||
|
||||
var (
|
||||
loopActive bool
|
||||
loopCount int
|
||||
loopCurrent int
|
||||
loopPrompt string
|
||||
loopStartNode string
|
||||
)
|
||||
|
||||
func Init(api ext.API) {
|
||||
// /tree - Show tree structure
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "tree",
|
||||
Description: "Show conversation tree structure",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
showTree(ctx)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /branch - Show current branch
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "branch",
|
||||
Description: "Show current conversation branch",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
showBranch(ctx)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /goto - Navigate to entry
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "goto",
|
||||
Description: "Navigate to a specific entry ID (usage: /goto <entry-id>)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if args == "" {
|
||||
ctx.PrintError("Usage: /goto <entry-id>")
|
||||
return "", nil
|
||||
}
|
||||
result := ctx.NavigateTo(args)
|
||||
if !result.Success {
|
||||
ctx.PrintError(fmt.Sprintf("Navigation failed: %s", result.Error))
|
||||
return "", nil
|
||||
}
|
||||
ctx.PrintInfo(fmt.Sprintf("Navigated to entry: %s", args))
|
||||
|
||||
// Show the node we navigated to
|
||||
node := ctx.GetTreeNode(args)
|
||||
if node != nil {
|
||||
ctx.PrintInfo(fmt.Sprintf("Entry type: %s, Role: %s", node.Type, node.Role))
|
||||
}
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /summarize - Summarize recent messages
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "summarize",
|
||||
Description: "Summarize last N messages (usage: /summarize [n=5])",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
n := 5
|
||||
if args != "" {
|
||||
if parsed, err := strconv.Atoi(args); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
|
||||
branch := ctx.GetCurrentBranch()
|
||||
if len(branch) < 2 {
|
||||
ctx.PrintError("Not enough messages to summarize")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Find range to summarize
|
||||
startIdx := len(branch) - n - 1
|
||||
if startIdx < 0 {
|
||||
startIdx = 0
|
||||
}
|
||||
endIdx := len(branch) - 1
|
||||
|
||||
fromID := branch[startIdx].ID
|
||||
toID := branch[endIdx].ID
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Summarizing messages %d to %d...", startIdx, endIdx))
|
||||
summary := ctx.SummarizeBranch(fromID, toID)
|
||||
|
||||
if summary == "" {
|
||||
ctx.PrintError("Failed to generate summary")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: summary,
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: "conversation-manager · Summary",
|
||||
})
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /fresh-context - Collapse and restart
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "fresh-context",
|
||||
Description: "Collapse conversation to summary and start fresh",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
branch := ctx.GetCurrentBranch()
|
||||
if len(branch) < 3 {
|
||||
ctx.PrintError("Not enough context to collapse")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Keep first message (system), summarize rest
|
||||
fromID := branch[1].ID
|
||||
toID := branch[len(branch)-1].ID
|
||||
|
||||
ctx.PrintInfo("Generating summary for context collapse...")
|
||||
summary := ctx.SummarizeBranch(fromID, toID)
|
||||
|
||||
if summary == "" {
|
||||
ctx.PrintError("Failed to generate summary")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Collapse the branch
|
||||
result := ctx.CollapseBranch(fromID, toID, summary)
|
||||
if !result.Success {
|
||||
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ctx.PrintInfo("Context collapsed. Starting fresh with summary.")
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: summary,
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "conversation-manager · Collapsed Context",
|
||||
})
|
||||
|
||||
// Set a widget showing we're in fresh mode
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "fresh-context",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: "🌱 Fresh Context Mode - Previous conversation collapsed"},
|
||||
Style: ext.WidgetStyle{BorderColor: "#a6e3a1"},
|
||||
})
|
||||
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// /loop - Execute with fresh context each iteration
|
||||
api.RegisterCommand(ext.CommandDef{
|
||||
Name: "loop",
|
||||
Description: "Execute prompt N times with fresh context (usage: /loop 5 analyze this code)",
|
||||
Execute: func(args string, ctx ext.Context) (string, error) {
|
||||
if loopActive {
|
||||
ctx.PrintError("Loop already in progress. Wait for completion.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Parse arguments
|
||||
parts := strings.SplitN(args, " ", 2)
|
||||
if len(parts) < 2 {
|
||||
ctx.PrintError("Usage: /loop <count> <prompt>")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
count, err := strconv.Atoi(parts[0])
|
||||
if err != nil || count <= 0 || count > 10 {
|
||||
ctx.PrintError("Invalid count (must be 1-10)")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
loopCount = count
|
||||
loopCurrent = 0
|
||||
loopPrompt = parts[1]
|
||||
loopActive = true
|
||||
|
||||
// Store current branch position
|
||||
branch := ctx.GetCurrentBranch()
|
||||
if len(branch) > 0 {
|
||||
loopStartNode = branch[len(branch)-1].ID
|
||||
}
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Starting loop: %d iterations", loopCount))
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "loop-progress",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: 0/%d - %s", loopCount, loopPrompt)},
|
||||
Style: ext.WidgetStyle{BorderColor: "#fab387"},
|
||||
})
|
||||
|
||||
// Start first iteration
|
||||
executeLoopIteration(ctx)
|
||||
return "", nil
|
||||
},
|
||||
})
|
||||
|
||||
// OnAgentEnd handles loop continuation
|
||||
api.OnAgentEnd(func(e ext.AgentEndEvent, ctx ext.Context) {
|
||||
if !loopActive {
|
||||
return
|
||||
}
|
||||
|
||||
loopCurrent++
|
||||
|
||||
if loopCurrent >= loopCount {
|
||||
// Loop complete
|
||||
loopActive = false
|
||||
ctx.RemoveWidget("loop-progress")
|
||||
ctx.PrintInfo(fmt.Sprintf("✅ Loop complete: %d/%d iterations", loopCurrent, loopCount))
|
||||
|
||||
// Show final summary
|
||||
branch := ctx.GetCurrentBranch()
|
||||
if len(branch) > 0 && loopStartNode != "" {
|
||||
summary := ctx.SummarizeBranch(loopStartNode, branch[len(branch)-1].ID)
|
||||
if summary != "" {
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: summary,
|
||||
BorderColor: "#a6e3a1",
|
||||
Subtitle: "conversation-manager · Loop Summary",
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update progress
|
||||
ctx.SetWidget(ext.WidgetConfig{
|
||||
ID: "loop-progress",
|
||||
Placement: ext.WidgetAbove,
|
||||
Content: ext.WidgetContent{Text: fmt.Sprintf("🔄 Loop: %d/%d - %s", loopCurrent, loopCount, loopPrompt)},
|
||||
Style: ext.WidgetStyle{BorderColor: "#fab387"},
|
||||
})
|
||||
|
||||
// Collapse previous iteration for fresh context
|
||||
branch := ctx.GetCurrentBranch()
|
||||
if len(branch) >= 2 {
|
||||
// Find the user messages (look for the one before the last assistant message)
|
||||
// We want to collapse from the user message that started this iteration
|
||||
// to the last assistant response
|
||||
var collapseStartIdx = -1
|
||||
for i := len(branch) - 1; i >= 0; i-- {
|
||||
if branch[i].Role == "assistant" {
|
||||
// Found the last assistant message, now find the user message before it
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
if branch[j].Role == "user" {
|
||||
collapseStartIdx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if collapseStartIdx >= 0 {
|
||||
fromID := branch[collapseStartIdx].ID
|
||||
toID := branch[len(branch)-1].ID
|
||||
|
||||
ctx.PrintInfo(fmt.Sprintf("Collapsing iteration %d for fresh context...", loopCurrent))
|
||||
summary := ctx.SummarizeBranch(fromID, toID)
|
||||
if summary != "" {
|
||||
result := ctx.CollapseBranch(fromID, toID, summary)
|
||||
if result.Success {
|
||||
ctx.PrintInfo("Context collapsed successfully")
|
||||
} else {
|
||||
ctx.PrintError(fmt.Sprintf("Collapse failed: %s", result.Error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to let UI update
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Trigger next iteration
|
||||
executeLoopIteration(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// showTree displays the conversation tree structure
|
||||
func showTree(ctx ext.Context) {
|
||||
branch := ctx.GetCurrentBranch()
|
||||
if len(branch) == 0 {
|
||||
ctx.PrintInfo("Tree is empty")
|
||||
return
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString(fmt.Sprintf("Conversation Tree (%d nodes):\n\n", len(branch)))
|
||||
|
||||
for i, node := range branch {
|
||||
prefix := " "
|
||||
if i == len(branch)-1 {
|
||||
prefix = "▶ " // Current node
|
||||
} else {
|
||||
prefix = " "
|
||||
}
|
||||
|
||||
roleIcon := "💬"
|
||||
switch node.Role {
|
||||
case "user":
|
||||
roleIcon = "👤"
|
||||
case "assistant":
|
||||
roleIcon = "🤖"
|
||||
case "system":
|
||||
roleIcon = "⚙️"
|
||||
}
|
||||
|
||||
content := truncate(node.Content, 50)
|
||||
if node.Type == "branch_summary" {
|
||||
roleIcon = "📋"
|
||||
content = "[Summary] " + truncate(node.Content, 40)
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf("%s%s %s: %s (%s...)\n", prefix, roleIcon, node.Role, node.ID[:8], content))
|
||||
|
||||
// Show children count if any
|
||||
children := ctx.GetChildren(node.ID)
|
||||
if len(children) > 0 {
|
||||
output.WriteString(fmt.Sprintf(" └─ %d branch(es)\n", len(children)))
|
||||
}
|
||||
}
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: output.String(),
|
||||
BorderColor: "#89b4fa",
|
||||
Subtitle: "conversation-manager · Tree View",
|
||||
})
|
||||
}
|
||||
|
||||
// showBranch displays the current branch path
|
||||
func showBranch(ctx ext.Context) {
|
||||
branch := ctx.GetCurrentBranch()
|
||||
if len(branch) == 0 {
|
||||
ctx.PrintInfo("No active branch")
|
||||
return
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString(fmt.Sprintf("Current Branch (%d nodes from root to leaf):\n\n", len(branch)))
|
||||
|
||||
for i, node := range branch {
|
||||
marker := " "
|
||||
if i == len(branch)-1 {
|
||||
marker = "▶ " // Current leaf
|
||||
}
|
||||
|
||||
output.WriteString(fmt.Sprintf("%s[%d] %s (%s): %s\n",
|
||||
marker, i, node.Type, node.ID[:8], truncate(node.Content, 40)))
|
||||
}
|
||||
|
||||
// Show current node details
|
||||
leaf := branch[len(branch)-1]
|
||||
output.WriteString(fmt.Sprintf("\nCurrent Leaf:\n"))
|
||||
output.WriteString(fmt.Sprintf(" ID: %s\n", leaf.ID))
|
||||
output.WriteString(fmt.Sprintf(" Type: %s\n", leaf.Type))
|
||||
output.WriteString(fmt.Sprintf(" Role: %s\n", leaf.Role))
|
||||
output.WriteString(fmt.Sprintf(" Model: %s\n", leaf.Model))
|
||||
output.WriteString(fmt.Sprintf(" Children: %d\n", len(leaf.Children)))
|
||||
|
||||
ctx.PrintBlock(ext.PrintBlockOpts{
|
||||
Text: output.String(),
|
||||
BorderColor: "#cba6f7",
|
||||
Subtitle: "conversation-manager · Branch View",
|
||||
})
|
||||
}
|
||||
|
||||
// executeLoopIteration triggers the next loop iteration
|
||||
func executeLoopIteration(ctx ext.Context) {
|
||||
iterationPrompt := fmt.Sprintf("[%d/%d] %s", loopCurrent+1, loopCount, loopPrompt)
|
||||
ctx.SendMessage(iterationPrompt)
|
||||
}
|
||||
|
||||
// truncate helper
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//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
|
||||
}
|
||||
@@ -572,6 +572,102 @@ type Context struct {
|
||||
// })
|
||||
// // handle.Kill() to cancel, handle.Wait() to block
|
||||
SpawnSubagent func(SubagentConfig) (*SubagentHandle, *SubagentResult, error)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tree Navigation API (Phase 1 Bridge)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// GetTreeNode returns a node by ID with full metadata and children.
|
||||
// Returns nil if entry not found.
|
||||
GetTreeNode func(entryID string) *TreeNode
|
||||
|
||||
// GetCurrentBranch returns the path from root to current leaf.
|
||||
// Each node contains full metadata (unlike GetMessages which flattens).
|
||||
GetCurrentBranch func() []TreeNode
|
||||
|
||||
// GetChildren returns direct child IDs of an entry.
|
||||
GetChildren func(entryID string) []string
|
||||
|
||||
// NavigateTo branches/forks the session to the specified entry ID.
|
||||
// Equivalent to SDK's Branch() but for extensions.
|
||||
NavigateTo func(entryID string) TreeNavigationResult
|
||||
|
||||
// SummarizeBranch uses LLM to summarize a branch range.
|
||||
// Returns summary text or error string (empty if success).
|
||||
SummarizeBranch func(fromID, toID string) string
|
||||
|
||||
// CollapseBranch replaces a branch range with a summary entry.
|
||||
// This is the "fresh context" primitive for context window management.
|
||||
CollapseBranch func(fromID, toID, summary string) TreeNavigationResult
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Skill Loading API (Phase 2 Bridge)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// LoadSkill loads a single skill file from path.
|
||||
// Parses YAML frontmatter, returns skill with content ready for injection.
|
||||
LoadSkill func(path string) (*Skill, string)
|
||||
|
||||
// LoadSkillsFromDir discovers and loads all skills from a directory.
|
||||
LoadSkillsFromDir func(dir string) SkillLoadResult
|
||||
|
||||
// DiscoverSkills finds skills in standard locations.
|
||||
// Checks ~/.config/kit/skills/, .kit/skills/, .agents/skills/
|
||||
DiscoverSkills func() SkillLoadResult
|
||||
|
||||
// InjectSkillAsContext sends a skill's content as a system message.
|
||||
// Looks up skill by name from discovered skills.
|
||||
InjectSkillAsContext func(skillName string) string
|
||||
|
||||
// InjectRawSkillAsContext loads and immediately injects a skill file.
|
||||
InjectRawSkillAsContext func(path string) string
|
||||
|
||||
// GetAvailableSkills returns all currently loaded/discovered skills.
|
||||
GetAvailableSkills func() []Skill
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Template Parsing API (Phase 3 Bridge)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// ParseTemplate extracts {{variables}} from template content.
|
||||
ParseTemplate func(name, content string) PromptTemplate
|
||||
|
||||
// RenderTemplate substitutes variables into template content.
|
||||
RenderTemplate func(tpl PromptTemplate, vars map[string]string) string
|
||||
|
||||
// ParseArguments parses command-line style arguments.
|
||||
ParseArguments func(input string, pattern ArgumentPattern) ParseResult
|
||||
|
||||
// SimpleParseArguments parses $1, $2, $@ style arguments.
|
||||
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
|
||||
SimpleParseArguments func(input string, count int) []string
|
||||
|
||||
// EvaluateModelConditional checks if condition matches current model.
|
||||
// Condition supports wildcards: * matches any, ? matches single char.
|
||||
EvaluateModelConditional func(condition string) bool
|
||||
|
||||
// RenderWithModelConditionals processes <if-model> blocks in content.
|
||||
RenderWithModelConditionals func(content string) string
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Model Resolution API (Phase 4 Bridge)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// ResolveModelChain attempts each model in order until one is available.
|
||||
ResolveModelChain func(preferences []string) ModelResolutionResult
|
||||
|
||||
// GetModelCapabilities returns capabilities for a specific model.
|
||||
// If model is empty, uses current model.
|
||||
GetModelCapabilities func(model string) (ModelCapabilities, string)
|
||||
|
||||
// CheckModelAvailable verifies if a model string is valid.
|
||||
CheckModelAvailable func(model string) bool
|
||||
|
||||
// GetCurrentProvider returns just the provider part of current model.
|
||||
GetCurrentProvider func() string
|
||||
|
||||
// GetCurrentModelID returns just the model ID part of current model.
|
||||
GetCurrentModelID func() string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -598,6 +694,148 @@ type SessionMessage struct {
|
||||
Timestamp string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree navigation types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TreeNode represents a node in the session tree for navigation.
|
||||
// Extensions use this to traverse conversation history and implement
|
||||
// features like "fresh context" loops and branch summarization.
|
||||
type TreeNode struct {
|
||||
// ID is the unique entry identifier.
|
||||
ID string
|
||||
// ParentID links this entry to its parent (empty if root).
|
||||
ParentID string
|
||||
// Type is the entry type: "message", "branch_summary", "model_change", "extension_data", "tool_execution".
|
||||
Type string
|
||||
// Role is the message role for message entries: "user", "assistant", "system", "tool".
|
||||
Role string
|
||||
// Content is the text content or summary.
|
||||
Content string
|
||||
// Model is the model that generated this (for assistant messages).
|
||||
Model string
|
||||
// Provider is the provider used.
|
||||
Provider string
|
||||
// Timestamp is the RFC3339-formatted creation time.
|
||||
Timestamp string
|
||||
// Children is the list of child entry IDs for tree traversal.
|
||||
Children []string
|
||||
}
|
||||
|
||||
// TreeNavigationResult reports success or failure of tree operations.
|
||||
type TreeNavigationResult struct {
|
||||
// Success is true if the operation completed.
|
||||
Success bool
|
||||
// Error describes what went wrong (empty if success).
|
||||
Error string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Skill represents a loaded skill file with parsed YAML frontmatter.
|
||||
type Skill struct {
|
||||
// Name is the human-readable identifier.
|
||||
Name string
|
||||
// Description summarizes what this skill provides.
|
||||
Description string
|
||||
// Content is the markdown body (frontmatter stripped).
|
||||
Content string
|
||||
// Path is the absolute filesystem path.
|
||||
Path string
|
||||
// Tags are optional labels for categorization.
|
||||
Tags []string
|
||||
// When controls automatic inclusion: "always", "on-demand", or file-glob.
|
||||
When string
|
||||
}
|
||||
|
||||
// SkillLoadResult reports skills loaded from a directory.
|
||||
type SkillLoadResult struct {
|
||||
// Skills is the list of loaded skills.
|
||||
Skills []Skill
|
||||
// Error describes loading failures (empty if success).
|
||||
Error string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template parsing types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PromptTemplate represents a parsed template with variable placeholders.
|
||||
type PromptTemplate struct {
|
||||
// Name is the template identifier.
|
||||
Name string
|
||||
// Content is the original template content.
|
||||
Content string
|
||||
// Variables are the extracted {{variable}} names.
|
||||
Variables []string
|
||||
}
|
||||
|
||||
// ArgumentPattern defines how to parse command arguments.
|
||||
type ArgumentPattern struct {
|
||||
// Positional names for $1, $2, etc.
|
||||
Positional []string
|
||||
// Rest is the variable name for $@ (all remaining).
|
||||
Rest string
|
||||
// Flags maps flag names to variable names (e.g., "--loop" -> "loop").
|
||||
Flags map[string]string
|
||||
}
|
||||
|
||||
// ParseResult reports argument parsing outcome.
|
||||
type ParseResult struct {
|
||||
// Vars maps variable names to values for positional args.
|
||||
Vars map[string]string
|
||||
// Flags maps flag names to values.
|
||||
Flags map[string]string
|
||||
// Rest is remaining unparsed text.
|
||||
Rest string
|
||||
// Error describes parsing failures (empty if success).
|
||||
Error string
|
||||
}
|
||||
|
||||
// ModelConditional represents an <if-model> block for evaluation.
|
||||
type ModelConditional struct {
|
||||
// Condition is the model pattern (e.g., "claude-*", "anthropic/*").
|
||||
Condition string
|
||||
// Content is rendered if condition matches.
|
||||
Content string
|
||||
// Else is rendered if condition doesn't match.
|
||||
Else string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model resolution types (exposed to Yaegi — concrete structs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ModelCapabilities describes what a model supports.
|
||||
type ModelCapabilities struct {
|
||||
// Provider is the provider ID (e.g., "anthropic").
|
||||
Provider string
|
||||
// ModelID is the model identifier (e.g., "claude-sonnet-4-20250929").
|
||||
ModelID string
|
||||
// ContextLimit is the maximum context window in tokens.
|
||||
ContextLimit int
|
||||
// OutputLimit is the maximum output tokens.
|
||||
OutputLimit int
|
||||
// Reasoning indicates if the model supports reasoning/thinking.
|
||||
Reasoning bool
|
||||
// Streaming indicates if the model supports streaming.
|
||||
Streaming bool
|
||||
}
|
||||
|
||||
// ModelResolutionResult reports model chain resolution outcome.
|
||||
type ModelResolutionResult struct {
|
||||
// Model is the selected model in "provider/model" format.
|
||||
Model string
|
||||
// Capabilities describes the selected model.
|
||||
Capabilities ModelCapabilities
|
||||
// Attempted lists models tried before success.
|
||||
Attempted []string
|
||||
// Error describes resolution failures (empty if success).
|
||||
Error string
|
||||
}
|
||||
|
||||
// ExtensionEntry represents persisted extension data stored in the session.
|
||||
// Extensions use AppendEntry to save custom state and GetEntries to retrieve
|
||||
// it on session resume.
|
||||
|
||||
@@ -214,6 +214,102 @@ func normalizeContext(ctx Context) Context {
|
||||
return nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tree Navigation API no-ops
|
||||
// -------------------------------------------------------------------------
|
||||
if ctx.GetTreeNode == nil {
|
||||
ctx.GetTreeNode = func(string) *TreeNode { return nil }
|
||||
}
|
||||
if ctx.GetCurrentBranch == nil {
|
||||
ctx.GetCurrentBranch = func() []TreeNode { return nil }
|
||||
}
|
||||
if ctx.GetChildren == nil {
|
||||
ctx.GetChildren = func(string) []string { return nil }
|
||||
}
|
||||
if ctx.NavigateTo == nil {
|
||||
ctx.NavigateTo = func(string) TreeNavigationResult {
|
||||
return TreeNavigationResult{Success: false, Error: "not implemented"}
|
||||
}
|
||||
}
|
||||
if ctx.SummarizeBranch == nil {
|
||||
ctx.SummarizeBranch = func(string, string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if ctx.CollapseBranch == nil {
|
||||
ctx.CollapseBranch = func(string, string, string) TreeNavigationResult {
|
||||
return TreeNavigationResult{Success: false, Error: "not implemented"}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Skill Loading API no-ops
|
||||
// -------------------------------------------------------------------------
|
||||
if ctx.LoadSkill == nil {
|
||||
ctx.LoadSkill = func(string) (*Skill, string) { return nil, "" }
|
||||
}
|
||||
if ctx.LoadSkillsFromDir == nil {
|
||||
ctx.LoadSkillsFromDir = func(string) SkillLoadResult { return SkillLoadResult{} }
|
||||
}
|
||||
if ctx.DiscoverSkills == nil {
|
||||
ctx.DiscoverSkills = func() SkillLoadResult { return SkillLoadResult{} }
|
||||
}
|
||||
if ctx.InjectSkillAsContext == nil {
|
||||
ctx.InjectSkillAsContext = func(string) string { return "" }
|
||||
}
|
||||
if ctx.InjectRawSkillAsContext == nil {
|
||||
ctx.InjectRawSkillAsContext = func(string) string { return "" }
|
||||
}
|
||||
if ctx.GetAvailableSkills == nil {
|
||||
ctx.GetAvailableSkills = func() []Skill { return nil }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Template Parsing API no-ops
|
||||
// -------------------------------------------------------------------------
|
||||
if ctx.ParseTemplate == nil {
|
||||
ctx.ParseTemplate = func(string, string) PromptTemplate { return PromptTemplate{} }
|
||||
}
|
||||
if ctx.RenderTemplate == nil {
|
||||
ctx.RenderTemplate = func(PromptTemplate, map[string]string) string { return "" }
|
||||
}
|
||||
if ctx.ParseArguments == nil {
|
||||
ctx.ParseArguments = func(string, ArgumentPattern) ParseResult { return ParseResult{} }
|
||||
}
|
||||
if ctx.SimpleParseArguments == nil {
|
||||
ctx.SimpleParseArguments = func(string, int) []string { return nil }
|
||||
}
|
||||
if ctx.EvaluateModelConditional == nil {
|
||||
ctx.EvaluateModelConditional = func(string) bool { return false }
|
||||
}
|
||||
if ctx.RenderWithModelConditionals == nil {
|
||||
ctx.RenderWithModelConditionals = func(string) string { return "" }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Model Resolution API no-ops
|
||||
// -------------------------------------------------------------------------
|
||||
if ctx.ResolveModelChain == nil {
|
||||
ctx.ResolveModelChain = func([]string) ModelResolutionResult {
|
||||
return ModelResolutionResult{Error: "not implemented"}
|
||||
}
|
||||
}
|
||||
if ctx.GetModelCapabilities == nil {
|
||||
ctx.GetModelCapabilities = func(string) (ModelCapabilities, string) {
|
||||
return ModelCapabilities{}, "not implemented"
|
||||
}
|
||||
}
|
||||
if ctx.CheckModelAvailable == nil {
|
||||
ctx.CheckModelAvailable = func(string) bool { return false }
|
||||
}
|
||||
if ctx.GetCurrentProvider == nil {
|
||||
ctx.GetCurrentProvider = func() string { return "" }
|
||||
}
|
||||
if ctx.GetCurrentModelID == nil {
|
||||
ctx.GetCurrentModelID = func() string { return "" }
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,24 @@ func Symbols() interp.Exports {
|
||||
"ThemeColor": reflect.ValueOf((*ThemeColor)(nil)),
|
||||
"ThemeColorConfig": reflect.ValueOf((*ThemeColorConfig)(nil)),
|
||||
|
||||
// Tree navigation types
|
||||
"TreeNode": reflect.ValueOf((*TreeNode)(nil)),
|
||||
"TreeNavigationResult": reflect.ValueOf((*TreeNavigationResult)(nil)),
|
||||
|
||||
// Skill types
|
||||
"Skill": reflect.ValueOf((*Skill)(nil)),
|
||||
"SkillLoadResult": reflect.ValueOf((*SkillLoadResult)(nil)),
|
||||
|
||||
// Template parsing types
|
||||
"PromptTemplate": reflect.ValueOf((*PromptTemplate)(nil)),
|
||||
"ArgumentPattern": reflect.ValueOf((*ArgumentPattern)(nil)),
|
||||
"ParseResult": reflect.ValueOf((*ParseResult)(nil)),
|
||||
"ModelConditional": reflect.ValueOf((*ModelConditional)(nil)),
|
||||
|
||||
// Model resolution types
|
||||
"ModelCapabilities": reflect.ValueOf((*ModelCapabilities)(nil)),
|
||||
"ModelResolutionResult": reflect.ValueOf((*ModelResolutionResult)(nil)),
|
||||
|
||||
// Event structs
|
||||
"ToolCallEvent": reflect.ValueOf((*ToolCallEvent)(nil)),
|
||||
"ToolCallResult": reflect.ValueOf((*ToolCallResult)(nil)),
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/message"
|
||||
"github.com/mark3labs/kit/internal/session"
|
||||
)
|
||||
|
||||
@@ -86,3 +91,213 @@ func (m *Kit) SetSessionName(name string) error {
|
||||
_, err := m.treeSession.AppendSessionInfo(name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree Navigation Bridge for Extensions (Phase 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetTreeNode returns a node by ID with full metadata and children.
|
||||
// Returns nil if entry not found or no tree session.
|
||||
func (m *Kit) GetTreeNode(entryID string) *TreeNode {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
entry := m.treeSession.GetEntry(entryID)
|
||||
if entry == nil {
|
||||
return nil
|
||||
}
|
||||
return m.entryToTreeNode(entry)
|
||||
}
|
||||
|
||||
// GetCurrentBranch returns the path from root to current leaf as TreeNodes.
|
||||
func (m *Kit) GetCurrentBranch() []TreeNode {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
branch := m.treeSession.GetBranch("")
|
||||
var nodes []TreeNode
|
||||
for _, entry := range branch {
|
||||
node := m.entryToTreeNode(entry)
|
||||
if node != nil {
|
||||
nodes = append(nodes, *node)
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// GetChildren returns direct child IDs of an entry.
|
||||
func (m *Kit) GetChildren(parentID string) []string {
|
||||
if m.treeSession == nil {
|
||||
return nil
|
||||
}
|
||||
return m.treeSession.GetChildren(parentID)
|
||||
}
|
||||
|
||||
// NavigateTo branches/forks the session to the specified entry ID.
|
||||
// Returns error description or empty string for success.
|
||||
func (m *Kit) NavigateTo(entryID string) string {
|
||||
if m.treeSession == nil {
|
||||
return "no tree session available"
|
||||
}
|
||||
if err := m.treeSession.Branch(entryID); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SummarizeBranch uses LLM to summarize a branch range.
|
||||
// Returns summary text or error string.
|
||||
func (m *Kit) SummarizeBranch(fromID, toID string) string {
|
||||
if m.treeSession == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Get the branch and find the range
|
||||
branch := m.treeSession.GetBranch("")
|
||||
var startIdx, endIdx = -1, -1
|
||||
for i, entry := range branch {
|
||||
id := m.getEntryID(entry)
|
||||
if id == fromID {
|
||||
startIdx = i
|
||||
}
|
||||
if id == toID {
|
||||
endIdx = i
|
||||
}
|
||||
}
|
||||
|
||||
if startIdx < 0 || endIdx < 0 || startIdx > endIdx {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build text to summarize
|
||||
var content strings.Builder
|
||||
for i := startIdx; i <= endIdx; i++ {
|
||||
node := m.entryToTreeNode(branch[i])
|
||||
if node != nil && node.Content != "" {
|
||||
fmt.Fprintf(&content, "[%s] %s\n\n", node.Role, node.Content)
|
||||
}
|
||||
}
|
||||
|
||||
if content.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Use LLM to summarize
|
||||
resp, err := m.ExecuteCompletion(context.Background(), extensions.CompleteRequest{
|
||||
Model: "", // Use current model
|
||||
System: "You are a concise summarization assistant. Summarize the conversation in 2-3 sentences.",
|
||||
Prompt: content.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return resp.Text
|
||||
}
|
||||
|
||||
// CollapseBranch replaces a branch range with a summary entry.
|
||||
// Returns error description or empty string for success.
|
||||
func (m *Kit) CollapseBranch(fromID, toID, summary string) string {
|
||||
if m.treeSession == nil {
|
||||
return "no tree session available"
|
||||
}
|
||||
_, err := m.treeSession.AppendBranchSummary(fromID, summary)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// entryToTreeNode converts a session entry to a TreeNode.
|
||||
func (m *Kit) entryToTreeNode(entry any) *TreeNode {
|
||||
switch e := entry.(type) {
|
||||
case *session.MessageEntry:
|
||||
msg, err := e.ToMessage()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var content strings.Builder
|
||||
for _, p := range msg.Parts {
|
||||
switch pt := p.(type) {
|
||||
case message.TextContent:
|
||||
content.WriteString(pt.Text)
|
||||
case message.ReasoningContent:
|
||||
content.WriteString(pt.Thinking)
|
||||
case message.ToolCall:
|
||||
fmt.Fprintf(&content, "[tool_call: %s]", pt.Name)
|
||||
case message.ToolResult:
|
||||
fmt.Fprintf(&content, "[tool_result: %s]", pt.Content)
|
||||
}
|
||||
}
|
||||
return &TreeNode{
|
||||
ID: e.ID,
|
||||
ParentID: e.ParentID,
|
||||
Type: "message",
|
||||
Role: string(msg.Role),
|
||||
Content: content.String(),
|
||||
Model: msg.Model,
|
||||
Provider: msg.Provider,
|
||||
Timestamp: e.Timestamp.Format(time.RFC3339),
|
||||
Children: m.treeSession.GetChildren(e.ID),
|
||||
}
|
||||
case *session.BranchSummaryEntry:
|
||||
return &TreeNode{
|
||||
ID: e.ID,
|
||||
ParentID: e.ParentID,
|
||||
Type: "branch_summary",
|
||||
Content: e.Summary,
|
||||
Timestamp: e.Timestamp.Format(time.RFC3339),
|
||||
Children: m.treeSession.GetChildren(e.ID),
|
||||
}
|
||||
case *session.ModelChangeEntry:
|
||||
return &TreeNode{
|
||||
ID: e.ID,
|
||||
ParentID: e.ParentID,
|
||||
Type: "model_change",
|
||||
Content: fmt.Sprintf("Model changed to %s/%s", e.Provider, e.ModelID),
|
||||
Model: e.Provider + "/" + e.ModelID,
|
||||
Provider: e.Provider,
|
||||
Timestamp: e.Timestamp.Format(time.RFC3339),
|
||||
Children: m.treeSession.GetChildren(e.ID),
|
||||
}
|
||||
case *session.ExtensionDataEntry:
|
||||
return &TreeNode{
|
||||
ID: e.ID,
|
||||
ParentID: e.ParentID,
|
||||
Type: "extension_data",
|
||||
Content: fmt.Sprintf("Extension data: %s", e.ExtType),
|
||||
Timestamp: e.Timestamp.Format(time.RFC3339),
|
||||
Children: m.treeSession.GetChildren(e.ID),
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// getEntryID extracts the ID from a session entry.
|
||||
func (m *Kit) getEntryID(entry any) string {
|
||||
switch e := entry.(type) {
|
||||
case *session.MessageEntry:
|
||||
return e.ID
|
||||
case *session.BranchSummaryEntry:
|
||||
return e.ID
|
||||
case *session.ModelChangeEntry:
|
||||
return e.ID
|
||||
case *session.ExtensionDataEntry:
|
||||
return e.ID
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// TreeNode represents a node in the session tree for SDK consumers.
|
||||
type TreeNode struct {
|
||||
ID string
|
||||
ParentID string
|
||||
Type string // "message", "branch_summary", "model_change", "extension_data"
|
||||
Role string // for messages: "user", "assistant", "system", "tool"
|
||||
Content string
|
||||
Model string
|
||||
Provider string
|
||||
Timestamp string
|
||||
Children []string
|
||||
}
|
||||
|
||||
+90
-1
@@ -1,6 +1,12 @@
|
||||
package kit
|
||||
|
||||
import "github.com/mark3labs/kit/internal/skills"
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/skills"
|
||||
)
|
||||
|
||||
// ==== Skills Types ====
|
||||
|
||||
@@ -67,3 +73,86 @@ func LoadPromptTemplate(path string) (*PromptTemplate, error) {
|
||||
func NewPromptBuilder(basePrompt string) *PromptBuilder {
|
||||
return skills.NewPromptBuilder(basePrompt)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill Bridge for Extensions (Phase 2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// skillCache holds skills discovered for the current session.
|
||||
type skillCache struct {
|
||||
skills []*Skill
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var globalSkillCache skillCache
|
||||
|
||||
// DiscoverSkillsForExtension finds skills in standard locations for extensions.
|
||||
// Returns skills in the extension-facing format.
|
||||
func (m *Kit) DiscoverSkillsForExtension() []extensions.Skill {
|
||||
cwd, _ := os.Getwd()
|
||||
|
||||
// Check cache first
|
||||
globalSkillCache.mu.RLock()
|
||||
if len(globalSkillCache.skills) > 0 {
|
||||
globalSkillCache.mu.RUnlock()
|
||||
return m.convertSkills(globalSkillCache.skills)
|
||||
}
|
||||
globalSkillCache.mu.RUnlock()
|
||||
|
||||
// Load fresh
|
||||
skillList, _ := skills.LoadSkills(cwd)
|
||||
|
||||
globalSkillCache.mu.Lock()
|
||||
globalSkillCache.skills = skillList
|
||||
globalSkillCache.mu.Unlock()
|
||||
|
||||
return m.convertSkills(skillList)
|
||||
}
|
||||
|
||||
// LoadSkillForExtension loads a single skill file for extensions.
|
||||
func (m *Kit) LoadSkillForExtension(path string) (*extensions.Skill, string) {
|
||||
s, err := skills.LoadSkill(path)
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return m.convertSkill(s), ""
|
||||
}
|
||||
|
||||
// LoadSkillsFromDirForExtension loads all skills from a directory for extensions.
|
||||
func (m *Kit) LoadSkillsFromDirForExtension(dir string) extensions.SkillLoadResult {
|
||||
skillList, err := skills.LoadSkillsFromDir(dir)
|
||||
if err != nil {
|
||||
return extensions.SkillLoadResult{Error: err.Error()}
|
||||
}
|
||||
return extensions.SkillLoadResult{Skills: m.convertSkills(skillList)}
|
||||
}
|
||||
|
||||
// convertSkill converts internal skill to extension-facing format.
|
||||
func (m *Kit) convertSkill(s *skills.Skill) *extensions.Skill {
|
||||
return &extensions.Skill{
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Content: s.Content,
|
||||
Path: s.Path,
|
||||
Tags: s.Tags,
|
||||
When: s.When,
|
||||
}
|
||||
}
|
||||
|
||||
// convertSkills converts a slice of skills.
|
||||
func (m *Kit) convertSkills(skills []*skills.Skill) []extensions.Skill {
|
||||
result := make([]extensions.Skill, 0, len(skills))
|
||||
for _, s := range skills {
|
||||
if converted := m.convertSkill(s); converted != nil {
|
||||
result = append(result, *converted)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ClearSkillCache clears the global skill cache (called on reload).
|
||||
func (m *Kit) ClearSkillCache() {
|
||||
globalSkillCache.mu.Lock()
|
||||
globalSkillCache.skills = nil
|
||||
globalSkillCache.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
package kit
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/kit/internal/extensions"
|
||||
"github.com/mark3labs/kit/internal/models"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Template Parsing Bridge for Extensions (Phase 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// varRegex matches {{variable}} placeholders in templates.
|
||||
var varRegex = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
|
||||
|
||||
// ParseTemplate extracts {{variables}} from template content.
|
||||
func ParseTemplate(name, content string) extensions.PromptTemplate {
|
||||
matches := varRegex.FindAllStringSubmatch(content, -1)
|
||||
vars := make([]string, 0, len(matches))
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range matches {
|
||||
if len(m) > 1 && !seen[m[1]] {
|
||||
seen[m[1]] = true
|
||||
vars = append(vars, m[1])
|
||||
}
|
||||
}
|
||||
return extensions.PromptTemplate{
|
||||
Name: name,
|
||||
Content: content,
|
||||
Variables: vars,
|
||||
}
|
||||
}
|
||||
|
||||
// RenderTemplate substitutes variables into template content.
|
||||
func RenderTemplate(tpl extensions.PromptTemplate, vars map[string]string) string {
|
||||
result := tpl.Content
|
||||
for name, value := range vars {
|
||||
placeholder := "{{" + name + "}}"
|
||||
result = strings.ReplaceAll(result, placeholder, value)
|
||||
// Also handle with spaces
|
||||
placeholderSpaced := "{{ " + name + " }}"
|
||||
result = strings.ReplaceAll(result, placeholderSpaced, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ParseArguments parses command-line style arguments.
|
||||
func ParseArguments(input string, pattern extensions.ArgumentPattern) extensions.ParseResult {
|
||||
result := extensions.ParseResult{
|
||||
Vars: make(map[string]string),
|
||||
Flags: make(map[string]string),
|
||||
}
|
||||
|
||||
fields := parseFields(input)
|
||||
if len(fields) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
// First field is the command itself (if present)
|
||||
startIdx := 0
|
||||
if len(fields) > 0 && !strings.HasPrefix(fields[0], "-") {
|
||||
// Check if it's a command name or positional arg
|
||||
if len(pattern.Positional) == 0 || !isFlag(fields[0], pattern.Flags) {
|
||||
startIdx = 1 // Skip command name
|
||||
}
|
||||
}
|
||||
|
||||
// Parse flags
|
||||
i := startIdx
|
||||
for i < len(fields) {
|
||||
field := fields[i]
|
||||
|
||||
// Check for flags
|
||||
if strings.HasPrefix(field, "--") {
|
||||
flagName := field[2:]
|
||||
if varName, ok := pattern.Flags["--"+flagName]; ok {
|
||||
// Flag with value
|
||||
if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "-") {
|
||||
result.Flags["--"+flagName] = fields[i+1]
|
||||
result.Vars[varName] = fields[i+1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Boolean flag
|
||||
result.Flags["--"+flagName] = "true"
|
||||
result.Vars[varName] = "true"
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(field, "-") && len(field) > 1 {
|
||||
flagName := field[1:]
|
||||
if varName, ok := pattern.Flags["-"+flagName]; ok {
|
||||
// Flag with value
|
||||
if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "-") {
|
||||
result.Flags["-"+flagName] = fields[i+1]
|
||||
result.Vars[varName] = fields[i+1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Boolean flag
|
||||
result.Flags["-"+flagName] = "true"
|
||||
result.Vars[varName] = "true"
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
// Collect remaining as positional args and "rest"
|
||||
positional := make([]string, 0)
|
||||
i = startIdx
|
||||
for i < len(fields) {
|
||||
field := fields[i]
|
||||
if !strings.HasPrefix(field, "-") {
|
||||
// Check if this was consumed as a flag value
|
||||
consumed := false
|
||||
for _, v := range result.Vars {
|
||||
if v == field {
|
||||
// Might be consumed, check previous field
|
||||
if i > 0 {
|
||||
prev := fields[i-1]
|
||||
if strings.HasPrefix(prev, "-") {
|
||||
consumed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !consumed {
|
||||
positional = append(positional, field)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
// Map positional args
|
||||
for i, name := range pattern.Positional {
|
||||
if i < len(positional) {
|
||||
result.Vars[name] = positional[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Set rest
|
||||
if pattern.Rest != "" && len(positional) > len(pattern.Positional) {
|
||||
restStart := len(pattern.Positional)
|
||||
if restStart < len(positional) {
|
||||
result.Vars[pattern.Rest] = strings.Join(positional[restStart:], " ")
|
||||
}
|
||||
}
|
||||
|
||||
result.Rest = strings.Join(fields, " ")
|
||||
return result
|
||||
}
|
||||
|
||||
// SimpleParseArguments parses $1, $2, $@ style arguments.
|
||||
// Returns slice where [0]=full input, [1]=$1, [2]=$2, ... [n]=$@
|
||||
func SimpleParseArguments(input string, count int) []string {
|
||||
fields := parseFields(input)
|
||||
result := make([]string, 0, count+2)
|
||||
result = append(result, input) // [0] = full input
|
||||
|
||||
// [1]..[count] = positional args
|
||||
for i := 0; i < count; i++ {
|
||||
if i < len(fields) {
|
||||
result = append(result, fields[i])
|
||||
} else {
|
||||
result = append(result, "")
|
||||
}
|
||||
}
|
||||
|
||||
// [n] = $@ (all remaining)
|
||||
if len(fields) > count {
|
||||
result = append(result, strings.Join(fields[count:], " "))
|
||||
} else {
|
||||
result = append(result, "")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parseFields splits input respecting quoted strings.
|
||||
func parseFields(input string) []string {
|
||||
var fields []string
|
||||
var current strings.Builder
|
||||
inQuote := false
|
||||
quoteChar := rune(0)
|
||||
|
||||
for _, r := range input {
|
||||
switch r {
|
||||
case '"', '\'':
|
||||
if !inQuote {
|
||||
inQuote = true
|
||||
quoteChar = r
|
||||
} else if r == quoteChar {
|
||||
inQuote = false
|
||||
quoteChar = 0
|
||||
} else {
|
||||
current.WriteRune(r)
|
||||
}
|
||||
case ' ', '\t':
|
||||
if inQuote {
|
||||
current.WriteRune(r)
|
||||
} else {
|
||||
if current.Len() > 0 {
|
||||
fields = append(fields, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
}
|
||||
default:
|
||||
current.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
fields = append(fields, current.String())
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// isFlag checks if a field is a known flag.
|
||||
func isFlag(field string, flags map[string]string) bool {
|
||||
if strings.HasPrefix(field, "--") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(field, "-") && len(field) > 1 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EvaluateModelConditional checks if condition matches current model.
|
||||
// Condition supports wildcards: * matches any, ? matches single char.
|
||||
func EvaluateModelConditional(currentModel, condition string) bool {
|
||||
// Handle comma-separated conditions (OR logic)
|
||||
for _, c := range strings.Split(condition, ",") {
|
||||
c = strings.TrimSpace(c)
|
||||
if matchModelPattern(currentModel, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchModelPattern matches a model against a pattern with wildcards.
|
||||
func matchModelPattern(model, pattern string) bool {
|
||||
// Convert pattern to regexp
|
||||
pattern = strings.ReplaceAll(pattern, "*", ".*")
|
||||
pattern = strings.ReplaceAll(pattern, "?", ".")
|
||||
pattern = "^" + pattern + "$"
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
// Fallback: exact match
|
||||
return model == pattern
|
||||
}
|
||||
return re.MatchString(model)
|
||||
}
|
||||
|
||||
// RenderWithModelConditionals processes <if-model> blocks in content.
|
||||
func RenderWithModelConditionals(content, currentModel string) string {
|
||||
// Simple regex-based processor for <if-model> blocks
|
||||
// Supports: <if-model is="pattern">content</if-model>
|
||||
// And: <if-model is="pattern">content<else>other</if-model>
|
||||
|
||||
result := content
|
||||
|
||||
// Pattern for if-model blocks
|
||||
ifModelRegex := regexp.MustCompile(`(?s)<if-model\s+is="([^"]+)">(.*?)(?:<else>(.*?))?</if-model>`)
|
||||
|
||||
for {
|
||||
match := ifModelRegex.FindStringSubmatchIndex(result)
|
||||
if match == nil {
|
||||
break
|
||||
}
|
||||
|
||||
condition := result[match[2]:match[3]]
|
||||
ifContent := result[match[4]:match[5]]
|
||||
elseContent := ""
|
||||
if match[6] >= 0 && match[7] >= 0 {
|
||||
elseContent = result[match[6]:match[7]]
|
||||
}
|
||||
|
||||
var replacement string
|
||||
if EvaluateModelConditional(currentModel, condition) {
|
||||
replacement = ifContent
|
||||
} else {
|
||||
replacement = elseContent
|
||||
}
|
||||
|
||||
result = result[:match[0]] + replacement + result[match[1]:]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model Resolution Bridge for Extensions (Phase 4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ResolveModelChain attempts each model in order until one is available.
|
||||
func ResolveModelChain(preferences []string) extensions.ModelResolutionResult {
|
||||
result := extensions.ModelResolutionResult{
|
||||
Attempted: make([]string, 0, len(preferences)),
|
||||
}
|
||||
|
||||
registry := models.GetGlobalRegistry()
|
||||
|
||||
for _, pref := range preferences {
|
||||
pref = strings.TrimSpace(pref)
|
||||
result.Attempted = append(result.Attempted, pref)
|
||||
|
||||
// Parse model string
|
||||
provider, modelID, err := models.ParseModelString(pref)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if provider exists
|
||||
if registry.GetProviderInfo(provider) == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if model exists in registry
|
||||
modelInfo := registry.LookupModel(provider, modelID)
|
||||
if modelInfo == nil {
|
||||
// Try with just the model as bare name
|
||||
continue
|
||||
}
|
||||
|
||||
// Found available model
|
||||
result.Model = provider + "/" + modelID
|
||||
result.Capabilities = extensions.ModelCapabilities{
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
ContextLimit: modelInfo.Limit.Context,
|
||||
OutputLimit: modelInfo.Limit.Output,
|
||||
Reasoning: modelInfo.Reasoning,
|
||||
Streaming: true, // Assume streaming support
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
result.Error = "no models in chain are available"
|
||||
return result
|
||||
}
|
||||
|
||||
// GetModelCapabilities returns capabilities for a specific model.
|
||||
// If model is empty, returns zero capabilities.
|
||||
func GetModelCapabilities(model string) (extensions.ModelCapabilities, string) {
|
||||
if model == "" {
|
||||
return extensions.ModelCapabilities{}, "no model specified"
|
||||
}
|
||||
|
||||
provider, modelID, err := models.ParseModelString(model)
|
||||
if err != nil {
|
||||
return extensions.ModelCapabilities{}, err.Error()
|
||||
}
|
||||
|
||||
registry := models.GetGlobalRegistry()
|
||||
modelInfo := registry.LookupModel(provider, modelID)
|
||||
if modelInfo == nil {
|
||||
return extensions.ModelCapabilities{}, "model not found in registry"
|
||||
}
|
||||
|
||||
return extensions.ModelCapabilities{
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
ContextLimit: modelInfo.Limit.Context,
|
||||
OutputLimit: modelInfo.Limit.Output,
|
||||
Reasoning: modelInfo.Reasoning,
|
||||
Streaming: true,
|
||||
}, ""
|
||||
}
|
||||
|
||||
// CheckModelAvailable verifies if a model string is valid and provider exists.
|
||||
func CheckModelAvailable(model string) bool {
|
||||
provider, _, err := models.ParseModelString(model)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
registry := models.GetGlobalRegistry()
|
||||
if registry.GetProviderInfo(provider) == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Model doesn't need to be in registry - could be dynamic/Ollama
|
||||
return true
|
||||
}
|
||||
|
||||
// GetCurrentProvider extracts provider from model string.
|
||||
func GetCurrentProvider(model string) string {
|
||||
provider, _, _ := models.ParseModelString(model)
|
||||
return provider
|
||||
}
|
||||
|
||||
// GetCurrentModelID extracts model ID from model string.
|
||||
func GetCurrentModelID(model string) string {
|
||||
_, modelID, _ := models.ParseModelString(model)
|
||||
return modelID
|
||||
}
|
||||
|
||||
// JoinModel combines provider and model ID into a model string.
|
||||
func JoinModel(provider, modelID string) string {
|
||||
if provider == "" {
|
||||
return modelID
|
||||
}
|
||||
return provider + "/" + modelID
|
||||
}
|
||||
|
||||
// MatchModelGlob matches a model against a glob pattern.
|
||||
// Pattern can contain * (match any) and ? (match single).
|
||||
func MatchModelGlob(model, pattern string) bool {
|
||||
return matchModelPattern(model, pattern)
|
||||
}
|
||||
|
||||
// ExtractProviderFromPath extracts provider from a path-like model string.
|
||||
func ExtractProviderFromPath(model string) string {
|
||||
parts := strings.Split(model, "/")
|
||||
if len(parts) >= 2 {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ExtractModelFromPath extracts model ID from a path-like model string.
|
||||
func ExtractModelFromPath(model string) string {
|
||||
parts := strings.Split(model, "/")
|
||||
if len(parts) >= 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
// IsBareModelID checks if a string is a bare model ID (no provider).
|
||||
func IsBareModelID(model string) bool {
|
||||
return !strings.Contains(model, "/")
|
||||
}
|
||||
|
||||
// AddProviderToModel adds a provider prefix to a bare model ID.
|
||||
func AddProviderToModel(provider, model string) string {
|
||||
if strings.Contains(model, "/") {
|
||||
return model // Already has provider
|
||||
}
|
||||
return provider + "/" + model
|
||||
}
|
||||
|
||||
// RemoveProviderFromModel removes the provider prefix from a model string.
|
||||
func RemoveProviderFromModel(model string) string {
|
||||
parts := strings.SplitN(model, "/", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return model
|
||||
}
|
||||
@@ -1210,6 +1210,129 @@ func applyMode(ctx ext.Context, active bool, tools []string) {
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bridged SDK APIs (New)
|
||||
|
||||
Extensions can now access powerful internal SDK capabilities that enable advanced features like conversation tree navigation, dynamic skill loading, template parsing, and model resolution.
|
||||
|
||||
### Tree Navigation
|
||||
|
||||
Navigate the conversation tree, summarize branches, and implement "fresh context" loops:
|
||||
|
||||
```go
|
||||
// Get a specific node by ID with full metadata and children
|
||||
node := ctx.GetTreeNode("entry-id")
|
||||
// node.ID, node.ParentID, node.Type ("message"/"branch_summary"/etc)
|
||||
// node.Role, node.Content, node.Model, node.Children ([]string)
|
||||
|
||||
// Get the current branch from root to leaf
|
||||
branch := ctx.GetCurrentBranch() // []ext.TreeNode
|
||||
|
||||
// Get child entry IDs of a node
|
||||
children := ctx.GetChildren("entry-id") // []string
|
||||
|
||||
// Navigate/fork to a different entry in the tree
|
||||
result := ctx.NavigateTo("entry-id") // ext.TreeNavigationResult{Success, Error}
|
||||
|
||||
// Summarize a range of the branch using LLM
|
||||
summary := ctx.SummarizeBranch("from-id", "to-id") // string
|
||||
|
||||
// Collapse a branch range into a summary entry (fresh context primitive)
|
||||
result := ctx.CollapseBranch("from-id", "to-id", "summary text")
|
||||
```
|
||||
|
||||
### Skill Loading
|
||||
|
||||
Load and inject skills dynamically at runtime:
|
||||
|
||||
```go
|
||||
// Discover skills from standard locations
|
||||
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
|
||||
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/
|
||||
|
||||
// Load a specific skill file
|
||||
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
|
||||
// skill.Name, skill.Description, skill.Content, skill.Tags, skill.When
|
||||
|
||||
// Load all skills from a directory
|
||||
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
|
||||
|
||||
// Inject a skill as context (pre-loads for next turn)
|
||||
err := ctx.InjectSkillAsContext("skill-name") // error string
|
||||
|
||||
// Inject a skill file directly
|
||||
err := ctx.InjectRawSkillAsContext("/path/to/skill.md") // error string
|
||||
|
||||
// Get all discovered skills
|
||||
skills := ctx.GetAvailableSkills() // []ext.Skill
|
||||
```
|
||||
|
||||
### Template Parsing
|
||||
|
||||
Parse and render templates with variable substitution:
|
||||
|
||||
```go
|
||||
// Parse a template to extract {{variables}}
|
||||
tpl := ctx.ParseTemplate("name", "Hello {{name}}, welcome to {{place}}!")
|
||||
// tpl.Name, tpl.Content, tpl.Variables ([]string)
|
||||
|
||||
// Render a template with variable values
|
||||
vars := map[string]string{"name": "Alice", "place": "Kit"}
|
||||
rendered := ctx.RenderTemplate(tpl, vars) // "Hello Alice, welcome to Kit!"
|
||||
|
||||
// Parse command-line style arguments
|
||||
pattern := ext.ArgumentPattern{
|
||||
Positional: []string{"command", "target"}, // $1, $2
|
||||
Rest: "args", // $@
|
||||
Flags: map[string]string{"--loop": "loop", "-f": "force"},
|
||||
}
|
||||
result := ctx.ParseArguments("deploy staging --loop 5", pattern)
|
||||
// result.Vars["command"] = "deploy"
|
||||
// result.Vars["target"] = "staging"
|
||||
// result.Flags["--loop"] = "5"
|
||||
|
||||
// Simple positional argument parsing ($1, $2, $@)
|
||||
args := ctx.SimpleParseArguments("deploy staging --force", 2)
|
||||
// args[0] = "deploy staging --force" (full input)
|
||||
// args[1] = "deploy" ($1)
|
||||
// args[2] = "staging" ($2)
|
||||
// args[3] = "--force" ($@)
|
||||
|
||||
// Evaluate model conditionals with wildcards
|
||||
matches := ctx.EvaluateModelConditional("claude-*") // bool
|
||||
// Patterns: * matches any, ? matches single char, comma = OR
|
||||
|
||||
// Render content with <if-model> conditionals
|
||||
content := `<if-model is="claude-*">Hi Claude<else>Hi there</if-model>`
|
||||
rendered := ctx.RenderWithModelConditionals(content) // based on current model
|
||||
```
|
||||
|
||||
### Model Resolution
|
||||
|
||||
Resolve model fallback chains and query capabilities:
|
||||
|
||||
```go
|
||||
// Resolve a chain of model preferences (tries each until available)
|
||||
result := ctx.ResolveModelChain([]string{
|
||||
"anthropic/claude-opus-4",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"openai/gpt-4o",
|
||||
})
|
||||
// result.Model (selected), result.Capabilities, result.Attempted, result.Error
|
||||
|
||||
// Get capabilities for a specific model
|
||||
caps, err := ctx.GetModelCapabilities("anthropic/claude-sonnet-4")
|
||||
// caps.Provider, caps.ModelID, caps.ContextLimit, caps.Reasoning, caps.Streaming
|
||||
|
||||
// Check if a model is available (provider exists)
|
||||
available := ctx.CheckModelAvailable("anthropic/claude-sonnet-4") // bool
|
||||
|
||||
// Get current provider/model ID
|
||||
provider := ctx.GetCurrentProvider() // "anthropic"
|
||||
modelID := ctx.GetCurrentModelID() // "claude-sonnet-4"
|
||||
```
|
||||
|
||||
## Key Files for Reference
|
||||
|
||||
- [`internal/extensions/api.go`](https://github.com/mark3labs/kit/blob/main/internal/extensions/api.go) — Complete API type definitions
|
||||
|
||||
@@ -334,3 +334,124 @@ api.OnCustomEvent("my-extension:data-ready", func(data any, ctx ext.Context) {
|
||||
// handle event
|
||||
})
|
||||
```
|
||||
|
||||
## Bridged SDK APIs
|
||||
|
||||
Extensions can access powerful internal SDK capabilities that enable advanced features like conversation tree navigation, dynamic skill loading, template parsing, and model resolution.
|
||||
|
||||
### Tree Navigation
|
||||
|
||||
Navigate the conversation tree, summarize branches, and implement "fresh context" loops:
|
||||
|
||||
```go
|
||||
// Get a specific node by ID with full metadata and children
|
||||
node := ctx.GetTreeNode("entry-id")
|
||||
// node.ID, node.ParentID, node.Type ("message"/"branch_summary"/etc)
|
||||
// node.Role, node.Content, node.Model, node.Children ([]string)
|
||||
|
||||
// Get the current branch from root to leaf
|
||||
branch := ctx.GetCurrentBranch() // []ext.TreeNode
|
||||
|
||||
// Get child entry IDs of a node
|
||||
children := ctx.GetChildren("entry-id") // []string
|
||||
|
||||
// Navigate/fork to a different entry in the tree
|
||||
result := ctx.NavigateTo("entry-id") // ext.TreeNavigationResult{Success, Error}
|
||||
|
||||
// Summarize a range of the branch using LLM
|
||||
summary := ctx.SummarizeBranch("from-id", "to-id") // string
|
||||
|
||||
// Collapse a branch range into a summary entry (fresh context primitive)
|
||||
result := ctx.CollapseBranch("from-id", "to-id", "summary text")
|
||||
```
|
||||
|
||||
### Skill Loading
|
||||
|
||||
Load and inject skills dynamically at runtime:
|
||||
|
||||
```go
|
||||
// Discover skills from standard locations
|
||||
result := ctx.DiscoverSkills() // ext.SkillLoadResult{Skills, Error}
|
||||
// Standard locations: ~/.config/kit/skills/, .kit/skills/, .agents/skills/
|
||||
|
||||
// Load a specific skill file
|
||||
skill, err := ctx.LoadSkill("/path/to/skill.md") // (*ext.Skill, error string)
|
||||
// skill.Name, skill.Description, skill.Content, skill.Tags, skill.When
|
||||
|
||||
// Load all skills from a directory
|
||||
result := ctx.LoadSkillsFromDir("/path/to/skills") // ext.SkillLoadResult
|
||||
|
||||
// Inject a skill as context (pre-loads for next turn)
|
||||
err := ctx.InjectSkillAsContext("skill-name") // error string
|
||||
|
||||
// Inject a skill file directly
|
||||
err := ctx.InjectRawSkillAsContext("/path/to/skill.md") // error string
|
||||
|
||||
// Get all discovered skills
|
||||
skills := ctx.GetAvailableSkills() // []ext.Skill
|
||||
```
|
||||
|
||||
### Template Parsing
|
||||
|
||||
Parse and render templates with variable substitution:
|
||||
|
||||
```go
|
||||
// Parse a template to extract {{variables}}
|
||||
tpl := ctx.ParseTemplate("name", "Hello {{name}}, welcome to {{place}}!")
|
||||
// tpl.Name, tpl.Content, tpl.Variables ([]string)
|
||||
|
||||
// Render a template with variable values
|
||||
vars := map[string]string{"name": "Alice", "place": "Kit"}
|
||||
rendered := ctx.RenderTemplate(tpl, vars) // "Hello Alice, welcome to Kit!"
|
||||
|
||||
// Parse command-line style arguments
|
||||
pattern := ext.ArgumentPattern{
|
||||
Positional: []string{"command", "target"}, // $1, $2
|
||||
Rest: "args", // $@
|
||||
Flags: map[string]string{"--loop": "loop", "-f": "force"},
|
||||
}
|
||||
result := ctx.ParseArguments("deploy staging --loop 5", pattern)
|
||||
// result.Vars["command"] = "deploy"
|
||||
// result.Vars["target"] = "staging"
|
||||
// result.Flags["--loop"] = "5"
|
||||
|
||||
// Simple positional argument parsing ($1, $2, $@)
|
||||
args := ctx.SimpleParseArguments("deploy staging --force", 2)
|
||||
// args[0] = "deploy staging --force" (full input)
|
||||
// args[1] = "deploy" ($1)
|
||||
// args[2] = "staging" ($2)
|
||||
// args[3] = "--force" ($@)
|
||||
|
||||
// Evaluate model conditionals with wildcards
|
||||
matches := ctx.EvaluateModelConditional("claude-*") // bool
|
||||
// Patterns: * matches any, ? matches single char, comma = OR
|
||||
|
||||
// Render content with <if-model> conditionals
|
||||
content := `<if-model is="claude-*">Hi Claude<else>Hi there</if-model>`
|
||||
rendered := ctx.RenderWithModelConditionals(content) // based on current model
|
||||
```
|
||||
|
||||
### Model Resolution
|
||||
|
||||
Resolve model fallback chains and query capabilities:
|
||||
|
||||
```go
|
||||
// Resolve a chain of model preferences (tries each until available)
|
||||
result := ctx.ResolveModelChain([]string{
|
||||
"anthropic/claude-opus-4",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"openai/gpt-4o",
|
||||
})
|
||||
// result.Model (selected), result.Capabilities, result.Attempted, result.Error
|
||||
|
||||
// Get capabilities for a specific model
|
||||
caps, err := ctx.GetModelCapabilities("anthropic/claude-sonnet-4")
|
||||
// caps.Provider, caps.ModelID, caps.ContextLimit, caps.Reasoning, caps.Streaming
|
||||
|
||||
// Check if a model is available (provider exists)
|
||||
available := ctx.CheckModelAvailable("anthropic/claude-sonnet-4") // bool
|
||||
|
||||
// Get current provider/model ID
|
||||
provider := ctx.GetCurrentProvider() // "anthropic"
|
||||
modelID := ctx.GetCurrentModelID() // "claude-sonnet-4"
|
||||
```
|
||||
|
||||
@@ -51,6 +51,15 @@ Kit ships with a rich set of example extensions in the `examples/extensions/` di
|
||||
| [`summarize.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/summarize.go) | Conversation summarization |
|
||||
| [`lsp-diagnostics.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/lsp-diagnostics.go) | LSP diagnostic integration |
|
||||
|
||||
## Bridged SDK APIs
|
||||
|
||||
These examples demonstrate the new bridged SDK APIs that give extensions access to internal Kit capabilities:
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| [`conversation-manager.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/conversation-manager.go) | **NEW** Tree navigation (`GetTreeNode`, `GetCurrentBranch`, `NavigateTo`), branch summarization (`SummarizeBranch`), and fresh context loops (`CollapseBranch`) |
|
||||
| [`prompt-templates.go`](https://github.com/mark3labs/kit/blob/master/examples/extensions/prompt-templates.go) | **NEW** Frontmatter-driven templates with model fallback chains (`ResolveModelChain`), skill injection (`InjectSkillAsContext`), and template parsing (`ParseTemplate`, `RenderTemplate`) |
|
||||
|
||||
## Themes
|
||||
|
||||
| Extension | Description |
|
||||
|
||||
Reference in New Issue
Block a user