diff --git a/README.md b/README.md
index e364899d..6a12cf9a 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/cmd/root.go b/cmd/root.go
index a5b7ef59..17969901 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -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("\n%s\n", 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("\n%s\n", 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("\n%s\n", 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("\n%s\n", 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)
+ },
})
}
diff --git a/examples/extensions/bridge_demo.go b/examples/extensions/bridge_demo.go
new file mode 100644
index 00000000..ff5d2ab2
--- /dev/null
+++ b/examples/extensions/bridge_demo.go
@@ -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 := `This is for Claude modelsThis is for other models`
+ 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] + "..."
+}
diff --git a/examples/extensions/conversation-manager.go b/examples/extensions/conversation-manager.go
new file mode 100644
index 00000000..9dd566d9
--- /dev/null
+++ b/examples/extensions/conversation-manager.go
@@ -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 - Navigate to a specific entry
+// /summarize - Summarize last N messages
+// /fresh-context - Collapse branch and start fresh
+// /loop - 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 )",
+ Execute: func(args string, ctx ext.Context) (string, error) {
+ if args == "" {
+ ctx.PrintError("Usage: /goto ")
+ 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 ")
+ 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] + "..."
+}
diff --git a/examples/extensions/prompt-templates.go b/examples/extensions/prompt-templates.go
new file mode 100644
index 00000000..5d9657d5
--- /dev/null
+++ b/examples/extensions/prompt-templates.go
@@ -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
+}
diff --git a/internal/extensions/api.go b/internal/extensions/api.go
index 1cf98497..bd83e4e1 100644
--- a/internal/extensions/api.go
+++ b/internal/extensions/api.go
@@ -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 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 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.
diff --git a/internal/extensions/runner.go b/internal/extensions/runner.go
index ae5ce8f4..827ce2ee 100644
--- a/internal/extensions/runner.go
+++ b/internal/extensions/runner.go
@@ -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
}
diff --git a/internal/extensions/symbols.go b/internal/extensions/symbols.go
index 4148f1bd..bf167036 100644
--- a/internal/extensions/symbols.go
+++ b/internal/extensions/symbols.go
@@ -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)),
diff --git a/pkg/kit/sessions.go b/pkg/kit/sessions.go
index 709ae010..81c6c025 100644
--- a/pkg/kit/sessions.go
+++ b/pkg/kit/sessions.go
@@ -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
+}
diff --git a/pkg/kit/skills.go b/pkg/kit/skills.go
index d2e2b814..7f73a424 100644
--- a/pkg/kit/skills.go
+++ b/pkg/kit/skills.go
@@ -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()
+}
diff --git a/pkg/kit/template_bridge.go b/pkg/kit/template_bridge.go
new file mode 100644
index 00000000..7089a938
--- /dev/null
+++ b/pkg/kit/template_bridge.go
@@ -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 blocks in content.
+func RenderWithModelConditionals(content, currentModel string) string {
+ // Simple regex-based processor for blocks
+ // Supports: content
+ // And: contentother
+
+ result := content
+
+ // Pattern for if-model blocks
+ ifModelRegex := regexp.MustCompile(`(?s)(.*?)(?:(.*?))?`)
+
+ 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
+}
diff --git a/skills/kit-extensions/SKILL.md b/skills/kit-extensions/SKILL.md
index 1d327202..def178f7 100644
--- a/skills/kit-extensions/SKILL.md
+++ b/skills/kit-extensions/SKILL.md
@@ -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 conditionals
+content := `Hi ClaudeHi there`
+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
diff --git a/www/pages/extensions/capabilities.md b/www/pages/extensions/capabilities.md
index 1dca8441..e26b1115 100644
--- a/www/pages/extensions/capabilities.md
+++ b/www/pages/extensions/capabilities.md
@@ -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 conditionals
+content := `Hi ClaudeHi there`
+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"
+```
diff --git a/www/pages/extensions/examples.md b/www/pages/extensions/examples.md
index ed69b79d..0f402968 100644
--- a/www/pages/extensions/examples.md
+++ b/www/pages/extensions/examples.md
@@ -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 |