This repository has been archived on 2026-06-13. You can view files and clone it, but cannot push or open issues or pull requests.
anvillm/internal/backends/kirocli.go

402 lines
10 KiB
Go

// Package backends provides concrete backend implementations
package backends
import (
"anvillm/internal/backend"
"anvillm/internal/backend/tmux"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// NewKiroCLI creates a kiro-cli backend
// Runs with --trust-all-tools because landrun provides the actual sandboxing
func NewKiroCLI() backend.Backend {
return tmux.New(tmux.Config{
Name: "kiro-cli",
Command: []string{"kiro-cli", "chat", "--trust-all-tools"},
Environment: map[string]string{
"TERM": "xterm-256color",
"NO_COLOR": "1",
"COLUMNS": "999",
},
TmuxSize: tmux.TmuxSize{
Rows: 40,
Cols: 120,
},
StartupTime: 30 * time.Second,
Detector: &kiroDetector{},
Cleaner: &kiroCleaner{},
Commands: &kiroCommands{},
})
}
// kiroDetector implements pattern detection for kiro-cli
type kiroDetector struct{}
func (d *kiroDetector) IsReady(output string) bool {
clean := stripANSI(output)
return strings.Contains(clean, "> ")
}
func (d *kiroDetector) IsComplete(prompt string, output string) bool {
isCommand := strings.HasPrefix(strings.TrimSpace(prompt), "/")
if isCommand {
// Slash commands: wait for next prompt line: [agent] > or !>
clean := stripANSI(output)
clean = strings.ReplaceAll(clean, "\r", "")
// Need substantial content first
if len(clean) < 200 {
return false
}
// Look for prompt pattern: [something] > or !>
lines := strings.Split(clean, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
// Match either [agent] > or !>
if (strings.Contains(line, "] ") && strings.Contains(line, ">")) || strings.HasPrefix(line, "!>") {
return true
}
}
return false
} else {
// Normal prompts: wait for "Time:" marker
return strings.Contains(output, "Time:")
}
}
// kiroCleaner implements output cleaning for kiro-cli
type kiroCleaner struct{}
func (c *kiroCleaner) Clean(prompt string, rawOutput string) string {
isCommand := strings.HasPrefix(strings.TrimSpace(prompt), "/")
if isCommand {
return c.cleanCommand(rawOutput)
}
return c.cleanResponse(rawOutput)
}
func (c *kiroCleaner) cleanResponse(raw string) string {
// Strip ANSI codes
clean := stripANSI(raw)
clean = strings.ReplaceAll(clean, "\r", "\n")
lines := strings.Split(clean, "\n")
var result []string
foundUserPrompt := false
inResponse := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Look for user prompt: "!>" or "[agent] !>"
if !foundUserPrompt && strings.Contains(trimmed, "!>") {
foundUserPrompt = true
continue
}
// Skip echoed user input until we see response marker "> " (but not "!> ")
if foundUserPrompt && !inResponse && trimmed != "" {
// Response marker is "> " not preceded by "!"
idx := strings.Index(trimmed, "> ")
if idx < 0 || (idx > 0 && trimmed[idx-1] == '!') {
continue
}
}
// Stop at next prompt containing "!>"
if foundUserPrompt && inResponse && strings.Contains(trimmed, "!>") {
break
}
// After user prompt, start collecting non-noise content
if foundUserPrompt && trimmed != "" {
// Check if line contains actual content (even if it has noise)
// Important markers: "I will run", "using tool:", "Purpose:", ">", or actual content after spinners
hasContent := false
text := trimmed
// If line contains "Thinking..." followed by content, extract the content
if strings.Contains(line, "Thinking...") {
idx := strings.Index(line, "Thinking...")
afterThinking := strings.TrimSpace(line[idx+11:]) // "Thinking..." is 11 chars
if afterThinking != "" {
text = afterThinking
hasContent = true
}
}
// Check for important markers
if !hasContent && (strings.Contains(trimmed, "I will run") ||
strings.Contains(trimmed, "using tool:") ||
strings.Contains(trimmed, "Purpose:") ||
strings.Contains(trimmed, ">")) {
hasContent = true
}
// If no content markers and it's noise, skip
if !hasContent && c.isNoise(line) {
continue
}
// If we got here, it's not pure noise
if !hasContent {
text = trimmed
}
// Start collecting
if !inResponse {
inResponse = true
}
// If line has ">", strip it (for assistant response lines)
if strings.Contains(text, ">") {
idx := strings.Index(text, ">")
if idx == 0 || (idx > 0 && text[idx-1] != '!') {
text = strings.TrimSpace(text[idx+1:])
}
}
if text != "" {
result = append(result, text)
}
continue
}
// Before user prompt, skip noise and look for hooks
if c.isNoise(line) {
continue
}
// Keep hooks lines
if strings.Contains(line, "hooks finished") {
result = append(result, trimmed)
continue
}
}
return strings.Join(result, "\n")
}
func (c *kiroCleaner) cleanCommand(raw string) string {
clean := stripANSI(raw)
clean = strings.ReplaceAll(clean, "\r", "\n")
lines := strings.Split(clean, "\n")
var result []string
collecting := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Detect prompt lines: [agent] > or !>
isPrompt := (strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">")) ||
strings.HasPrefix(trimmed, "!>")
if isPrompt {
if collecting {
break // Second prompt = end
}
continue
}
// Check for summary line (contains "> ") - might be mixed with noise
if idx := strings.Index(trimmed, "> "); idx >= 0 {
// Extract everything from "> " onwards
summary := strings.TrimSpace(trimmed[idx:])
collecting = true
result = append(result, summary)
continue
}
// Skip command echo and noise
if strings.HasPrefix(trimmed, "/") ||
strings.Contains(trimmed, "Switching to") ||
c.isNoise(line) {
continue
}
collecting = true
result = append(result, trimmed)
}
return strings.Join(result, "\n")
}
func (c *kiroCleaner) isNoise(line string) bool {
trimmed := strings.TrimSpace(line)
// Check for noise patterns
noisePatterns := []string{
"Thinking...",
"▸ Credits:",
"▸ Time:",
}
for _, pattern := range noisePatterns {
if strings.Contains(line, pattern) {
return true
}
}
// Check for spinner characters
spinners := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', '⢀', '⡀', '✓'}
for _, r := range spinners {
if strings.HasPrefix(trimmed, string(r)) {
return true
}
}
// Check for prompt lines
if strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">") {
return true
}
return false
}
// kiroCommands implements command handling for kiro-cli
type kiroCommands struct{}
func (h *kiroCommands) IsSupported(command string) bool {
supported := []string{"/chat load", "/chat save", "/help", "/agent list"}
for _, cmd := range supported {
if strings.HasPrefix(command, cmd) {
return true
}
}
return false
}
func (h *kiroCommands) Execute(ctx context.Context, command string) (string, error) {
// For kiro-cli, commands are executed by sending them to the PTY
// This is handled by the Session.Send() method
// We just validate the command here
if !h.IsSupported(command) {
return "", fmt.Errorf("unsupported command")
}
return "", fmt.Errorf("execute called directly - use Send() instead")
}
// Helper functions
func stripANSI(s string) string {
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
return ansiRegex.ReplaceAllString(s, "")
}
// SessionsDir returns the path to the sessions storage directory
func SessionsDir() string {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".config", "anvillm", "sessions")
os.MkdirAll(dir, 0755)
return dir
}
// GenerateSmartFilename creates a descriptive filename from saved conversation JSON
func GenerateSmartFilename(jsonPath string, windowBody string) (string, error) {
data, err := os.ReadFile(jsonPath)
if err != nil {
return "", err
}
// Extract user prompt text - try JSON first, then window body
var summaryText string
// Try to find user prompts in the JSON
promptRe := regexp.MustCompile(`"prompt"\s*:\s*"([^"]{10,200})"`)
if matches := promptRe.FindAllStringSubmatch(string(data), 5); len(matches) > 0 {
for _, m := range matches {
text := m[1]
// Skip if it looks like JSON/tool output
if !strings.Contains(text, "exit_status") && !strings.Contains(text, "\\n\\t") {
summaryText = text
break
}
}
}
// If no prompt in JSON, try to extract from window body
if summaryText == "" && windowBody != "" {
if idx := strings.Index(windowBody, "USER:"); idx >= 0 {
userText := strings.TrimSpace(windowBody[idx+5:])
// Take up to next section marker
if end := strings.Index(userText, "\n\nA:"); end > 0 {
userText = userText[:end]
} else if end := strings.Index(userText, "\n\nASSISTANT:"); end > 0 {
userText = userText[:end]
} else if end := strings.Index(userText, "\n---"); end > 0 {
userText = userText[:end]
}
userText = strings.TrimSpace(userText)
if len(userText) > 200 {
userText = userText[:200]
}
if len(userText) > 5 {
summaryText = userText
}
}
}
// Extract words for filename
var parts []string
if summaryText != "" {
summaryText = strings.ToLower(summaryText)
wordRe := regexp.MustCompile(`[a-z]+`)
words := wordRe.FindAllString(summaryText, 10)
stopWords := []string{
"a", "an", "and", "are", "as", "at", "be", "by", "can", "do",
"for", "from", "has", "have", "he", "her", "his", "how", "if",
"in", "into", "is", "it", "its", "just", "me", "my", "no", "not",
"of", "on", "or", "our", "out", "so", "some", "than", "that",
"the", "their", "them", "then", "there", "these", "they", "this",
"to", "up", "us", "was", "we", "what", "when", "which", "who",
"will", "with", "would", "you", "your",
}
for _, w := range words {
if len(w) > 2 && !contains(stopWords, w) {
parts = append(parts, w)
if len(parts) >= 5 {
break
}
}
}
}
if len(parts) == 0 {
parts = append(parts, "chat")
}
filename := time.Now().Format("20060102T150405") + "-" + strings.Join(parts, "-") + ".json"
return filepath.Join(SessionsDir(), filename), nil
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// SendCtl is a helper to send a control command to kiro-cli
func SendCtl(sess backend.Session, cmd string) error {
_, err := sess.Send(context.Background(), cmd)
return err
}