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

431 lines
11 KiB
Go

// Package backends provides concrete backend implementations
package backends
import (
"anvillm/internal/backend"
"anvillm/internal/backend/tmux"
"anvillm/internal/debug"
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// NewKiroCLI creates a kiro-cli backend
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] >
clean := stripANSI(output)
clean = strings.ReplaceAll(clean, "\r", "")
// Need substantial content first
if len(clean) < 200 {
return false
}
// Look for prompt pattern: [something] >
lines := strings.Split(clean, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if strings.Contains(lines[i], "] ") && strings.Contains(lines[i], ">") {
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 (starts with "!>")
if !foundUserPrompt && strings.HasPrefix(trimmed, "!>") {
foundUserPrompt = true
continue
}
// Skip echoed user input (the line right after !> that doesn't have markers)
if foundUserPrompt && !inResponse && trimmed != "" &&
!strings.Contains(trimmed, "Thinking...") &&
!strings.Contains(trimmed, "I will run") &&
!strings.Contains(trimmed, "using tool:") &&
!strings.Contains(trimmed, "Purpose:") &&
!strings.Contains(trimmed, ">") {
continue
}
// Stop at next prompt (starts with "!>") - but only after we've started collecting
if foundUserPrompt && inResponse && strings.HasPrefix(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] >
isPrompt := strings.Contains(line, "[") && strings.Contains(line, "]") && strings.Contains(line, ">")
if isPrompt {
if collecting {
break // Second prompt = end
}
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", "acme-q", "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
}
// SavePath returns the save file path for a session ID
func SavePath(id string) string {
return filepath.Join(SessionsDir(), id+".json")
}
// 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
}
// Save saves a session using kiro-cli's /chat save command
func Save(sess backend.Session, id string) error {
return SendCtl(sess, "/chat save "+SavePath(id))
}
// Load loads a session using kiro-cli's /chat load command
func Load(sess backend.Session, path string) error {
return SendCtl(sess, "/chat load "+path)
}
// SaveWithSmartFilename saves with a generated filename
func SaveWithSmartFilename(sess backend.Session, id string, bodyText string) (string, error) {
tempPath := SavePath(id)
if err := SendCtl(sess, "/chat save "+tempPath); err != nil {
return "", err
}
smartPath, err := GenerateSmartFilename(tempPath, bodyText)
if err != nil {
debug.Log("[session %s] Smart filename failed: %v, keeping %s", id, err, tempPath)
return tempPath, nil
}
if smartPath != tempPath {
if err := os.Rename(tempPath, smartPath); err == nil {
return smartPath, nil
} else {
debug.Log("[session %s] Rename failed: %v", id, err)
return tempPath, nil
}
}
return smartPath, nil
}