mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-13 19:20:06 +00:00
57250a3a3d
Drop the --prompt/-p flag entirely. Non-interactive mode is now triggered by passing positional arguments: kit "Explain this" kit @file.go "Review this" --json kit @a.go @b.go --quiet Updated extension examples (kit-kit.go, subagent-widget.go) to pass the prompt as a positional arg. Updated AGENTS.md and README.md.
871 lines
22 KiB
Go
871 lines
22 KiB
Go
//go:build ignore
|
|
|
|
// Kit Kit — Meta-agent that builds Kit agents
|
|
//
|
|
// A team of domain-specific research experts operate IN PARALLEL to gather
|
|
// documentation and patterns. The primary agent synthesizes their findings
|
|
// and WRITES the actual files.
|
|
//
|
|
// Each expert runs as a separate `kit` subprocess with a domain-specific
|
|
// system prompt. Experts are read-only researchers; the primary agent is
|
|
// the only writer.
|
|
//
|
|
// Commands:
|
|
//
|
|
// /experts — list available experts and their status
|
|
// /experts-grid N — set dashboard column count (default 3)
|
|
//
|
|
// Usage: kit -e examples/extensions/kit-kit.go
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"kit/ext"
|
|
)
|
|
|
|
// kitJSONOutput matches the JSON envelope produced by `kit --json`.
|
|
type kitJSONOutput struct {
|
|
Response string `json:"response"`
|
|
Model string `json:"model"`
|
|
Usage *struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
} `json:"usage,omitempty"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type expertDef struct {
|
|
Name string
|
|
Description string
|
|
Tools string
|
|
System string // system prompt body
|
|
File string
|
|
}
|
|
|
|
type expertState struct {
|
|
Def expertDef
|
|
Status string // "idle", "researching", "done", "error"
|
|
Question string
|
|
Elapsed time.Duration
|
|
LastLine string
|
|
QueryCount int
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (s *expertState) set(status, question, lastLine string, elapsed time.Duration) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if status != "" {
|
|
s.Status = status
|
|
}
|
|
if question != "" {
|
|
s.Question = question
|
|
}
|
|
if lastLine != "" {
|
|
s.LastLine = lastLine
|
|
}
|
|
if elapsed > 0 {
|
|
s.Elapsed = elapsed
|
|
}
|
|
}
|
|
|
|
func (s *expertState) snapshot() (string, string, string, time.Duration, int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.Status, s.Question, s.LastLine, s.Elapsed, s.QueryCount
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Package-level state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
experts = map[string]*expertState{}
|
|
gridCols = 3
|
|
latestCtx ext.Context
|
|
hasCtx bool
|
|
kitBinary string // resolved path to kit executable
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func displayName(name string) string {
|
|
parts := strings.Split(name, "-")
|
|
for i, w := range parts {
|
|
if len(w) > 0 {
|
|
parts[i] = strings.ToUpper(w[:1]) + w[1:]
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func runeWidth(s string) int {
|
|
return len([]rune(s))
|
|
}
|
|
|
|
func truncate(s string, max int) string {
|
|
runes := []rune(s)
|
|
if len(runes) <= max {
|
|
return s
|
|
}
|
|
if max < 4 {
|
|
return string(runes[:max])
|
|
}
|
|
return string(runes[:max-3]) + "..."
|
|
}
|
|
|
|
func pad(s string, width int) string {
|
|
w := runeWidth(s)
|
|
if w >= width {
|
|
return string([]rune(s)[:width])
|
|
}
|
|
return s + strings.Repeat(" ", width-w)
|
|
}
|
|
|
|
// parseAgentFile reads a .md file with YAML-like frontmatter.
|
|
//
|
|
// ---
|
|
// name: ext-expert
|
|
// description: Extensions documentation
|
|
// tools: read,grep,glob
|
|
// ---
|
|
// System prompt body here ...
|
|
func parseAgentFile(path string) *expertDef {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
text := string(raw)
|
|
|
|
// Must start with "---\n"
|
|
if !strings.HasPrefix(text, "---\n") {
|
|
return nil
|
|
}
|
|
rest := text[4:]
|
|
idx := strings.Index(rest, "\n---\n")
|
|
if idx < 0 {
|
|
return nil
|
|
}
|
|
frontmatter := rest[:idx]
|
|
body := strings.TrimSpace(rest[idx+5:])
|
|
|
|
fm := map[string]string{}
|
|
for _, line := range strings.Split(frontmatter, "\n") {
|
|
i := strings.Index(line, ":")
|
|
if i > 0 {
|
|
fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
|
|
}
|
|
}
|
|
if fm["name"] == "" {
|
|
return nil
|
|
}
|
|
return &expertDef{
|
|
Name: fm["name"],
|
|
Description: fm["description"],
|
|
Tools: fm["tools"],
|
|
System: body,
|
|
File: path,
|
|
}
|
|
}
|
|
|
|
func loadExperts(cwd string) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
experts = map[string]*expertState{}
|
|
dir := filepath.Join(cwd, ".kit", "agents", "kit-kit")
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
|
continue
|
|
}
|
|
if e.Name() == "orchestrator.md" {
|
|
continue
|
|
}
|
|
def := parseAgentFile(filepath.Join(dir, e.Name()))
|
|
if def == nil {
|
|
continue
|
|
}
|
|
key := strings.ToLower(def.Name)
|
|
experts[key] = &expertState{
|
|
Def: *def,
|
|
Status: "idle",
|
|
}
|
|
}
|
|
}
|
|
|
|
func expertList() []*expertState {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
list := make([]*expertState, 0, len(experts))
|
|
for _, s := range experts {
|
|
list = append(list, s)
|
|
}
|
|
return list
|
|
}
|
|
|
|
func expertNames() string {
|
|
list := expertList()
|
|
names := make([]string, len(list))
|
|
for i, s := range list {
|
|
names[i] = displayName(s.Def.Name)
|
|
}
|
|
return strings.Join(names, ", ")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Widget grid rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func renderCard(s *expertState, w int) []string {
|
|
status, question, lastLine, elapsed, queryCount := s.snapshot()
|
|
inner := w - 2 // inside the box-drawing borders
|
|
|
|
// Name line
|
|
name := truncate(displayName(s.Def.Name), inner-1)
|
|
|
|
// Status line
|
|
var icon string
|
|
switch status {
|
|
case "idle":
|
|
icon = "○"
|
|
case "researching":
|
|
icon = "◉"
|
|
case "done":
|
|
icon = "✓"
|
|
default:
|
|
icon = "✗"
|
|
}
|
|
statusText := icon + " " + status
|
|
if status != "idle" {
|
|
statusText += fmt.Sprintf(" %ds", int(elapsed.Seconds()))
|
|
}
|
|
if queryCount > 0 {
|
|
statusText += fmt.Sprintf(" (%d)", queryCount)
|
|
}
|
|
statusText = truncate(statusText, inner-1)
|
|
|
|
// Work line (question or description)
|
|
work := question
|
|
if work == "" {
|
|
work = s.Def.Description
|
|
}
|
|
work = truncate(work, inner-1)
|
|
|
|
// Last output line
|
|
last := lastLine
|
|
if last == "" {
|
|
last = "—"
|
|
}
|
|
last = truncate(last, inner-1)
|
|
|
|
// Build card (use rune width for box-drawing alignment)
|
|
topBar := "─ " + name + " "
|
|
if runeWidth(topBar) < inner {
|
|
topBar += strings.Repeat("─", inner-runeWidth(topBar))
|
|
}
|
|
|
|
return []string{
|
|
"┌" + truncate(topBar, inner) + "┐",
|
|
"│ " + pad(statusText, inner-1) + "│",
|
|
"│ " + pad(work, inner-1) + "│",
|
|
"│ " + pad(last, inner-1) + "│",
|
|
"└" + strings.Repeat("─", inner) + "┘",
|
|
}
|
|
}
|
|
|
|
func buildGrid() string {
|
|
list := expertList()
|
|
if len(list) == 0 {
|
|
return "No experts found. Add agent .md files to .kit/agents/kit-kit/"
|
|
}
|
|
|
|
cols := gridCols
|
|
if cols > len(list) {
|
|
cols = len(list)
|
|
}
|
|
|
|
// Card width: aim for ~28 chars per card
|
|
cardWidth := 28
|
|
gap := 1
|
|
|
|
var lines []string
|
|
for i := 0; i < len(list); i += cols {
|
|
end := i + cols
|
|
if end > len(list) {
|
|
end = len(list)
|
|
}
|
|
row := list[i:end]
|
|
|
|
// Render each card in this row
|
|
cards := make([][]string, len(row))
|
|
maxHeight := 0
|
|
for j, s := range row {
|
|
cards[j] = renderCard(s, cardWidth)
|
|
if len(cards[j]) > maxHeight {
|
|
maxHeight = len(cards[j])
|
|
}
|
|
}
|
|
|
|
// Merge columns line by line
|
|
for line := 0; line < maxHeight; line++ {
|
|
var parts []string
|
|
for _, card := range cards {
|
|
if line < len(card) {
|
|
parts = append(parts, card[line])
|
|
} else {
|
|
parts = append(parts, strings.Repeat(" ", cardWidth))
|
|
}
|
|
}
|
|
lines = append(lines, strings.Join(parts, strings.Repeat(" ", gap)))
|
|
}
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func updateWidget() {
|
|
mu.Lock()
|
|
ctx := latestCtx
|
|
ok := hasCtx
|
|
mu.Unlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx.SetWidget(ext.WidgetConfig{
|
|
ID: "kit-kit:grid",
|
|
Placement: ext.WidgetAbove,
|
|
Content: ext.WidgetContent{
|
|
Text: buildGrid(),
|
|
},
|
|
Style: ext.WidgetStyle{
|
|
NoBorder: true,
|
|
BorderColor: "",
|
|
},
|
|
Priority: 10,
|
|
})
|
|
}
|
|
|
|
func updateFooter() {
|
|
mu.Lock()
|
|
ctx := latestCtx
|
|
ok := hasCtx
|
|
mu.Unlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
list := expertList()
|
|
active := 0
|
|
done := 0
|
|
for _, s := range list {
|
|
st, _, _, _, _ := s.snapshot()
|
|
switch st {
|
|
case "researching":
|
|
active++
|
|
case "done":
|
|
done++
|
|
}
|
|
}
|
|
|
|
var mid string
|
|
if active > 0 {
|
|
mid = fmt.Sprintf(" ◉ %d researching", active)
|
|
} else if done > 0 {
|
|
mid = fmt.Sprintf(" ✓ %d done", done)
|
|
}
|
|
|
|
text := fmt.Sprintf("%s | Kit Kit%s", ctx.Model, mid)
|
|
|
|
ctx.SetFooter(ext.HeaderFooterConfig{
|
|
Content: ext.WidgetContent{Text: text},
|
|
Style: ext.WidgetStyle{BorderColor: "#89b4fa"},
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Kit binary resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func findKitBinary() string {
|
|
// Try the current process executable first.
|
|
if exe, err := os.Executable(); err == nil {
|
|
if _, err := os.Stat(exe); err == nil {
|
|
return exe
|
|
}
|
|
}
|
|
// Fall back to PATH lookup.
|
|
if p, err := exec.LookPath("kit"); err == nil {
|
|
return p
|
|
}
|
|
return "kit"
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Expert query (subprocess)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func queryExpert(name, question string) (output string, exitCode int, elapsed time.Duration) {
|
|
mu.Lock()
|
|
state, ok := experts[strings.ToLower(name)]
|
|
mu.Unlock()
|
|
if !ok {
|
|
return fmt.Sprintf("Expert %q not found.", name), 1, 0
|
|
}
|
|
|
|
// Mark as researching.
|
|
state.mu.Lock()
|
|
if state.Status == "researching" {
|
|
state.mu.Unlock()
|
|
return fmt.Sprintf("Expert %q is already researching.", displayName(name)), 1, 0
|
|
}
|
|
state.Status = "researching"
|
|
state.Question = question
|
|
state.Elapsed = 0
|
|
state.LastLine = ""
|
|
state.QueryCount++
|
|
state.mu.Unlock()
|
|
updateWidget()
|
|
|
|
start := time.Now()
|
|
|
|
// Timer goroutine: update widget every second while researching.
|
|
done := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
state.set("", "", "", time.Since(start))
|
|
updateWidget()
|
|
updateFooter()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Write system prompt to temp file.
|
|
tmpFile, err := os.CreateTemp("", "kit-kit-*.txt")
|
|
if err != nil {
|
|
close(done)
|
|
state.set("error", "", "temp file error: "+err.Error(), time.Since(start))
|
|
updateWidget()
|
|
updateFooter()
|
|
return "Error creating temp file: " + err.Error(), 1, time.Since(start)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
|
|
if _, err := tmpFile.WriteString(state.Def.System); err != nil {
|
|
tmpFile.Close()
|
|
close(done)
|
|
state.set("error", "", "write error: "+err.Error(), time.Since(start))
|
|
updateWidget()
|
|
updateFooter()
|
|
return "Error writing system prompt: " + err.Error(), 1, time.Since(start)
|
|
}
|
|
tmpFile.Close()
|
|
|
|
// Build subprocess arguments. Use --json for structured output parsing.
|
|
// Don't pass --model; the subprocess inherits the same config/env default.
|
|
args := []string{
|
|
"--json",
|
|
"--no-session",
|
|
"--no-extensions",
|
|
"--system-prompt", tmpFile.Name(),
|
|
question,
|
|
}
|
|
|
|
var stdoutBuf, stderrBuf bytes.Buffer
|
|
cmd := exec.Command(kitBinary, args...)
|
|
cmd.Env = os.Environ()
|
|
cmd.Stdout = &stdoutBuf
|
|
cmd.Stderr = &stderrBuf
|
|
|
|
err = cmd.Run()
|
|
close(done)
|
|
elapsed = time.Since(start)
|
|
|
|
if err != nil {
|
|
// On error, prefer stderr for the error message; fall back to stdout.
|
|
errText := strings.TrimSpace(stderrBuf.String())
|
|
if errText == "" {
|
|
errText = strings.TrimSpace(stdoutBuf.String())
|
|
}
|
|
errLine := errText
|
|
if idx := strings.Index(errLine, "\n"); idx >= 0 {
|
|
errLine = errLine[:idx]
|
|
}
|
|
state.set("error", "", truncate(strings.TrimSpace(errLine), 80), elapsed)
|
|
updateWidget()
|
|
updateFooter()
|
|
code := 1
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
code = exitErr.ExitCode()
|
|
}
|
|
return errText, code, elapsed
|
|
}
|
|
|
|
// Parse JSON output from subprocess.
|
|
var parsed kitJSONOutput
|
|
result := strings.TrimSpace(stdoutBuf.String())
|
|
if err := json.Unmarshal([]byte(result), &parsed); err == nil {
|
|
result = parsed.Response
|
|
}
|
|
// else: fall back to raw stdout (e.g. older kit binary without --json)
|
|
|
|
// Extract last non-empty line for the card.
|
|
lines := strings.Split(result, "\n")
|
|
var lastLine string
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if strings.TrimSpace(lines[i]) != "" {
|
|
lastLine = lines[i]
|
|
break
|
|
}
|
|
}
|
|
state.set("done", "", truncate(lastLine, 60), elapsed)
|
|
updateWidget()
|
|
updateFooter()
|
|
|
|
return result, 0, elapsed
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Orchestrator system prompt
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func buildOrchestratorPrompt(cwd string) string {
|
|
orchPath := filepath.Join(cwd, ".kit", "agents", "kit-kit", "orchestrator.md")
|
|
raw, err := os.ReadFile(orchPath)
|
|
if err != nil {
|
|
// Fallback: generate a basic orchestrator prompt.
|
|
return buildDefaultOrchestratorPrompt()
|
|
}
|
|
|
|
text := string(raw)
|
|
// Strip frontmatter if present.
|
|
if strings.HasPrefix(text, "---\n") {
|
|
if idx := strings.Index(text[4:], "\n---\n"); idx >= 0 {
|
|
text = strings.TrimSpace(text[4+idx+5:])
|
|
}
|
|
}
|
|
|
|
list := expertList()
|
|
catalog := buildExpertCatalog(list)
|
|
names := make([]string, len(list))
|
|
for i, s := range list {
|
|
names[i] = displayName(s.Def.Name)
|
|
}
|
|
|
|
text = strings.ReplaceAll(text, "{{EXPERT_COUNT}}", fmt.Sprintf("%d", len(list)))
|
|
text = strings.ReplaceAll(text, "{{EXPERT_NAMES}}", strings.Join(names, ", "))
|
|
text = strings.ReplaceAll(text, "{{EXPERT_CATALOG}}", catalog)
|
|
return text
|
|
}
|
|
|
|
func buildExpertCatalog(list []*expertState) string {
|
|
var sb strings.Builder
|
|
for _, s := range list {
|
|
fmt.Fprintf(&sb, "### %s\n", displayName(s.Def.Name))
|
|
fmt.Fprintf(&sb, "**Query as:** `%s`\n", s.Def.Name)
|
|
fmt.Fprintf(&sb, "%s\n\n", s.Def.Description)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func buildDefaultOrchestratorPrompt() string {
|
|
list := expertList()
|
|
names := make([]string, len(list))
|
|
for i, s := range list {
|
|
names[i] = displayName(s.Def.Name)
|
|
}
|
|
catalog := buildExpertCatalog(list)
|
|
|
|
return fmt.Sprintf(`You are Kit Kit, an orchestrator agent with %d domain experts: %s.
|
|
|
|
Use the query_experts tool to consult experts IN PARALLEL before writing code.
|
|
Always query multiple experts at once when the task spans multiple domains.
|
|
|
|
## Available Experts
|
|
|
|
%s
|
|
|
|
## Workflow
|
|
|
|
1. Analyze the user's request to identify which domains are relevant.
|
|
2. Use query_experts to ask specific questions of the relevant experts.
|
|
3. Synthesize the expert findings into a coherent implementation.
|
|
4. Write the actual code/files — you are the only agent that writes.
|
|
|
|
## Rules
|
|
|
|
- ALWAYS query experts before implementing. Never guess.
|
|
- Ask SPECIFIC questions. "How does X work?" is better than "Tell me about X".
|
|
- Query multiple experts in a single call when possible (they run in parallel).
|
|
- If an expert returns insufficient info, query again with a more specific question.
|
|
`, len(list), strings.Join(names, ", "), catalog)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Init
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func Init(api ext.API) {
|
|
kitBinary = findKitBinary()
|
|
|
|
// ── Session Start: load experts, show grid ──
|
|
api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) {
|
|
mu.Lock()
|
|
latestCtx = ctx
|
|
hasCtx = true
|
|
mu.Unlock()
|
|
|
|
loadExperts(ctx.CWD)
|
|
updateWidget()
|
|
updateFooter()
|
|
|
|
names := expertNames()
|
|
n := len(expertList())
|
|
if n > 0 {
|
|
ctx.PrintInfo(fmt.Sprintf(
|
|
"Kit Kit loaded — %d experts: %s\n\n"+
|
|
"/experts List experts and status\n"+
|
|
"/experts-grid N Set grid columns (1-5)\n\n"+
|
|
"Ask me to build any Kit component!",
|
|
n, names))
|
|
} else {
|
|
ctx.PrintInfo(
|
|
"Kit Kit loaded — no experts found.\n\n" +
|
|
"Add agent .md files to .kit/agents/kit-kit/ to get started.\n" +
|
|
"See examples/extensions/kit-kit-agents/ for samples.")
|
|
}
|
|
})
|
|
|
|
// ── Before Agent Start: inject orchestrator system prompt ──
|
|
api.OnBeforeAgentStart(func(_ ext.BeforeAgentStartEvent, ctx ext.Context) *ext.BeforeAgentStartResult {
|
|
mu.Lock()
|
|
latestCtx = ctx
|
|
mu.Unlock()
|
|
|
|
prompt := buildOrchestratorPrompt(ctx.CWD)
|
|
return &ext.BeforeAgentStartResult{SystemPrompt: &prompt}
|
|
})
|
|
|
|
// ── Agent End: update footer ──
|
|
api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) {
|
|
mu.Lock()
|
|
latestCtx = ctx
|
|
mu.Unlock()
|
|
updateFooter()
|
|
})
|
|
|
|
// ── Session Shutdown: cleanup ──
|
|
api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) {
|
|
ctx.RemoveWidget("kit-kit:grid")
|
|
ctx.RemoveFooter()
|
|
})
|
|
|
|
// ── Tool: query_experts ──
|
|
api.RegisterTool(ext.ToolDef{
|
|
Name: "query_experts",
|
|
Description: `Query one or more Kit domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses.
|
|
|
|
Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together.
|
|
|
|
Available experts are loaded from .kit/agents/kit-kit/*.md at session start. The default set includes:
|
|
- ext-expert: Kit extensions — tools, events, commands, widgets, editor interceptors
|
|
- tui-expert: Kit TUI — Bubble Tea v2 components, rendering, theming, layout
|
|
- llm-expert: Kit LLM system — providers, streaming, agent loop, tool execution
|
|
|
|
Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`,
|
|
Parameters: `{
|
|
"type": "object",
|
|
"properties": {
|
|
"queries": {
|
|
"type": "array",
|
|
"description": "Array of expert queries to run in parallel",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"expert": {
|
|
"type": "string",
|
|
"description": "Expert name (e.g. ext-expert, tui-expert, llm-expert)"
|
|
},
|
|
"question": {
|
|
"type": "string",
|
|
"description": "Specific question about what you need to build"
|
|
}
|
|
},
|
|
"required": ["expert", "question"]
|
|
}
|
|
}
|
|
},
|
|
"required": ["queries"]
|
|
}`,
|
|
Execute: func(input string) (string, error) {
|
|
var params struct {
|
|
Queries []struct {
|
|
Expert string `json:"expert"`
|
|
Question string `json:"question"`
|
|
} `json:"queries"`
|
|
}
|
|
if err := json.Unmarshal([]byte(input), ¶ms); err != nil {
|
|
return "", fmt.Errorf("invalid parameters: %w", err)
|
|
}
|
|
if len(params.Queries) == 0 {
|
|
return "No queries provided.", nil
|
|
}
|
|
|
|
// Launch all experts in parallel.
|
|
type result struct {
|
|
Expert string
|
|
Question string
|
|
Output string
|
|
ExitCode int
|
|
Elapsed time.Duration
|
|
}
|
|
results := make([]result, len(params.Queries))
|
|
var wg sync.WaitGroup
|
|
|
|
for i, q := range params.Queries {
|
|
wg.Add(1)
|
|
go func(idx int, expert, question string) {
|
|
defer wg.Done()
|
|
out, code, elapsed := queryExpert(expert, question)
|
|
results[idx] = result{
|
|
Expert: expert,
|
|
Question: question,
|
|
Output: out,
|
|
ExitCode: code,
|
|
Elapsed: elapsed,
|
|
}
|
|
}(i, q.Expert, q.Question)
|
|
}
|
|
wg.Wait()
|
|
|
|
// Build combined response.
|
|
var sb strings.Builder
|
|
for _, r := range results {
|
|
icon := "✓"
|
|
if r.ExitCode != 0 {
|
|
icon = "✗"
|
|
}
|
|
fmt.Fprintf(&sb, "## [%s] %s (%ds)\n\n",
|
|
icon, displayName(r.Expert), int(r.Elapsed.Seconds()))
|
|
|
|
out := r.Output
|
|
if len(out) > 12000 {
|
|
out = out[:12000] + "\n\n... [truncated — ask follow-up for more]"
|
|
}
|
|
sb.WriteString(out)
|
|
sb.WriteString("\n\n---\n\n")
|
|
}
|
|
return sb.String(), nil
|
|
},
|
|
})
|
|
|
|
// ── Tool Renderer: query_experts ──
|
|
api.RegisterToolRenderer(ext.ToolRenderConfig{
|
|
ToolName: "query_experts",
|
|
DisplayName: "Query Experts",
|
|
BorderColor: "#89b4fa",
|
|
RenderHeader: func(toolArgs string, width int) string {
|
|
var args struct {
|
|
Queries []struct {
|
|
Expert string `json:"expert"`
|
|
} `json:"queries"`
|
|
}
|
|
if err := json.Unmarshal([]byte(toolArgs), &args); err != nil {
|
|
return ""
|
|
}
|
|
names := make([]string, len(args.Queries))
|
|
for i, q := range args.Queries {
|
|
names[i] = displayName(q.Expert)
|
|
}
|
|
header := fmt.Sprintf("%d experts in parallel: %s",
|
|
len(args.Queries), strings.Join(names, ", "))
|
|
return truncate(header, width)
|
|
},
|
|
RenderBody: func(toolResult string, isError bool, width int) string {
|
|
if isError {
|
|
return "" // fall back to default
|
|
}
|
|
// Show compact summary: extract ## headers with status
|
|
var lines []string
|
|
for _, line := range strings.Split(toolResult, "\n") {
|
|
if strings.HasPrefix(line, "## [") {
|
|
lines = append(lines, line[3:]) // strip "## "
|
|
}
|
|
}
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(lines, " · ")
|
|
},
|
|
})
|
|
|
|
// ── Command: /experts ──
|
|
api.RegisterCommand(ext.CommandDef{
|
|
Name: "experts",
|
|
Description: "List available Kit Kit experts and their status",
|
|
Execute: func(args string, ctx ext.Context) (string, error) {
|
|
mu.Lock()
|
|
latestCtx = ctx
|
|
mu.Unlock()
|
|
|
|
list := expertList()
|
|
if len(list) == 0 {
|
|
return "No experts loaded. Add agent .md files to .kit/agents/kit-kit/", nil
|
|
}
|
|
var sb strings.Builder
|
|
for _, s := range list {
|
|
status, _, _, _, qc := s.snapshot()
|
|
fmt.Fprintf(&sb, "%s (%s, queries: %d): %s\n",
|
|
displayName(s.Def.Name), status, qc, s.Def.Description)
|
|
}
|
|
return sb.String(), nil
|
|
},
|
|
})
|
|
|
|
// ── Command: /experts-grid ──
|
|
api.RegisterCommand(ext.CommandDef{
|
|
Name: "experts-grid",
|
|
Description: "Set expert grid columns: /experts-grid <1-5>",
|
|
Execute: func(args string, ctx ext.Context) (string, error) {
|
|
mu.Lock()
|
|
latestCtx = ctx
|
|
mu.Unlock()
|
|
|
|
args = strings.TrimSpace(args)
|
|
n := 0
|
|
if _, err := fmt.Sscanf(args, "%d", &n); err != nil || n < 1 || n > 5 {
|
|
return "Usage: /experts-grid <1-5>", nil
|
|
}
|
|
mu.Lock()
|
|
gridCols = n
|
|
mu.Unlock()
|
|
updateWidget()
|
|
return fmt.Sprintf("Grid set to %d columns.", n), nil
|
|
},
|
|
})
|
|
}
|