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:
Ed Zynda
2026-03-28 12:00:19 +03:00
parent 15ef8ad78b
commit 4caa8ba3dc
14 changed files with 2465 additions and 1 deletions
+8
View File
@@ -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
View File
@@ -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)
},
})
}
+170
View File
@@ -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] + "..."
}
+406
View File
@@ -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] + "..."
}
+269
View File
@@ -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
}
+238
View File
@@ -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.
+96
View File
@@ -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
}
+18
View File
@@ -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)),
+215
View File
@@ -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
View File
@@ -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()
}
+462
View File
@@ -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
}
+123
View File
@@ -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
+121
View File
@@ -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"
```
+9
View File
@@ -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 |