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 |