Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

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
}