f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
827 lines
20 KiB
Go
827 lines
20 KiB
Go
package insight
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// geminiInsightModel is the model passed to the gemini CLI
|
|
// for insight generation.
|
|
const geminiInsightModel = "gemini-3.1-pro-preview"
|
|
|
|
// claudeStdoutLogMaxBytes bounds Claude stdout log payloads sent to
|
|
// SSE/UI so a single large JSON response does not overwhelm the log
|
|
// stream. Full stdout is still parsed for result extraction.
|
|
const claudeStdoutLogMaxBytes = 16 * 1024
|
|
|
|
// Result holds the output from an AI agent invocation.
|
|
type Result struct {
|
|
Content string
|
|
Agent string
|
|
Model string
|
|
}
|
|
|
|
// ValidAgentNames lists the supported insight agent names in display
|
|
// order. ValidAgents is a lookup set derived from it.
|
|
var ValidAgentNames = []string{
|
|
"claude",
|
|
"codex",
|
|
"copilot",
|
|
"gemini",
|
|
"kiro",
|
|
}
|
|
|
|
// ValidAgents is the set of supported insight agent names.
|
|
var ValidAgents = func() map[string]bool {
|
|
m := make(map[string]bool, len(ValidAgentNames))
|
|
for _, name := range ValidAgentNames {
|
|
m[name] = true
|
|
}
|
|
return m
|
|
}()
|
|
|
|
// GenerateFunc is the signature for insight generation,
|
|
// allowing tests to substitute a stub.
|
|
type GenerateFunc func(
|
|
ctx context.Context, agent, prompt string,
|
|
) (Result, error)
|
|
|
|
// LogEvent represents one log line emitted by an insight
|
|
// agent process.
|
|
type LogEvent struct {
|
|
Stream string `json:"stream"` // stdout|stderr
|
|
Line string `json:"line"`
|
|
}
|
|
|
|
// LogFunc receives real-time log events from the agent
|
|
// subprocess.
|
|
type LogFunc func(LogEvent)
|
|
|
|
// GenerateStreamFunc is like GenerateFunc but includes a
|
|
// log callback for streaming stdout/stderr events.
|
|
type GenerateStreamFunc func(
|
|
ctx context.Context, agent, prompt string, onLog LogFunc,
|
|
) (Result, error)
|
|
|
|
// AgentConfig holds insight generation overrides for one agent.
|
|
type AgentConfig struct {
|
|
Binary string
|
|
Sandbox string
|
|
AllowUnsafe bool
|
|
}
|
|
|
|
// GenerateOptions holds optional insight generation overrides.
|
|
type GenerateOptions struct {
|
|
Agents map[string]AgentConfig
|
|
}
|
|
|
|
// Generate invokes an AI agent CLI to generate an insight.
|
|
// The agent parameter selects which CLI to use (claude,
|
|
// codex, gemini). The prompt is passed via stdin.
|
|
func Generate(
|
|
ctx context.Context, agent, prompt string,
|
|
) (Result, error) {
|
|
return GenerateStream(ctx, agent, prompt, nil)
|
|
}
|
|
|
|
// GenerateStream invokes an AI agent CLI to generate an
|
|
// insight while optionally streaming process logs.
|
|
func GenerateStream(
|
|
ctx context.Context, agent, prompt string, onLog LogFunc,
|
|
) (Result, error) {
|
|
return GenerateStreamWithOptions(
|
|
ctx, agent, prompt, onLog, GenerateOptions{},
|
|
)
|
|
}
|
|
|
|
// GenerateStreamWithOptions invokes an AI agent CLI to generate an
|
|
// insight, using configured binary paths before falling back to PATH.
|
|
func GenerateStreamWithOptions(
|
|
ctx context.Context, agent, prompt string, onLog LogFunc,
|
|
opts GenerateOptions,
|
|
) (Result, error) {
|
|
if !ValidAgents[agent] {
|
|
return Result{}, fmt.Errorf(
|
|
"unsupported agent: %s", agent,
|
|
)
|
|
}
|
|
|
|
path, err := resolveAgentBinary(agent, opts)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"%s CLI not found: %w", agent, err,
|
|
)
|
|
}
|
|
|
|
switch agent {
|
|
case "codex":
|
|
return generateCodex(ctx, path, prompt, onLog)
|
|
case "copilot":
|
|
return generateCopilot(ctx, path, prompt, onLog)
|
|
case "gemini":
|
|
return generateGemini(
|
|
ctx, path, prompt, onLog, opts.Agents[agent],
|
|
)
|
|
case "kiro":
|
|
return generateKiro(ctx, path, prompt, onLog)
|
|
default:
|
|
return generateClaude(ctx, path, prompt, onLog)
|
|
}
|
|
}
|
|
|
|
func resolveAgentBinary(agent string, opts GenerateOptions) (string, error) {
|
|
if cfg, ok := opts.Agents[agent]; ok && strings.TrimSpace(cfg.Binary) != "" {
|
|
return strings.TrimSpace(cfg.Binary), nil
|
|
}
|
|
return exec.LookPath(agentBinary(agent))
|
|
}
|
|
|
|
// agentEnv returns the current environment with
|
|
// CLAUDE_NO_SOUND=1 appended.
|
|
//
|
|
// The full environment is passed through intentionally.
|
|
// Agent CLIs need provider auth (API keys, tokens, config
|
|
// paths) that vary across providers, users, and deployment
|
|
// methods (env vars, desktop.env, persisted login). An
|
|
// allowlist is brittle here: every new provider or config
|
|
// var requires a code change, and missing one breaks auth.
|
|
// Sandboxing is handled by CLI flags (--tools, --sandbox,
|
|
// --config-dir, etc.), not by env filtering.
|
|
func agentEnv() []string {
|
|
return append(os.Environ(), "CLAUDE_NO_SOUND=1")
|
|
}
|
|
|
|
func emitLog(onLog LogFunc, stream, line string) {
|
|
if onLog == nil {
|
|
return
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return
|
|
}
|
|
onLog(LogEvent{
|
|
Stream: stream,
|
|
Line: line,
|
|
})
|
|
}
|
|
|
|
func truncateLogLine(line string, maxBytes int) string {
|
|
if maxBytes <= 0 || len(line) <= maxBytes {
|
|
return line
|
|
}
|
|
omitted := len(line) - maxBytes
|
|
return fmt.Sprintf(
|
|
"%s... [truncated %d bytes]",
|
|
line[:maxBytes], omitted,
|
|
)
|
|
}
|
|
|
|
func collectStreamLines(
|
|
r io.Reader, stream string, onLog LogFunc,
|
|
) <-chan string {
|
|
ch := make(chan string, 1)
|
|
go func() {
|
|
defer close(ch)
|
|
br := bufio.NewReader(r)
|
|
var lines []string
|
|
for {
|
|
line, err := br.ReadString('\n')
|
|
trimmed := strings.TrimRight(line, "\r\n")
|
|
if trimmed != "" {
|
|
lines = append(lines, trimmed)
|
|
emitLog(onLog, stream, trimmed)
|
|
}
|
|
if err == nil {
|
|
continue
|
|
}
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
emitLog(
|
|
onLog, "stderr",
|
|
fmt.Sprintf("read %s: %v", stream, err),
|
|
)
|
|
_, _ = io.Copy(io.Discard, br)
|
|
break
|
|
}
|
|
ch <- strings.Join(lines, "\n")
|
|
}()
|
|
return ch
|
|
}
|
|
|
|
// generateClaude invokes `claude -p --output-format json`.
|
|
func generateClaude(
|
|
ctx context.Context, path, prompt string, onLog LogFunc,
|
|
) (Result, error) {
|
|
cmd := exec.CommandContext(
|
|
ctx, path,
|
|
"-p", "--output-format", "json",
|
|
"--no-session-persistence",
|
|
"--tools", "",
|
|
)
|
|
cmd.Env = agentEnv()
|
|
cmd.Stdin = strings.NewReader(prompt)
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"create stdout pipe: %w", err,
|
|
)
|
|
}
|
|
stderrPipe, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"create stderr pipe: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return Result{}, fmt.Errorf("start claude: %w", err)
|
|
}
|
|
|
|
stderrDone := collectStreamLines(
|
|
stderrPipe, "stderr", onLog,
|
|
)
|
|
stdoutBytes, readErr := io.ReadAll(stdoutPipe)
|
|
if readErr != nil {
|
|
_, _ = io.Copy(io.Discard, stdoutPipe)
|
|
}
|
|
stderrText := <-stderrDone
|
|
runErr := cmd.Wait()
|
|
if readErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"read claude stdout: %w", readErr,
|
|
)
|
|
}
|
|
|
|
emitLog(
|
|
onLog,
|
|
"stdout",
|
|
truncateLogLine(string(stdoutBytes), claudeStdoutLogMaxBytes),
|
|
)
|
|
|
|
// Honor context cancellation over salvaging stdout, but
|
|
// only when the command actually failed. A successful
|
|
// cmd.Run with a race-y post-completion cancel should
|
|
// still return the valid result.
|
|
if runErr != nil && ctx.Err() != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"claude CLI cancelled: %w", ctx.Err(),
|
|
)
|
|
}
|
|
|
|
// Claude Code CLI outputs a JSON array of events when
|
|
// invoked with -p --output-format json. Find the element
|
|
// with type="result" and extract its result field.
|
|
// Also accept the legacy single-object format as a
|
|
// fallback for older Claude CLI versions.
|
|
content, model := parseCLIResult(stdoutBytes)
|
|
if strings.TrimSpace(content) != "" {
|
|
return Result{
|
|
Content: content,
|
|
Agent: "claude",
|
|
Model: model,
|
|
}, nil
|
|
}
|
|
|
|
if runErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"claude CLI failed: %w\nstderr: %s",
|
|
runErr, stderrText,
|
|
)
|
|
}
|
|
|
|
return Result{}, fmt.Errorf(
|
|
"claude returned empty result\nraw: %s",
|
|
string(stdoutBytes),
|
|
)
|
|
}
|
|
|
|
// parseCLIResult extracts the result text and model from
|
|
// claude CLI output. Claude Code (v2+) outputs a JSON array
|
|
// of events; we find type="result" and read its result field.
|
|
// Falls back to the legacy single-object format for older
|
|
// versions: {"result":"...","model":"..."}.
|
|
func parseCLIResult(data []byte) (result, model string) {
|
|
// Try JSON array format (Claude Code v2+).
|
|
var events []json.RawMessage
|
|
if json.Unmarshal(data, &events) == nil {
|
|
for _, raw := range events {
|
|
var ev struct {
|
|
Type string `json:"type"`
|
|
Result string `json:"result"`
|
|
ModelUsage map[string]json.RawMessage `json:"modelUsage"`
|
|
}
|
|
if json.Unmarshal(raw, &ev) != nil {
|
|
continue
|
|
}
|
|
if ev.Type == "result" &&
|
|
strings.TrimSpace(ev.Result) != "" {
|
|
if len(ev.ModelUsage) > 0 {
|
|
keys := make([]string, 0, len(ev.ModelUsage))
|
|
for k := range ev.ModelUsage {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
model = keys[0]
|
|
}
|
|
return ev.Result, model
|
|
}
|
|
}
|
|
}
|
|
// Fall back to legacy single-object format.
|
|
var resp struct {
|
|
Result string `json:"result"`
|
|
Model string `json:"model"`
|
|
}
|
|
if json.Unmarshal(data, &resp) == nil {
|
|
return resp.Result, resp.Model
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// generateCodex invokes `codex exec` in read-only sandbox
|
|
// and parses the JSONL stream for agent_message items.
|
|
func generateCodex(
|
|
ctx context.Context, path, prompt string, onLog LogFunc,
|
|
) (Result, error) {
|
|
cmd := exec.CommandContext(
|
|
ctx, path,
|
|
"exec", "--json",
|
|
"--sandbox", "read-only",
|
|
"--skip-git-repo-check",
|
|
"--ephemeral",
|
|
"-",
|
|
)
|
|
cmd.Env = agentEnv()
|
|
cmd.Stdin = strings.NewReader(prompt)
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"create stdout pipe: %w", err,
|
|
)
|
|
}
|
|
stderrPipe, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"create stderr pipe: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"start codex: %w", err,
|
|
)
|
|
}
|
|
|
|
stderrDone := collectStreamLines(
|
|
stderrPipe, "stderr", onLog,
|
|
)
|
|
content, parseErr := parseCodexStream(stdoutPipe, onLog)
|
|
|
|
// Drain remaining stdout so cmd.Wait doesn't block.
|
|
if parseErr != nil {
|
|
_, _ = io.Copy(io.Discard, stdoutPipe)
|
|
}
|
|
|
|
stderrText := <-stderrDone
|
|
if waitErr := cmd.Wait(); waitErr != nil {
|
|
if parseErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"codex failed: %w (parse: %v)\nstderr: %s",
|
|
waitErr, parseErr, stderrText,
|
|
)
|
|
}
|
|
return Result{}, fmt.Errorf(
|
|
"codex failed: %w\nstderr: %s",
|
|
waitErr, stderrText,
|
|
)
|
|
}
|
|
if parseErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"parse codex stream: %w\nstderr: %s",
|
|
parseErr, stderrText,
|
|
)
|
|
}
|
|
|
|
return Result{
|
|
Content: content,
|
|
Agent: "codex",
|
|
}, nil
|
|
}
|
|
|
|
// codexEvent represents a JSONL event from codex --json.
|
|
type codexEvent struct {
|
|
Type string `json:"type"`
|
|
Error struct {
|
|
Message string `json:"message,omitempty"`
|
|
} `json:"error,omitempty"`
|
|
Item struct {
|
|
ID string `json:"id,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
} `json:"item,omitempty"`
|
|
}
|
|
|
|
// parseCodexStream reads codex JSONL and extracts
|
|
// agent_message text from item.completed/item.updated events.
|
|
func parseCodexStream(
|
|
r io.Reader, onLog LogFunc,
|
|
) (string, error) {
|
|
br := bufio.NewReader(r)
|
|
messages := make([]string, 0)
|
|
indexByID := make(map[string]int)
|
|
|
|
for {
|
|
line, err := br.ReadString('\n')
|
|
if err != nil && err != io.EOF {
|
|
return "", fmt.Errorf("read stream: %w", err)
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
emitLog(onLog, "stdout", trimmed)
|
|
if trimmed != "" {
|
|
var ev codexEvent
|
|
if json.Unmarshal(
|
|
[]byte(trimmed), &ev,
|
|
) == nil {
|
|
if ev.Type == "turn.failed" ||
|
|
ev.Type == "error" {
|
|
msg := ev.Error.Message
|
|
if msg == "" {
|
|
msg = "codex stream error"
|
|
}
|
|
return "", fmt.Errorf(
|
|
"codex: %s", msg,
|
|
)
|
|
}
|
|
|
|
isMsg := (ev.Type == "item.completed" ||
|
|
ev.Type == "item.updated") &&
|
|
ev.Item.Type == "agent_message" &&
|
|
ev.Item.Text != ""
|
|
if isMsg {
|
|
if ev.Item.ID == "" {
|
|
messages = append(
|
|
messages, ev.Item.Text,
|
|
)
|
|
} else if idx, ok := indexByID[ev.Item.ID]; ok {
|
|
messages[idx] = ev.Item.Text
|
|
} else {
|
|
indexByID[ev.Item.ID] = len(messages)
|
|
messages = append(
|
|
messages, ev.Item.Text,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
}
|
|
|
|
return strings.Join(messages, "\n"), nil
|
|
}
|
|
|
|
// generateCopilot invokes `copilot -p <prompt> --silent`.
|
|
// The prompt is passed as the -p argument (copilot does not
|
|
// read prompts from stdin). Output is plain text on stdout.
|
|
func generateCopilot(
|
|
ctx context.Context, path, prompt string, onLog LogFunc,
|
|
) (Result, error) {
|
|
cmd := exec.CommandContext(
|
|
ctx, path,
|
|
"-p", prompt,
|
|
"--silent",
|
|
"--no-custom-instructions",
|
|
"--no-ask-user",
|
|
"--disable-builtin-mcps",
|
|
)
|
|
cmd.Env = agentEnv()
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"create stdout pipe: %w", err,
|
|
)
|
|
}
|
|
stderrPipe, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"create stderr pipe: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"start copilot: %w", err,
|
|
)
|
|
}
|
|
|
|
stderrDone := collectStreamLines(
|
|
stderrPipe, "stderr", onLog,
|
|
)
|
|
// Read stdout raw to preserve blank lines in plain
|
|
// text output (collectStreamLines drops empty lines).
|
|
stdoutBytes, readErr := io.ReadAll(stdoutPipe)
|
|
stderrText := <-stderrDone
|
|
runErr := cmd.Wait()
|
|
|
|
if readErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"read copilot stdout: %w", readErr,
|
|
)
|
|
}
|
|
|
|
emitLog(onLog, "stdout", string(stdoutBytes))
|
|
|
|
if runErr != nil && ctx.Err() != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"copilot CLI cancelled: %w", ctx.Err(),
|
|
)
|
|
}
|
|
if runErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"copilot CLI failed: %w\nstderr: %s",
|
|
runErr, stderrText,
|
|
)
|
|
}
|
|
|
|
content := strings.TrimSpace(string(stdoutBytes))
|
|
if content == "" {
|
|
return Result{}, fmt.Errorf(
|
|
"copilot returned empty result",
|
|
)
|
|
}
|
|
|
|
return Result{
|
|
Content: content,
|
|
Agent: "copilot",
|
|
}, nil
|
|
}
|
|
|
|
// generateGemini invokes `gemini --output-format stream-json`
|
|
// and parses the JSONL stream for result/assistant messages.
|
|
func generateGemini(
|
|
ctx context.Context, path, prompt string, onLog LogFunc,
|
|
cfg AgentConfig,
|
|
) (Result, error) {
|
|
if strings.TrimSpace(cfg.Sandbox) == "" && !cfg.AllowUnsafe {
|
|
return Result{}, fmt.Errorf(
|
|
"gemini insights require an explicit sandbox or unsafe opt-in; set [agent.gemini].sandbox to a Gemini sandbox provider or [agent.gemini].allow_unsafe = true",
|
|
)
|
|
}
|
|
cmd := exec.CommandContext(
|
|
ctx, path,
|
|
"--model", geminiInsightModel,
|
|
"--output-format", "stream-json",
|
|
)
|
|
cmd.Env = agentEnv()
|
|
if sandbox := strings.TrimSpace(cfg.Sandbox); sandbox != "" {
|
|
cmd.Env = append(cmd.Env, "GEMINI_SANDBOX="+sandbox)
|
|
}
|
|
cmd.Stdin = strings.NewReader(prompt)
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"create stdout pipe: %w", err,
|
|
)
|
|
}
|
|
stderrPipe, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"create stderr pipe: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"start gemini: %w", err,
|
|
)
|
|
}
|
|
|
|
stderrDone := collectStreamLines(
|
|
stderrPipe, "stderr", onLog,
|
|
)
|
|
content, parseErr := parseStreamJSON(stdoutPipe, onLog)
|
|
|
|
// Drain remaining stdout so cmd.Wait doesn't block.
|
|
if parseErr != nil {
|
|
_, _ = io.Copy(io.Discard, stdoutPipe)
|
|
}
|
|
|
|
stderrText := <-stderrDone
|
|
if waitErr := cmd.Wait(); waitErr != nil {
|
|
if parseErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"gemini failed: %w (parse: %v)\nstderr: %s",
|
|
waitErr, parseErr, stderrText,
|
|
)
|
|
}
|
|
return Result{}, fmt.Errorf(
|
|
"gemini failed: %w\nstderr: %s",
|
|
waitErr, stderrText,
|
|
)
|
|
}
|
|
if parseErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"parse gemini stream: %w\nstderr: %s",
|
|
parseErr, stderrText,
|
|
)
|
|
}
|
|
|
|
return Result{
|
|
Content: content,
|
|
Agent: "gemini",
|
|
Model: geminiInsightModel,
|
|
}, nil
|
|
}
|
|
|
|
// streamMessage represents a JSONL event from stream-json
|
|
// output (shared format between Claude and Gemini CLIs).
|
|
type streamMessage struct {
|
|
Type string `json:"type"`
|
|
Role string `json:"role,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
Message struct {
|
|
Content string `json:"content,omitempty"`
|
|
} `json:"message,omitempty"`
|
|
Result string `json:"result,omitempty"`
|
|
Error struct {
|
|
Message string `json:"message,omitempty"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
// parseStreamJSON reads stream-json JSONL and extracts the
|
|
// result text. Prefers type=result, falls back to collecting
|
|
// assistant messages.
|
|
func parseStreamJSON(
|
|
r io.Reader, onLog LogFunc,
|
|
) (string, error) {
|
|
br := bufio.NewReader(r)
|
|
var lastResult string
|
|
var assistantMsgs []string
|
|
|
|
for {
|
|
line, err := br.ReadString('\n')
|
|
if err != nil && err != io.EOF {
|
|
return "", fmt.Errorf("read stream: %w", err)
|
|
}
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
emitLog(onLog, "stdout", trimmed)
|
|
if trimmed != "" {
|
|
var msg streamMessage
|
|
if json.Unmarshal(
|
|
[]byte(trimmed), &msg,
|
|
) != nil {
|
|
continue
|
|
}
|
|
if msg.Type == "error" {
|
|
m := msg.Error.Message
|
|
if m == "" {
|
|
m = "stream error"
|
|
}
|
|
return "", fmt.Errorf(
|
|
"stream: %s", m,
|
|
)
|
|
}
|
|
if msg.Type == "message" &&
|
|
msg.Role == "assistant" &&
|
|
msg.Content != "" {
|
|
assistantMsgs = append(
|
|
assistantMsgs, msg.Content,
|
|
)
|
|
}
|
|
if msg.Type == "assistant" &&
|
|
msg.Message.Content != "" {
|
|
assistantMsgs = append(
|
|
assistantMsgs,
|
|
msg.Message.Content,
|
|
)
|
|
}
|
|
if msg.Type == "result" &&
|
|
msg.Result != "" {
|
|
lastResult = msg.Result
|
|
}
|
|
}
|
|
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
}
|
|
|
|
if lastResult != "" {
|
|
return lastResult, nil
|
|
}
|
|
if len(assistantMsgs) > 0 {
|
|
return strings.Join(assistantMsgs, "\n"), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// agentBinary maps an agent name to its CLI binary name.
|
|
func agentBinary(agent string) string {
|
|
if agent == "kiro" {
|
|
return "kiro-cli"
|
|
}
|
|
return agent
|
|
}
|
|
|
|
// ansiRE matches ANSI escape sequences.
|
|
var ansiRE = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]`)
|
|
|
|
// generateKiro invokes `kiro-cli chat --no-interactive` with
|
|
// the prompt on stdin and strips ANSI escape codes from output.
|
|
//
|
|
// Note: kiro-cli does not have a hard no-tools/read-only mode
|
|
// like Claude's --tools "" or Codex's --sandbox read-only.
|
|
// --trust-tools= disables tool trust prompts but does not
|
|
// prevent tool execution. Use only with trusted session data.
|
|
// The working directory is set to os.TempDir() to limit
|
|
// exposure if tools are triggered.
|
|
func generateKiro(
|
|
ctx context.Context, path, prompt string, onLog LogFunc,
|
|
) (Result, error) {
|
|
cmd := exec.CommandContext(
|
|
ctx, path,
|
|
"chat",
|
|
"--no-interactive",
|
|
"--trust-tools=",
|
|
"--wrap", "never",
|
|
)
|
|
cmd.Env = agentEnv()
|
|
cmd.Dir = os.TempDir()
|
|
cmd.Stdin = strings.NewReader(prompt)
|
|
|
|
stdoutPipe, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("create stdout pipe: %w", err)
|
|
}
|
|
stderrPipe, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("create stderr pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return Result{}, fmt.Errorf("start kiro-cli: %w", err)
|
|
}
|
|
|
|
stderrDone := collectStreamLines(stderrPipe, "stderr", onLog)
|
|
stdoutBytes, readErr := io.ReadAll(stdoutPipe)
|
|
stderrText := <-stderrDone
|
|
runErr := cmd.Wait()
|
|
|
|
if readErr != nil {
|
|
return Result{}, fmt.Errorf("read kiro stdout: %w", readErr)
|
|
}
|
|
|
|
emitLog(onLog, "stdout", string(stdoutBytes))
|
|
|
|
if runErr != nil && ctx.Err() != nil {
|
|
return Result{}, fmt.Errorf("kiro-cli cancelled: %w", ctx.Err())
|
|
}
|
|
if runErr != nil {
|
|
return Result{}, fmt.Errorf(
|
|
"kiro-cli failed: %w\nstderr: %s", runErr, stderrText,
|
|
)
|
|
}
|
|
|
|
// Strip ANSI escape codes and the trust/timing banners.
|
|
clean := ansiRE.ReplaceAllString(string(stdoutBytes), "")
|
|
var lines []string
|
|
for line := range strings.SplitSeq(clean, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "Agents can sometimes") ||
|
|
strings.HasPrefix(trimmed, "Learn more at") ||
|
|
strings.HasPrefix(trimmed, "▸ Time:") {
|
|
continue
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
content := strings.TrimSpace(strings.Join(lines, "\n"))
|
|
if content == "" {
|
|
return Result{}, fmt.Errorf("kiro returned empty result")
|
|
}
|
|
|
|
return Result{
|
|
Content: content,
|
|
Agent: "kiro",
|
|
}, nil
|
|
}
|