diff --git a/cmd/bizdev-cli/main.go b/cmd/bizdev-cli/main.go index 30e04ffe..0764c941 100644 --- a/cmd/bizdev-cli/main.go +++ b/cmd/bizdev-cli/main.go @@ -24,7 +24,7 @@ import ( func main() { stageFlag := flag.String("stage", "", "执行成熟度阶段 (可选: V0.1_triage, V0.5_feasibility, V1.0_quoting)") taskKey := flag.String("task", "intake", "任务标识 (intake, research, cdo_quote 等)") - backend := flag.String("backend", "", "智能体后端 (留空则读取 .env 配置,可选: stub, pi-agent, cursor-cli)") + backend := flag.String("backend", "", "智能体后端 (留空则读取 .env 配置,可选: stub, pi-agent, pi-native)") project := flag.String("project", "医疗器械研发演示项目", "项目名称") customer := flag.String("customer", "北京水木东方", "客户名称") device := flag.String("device", "医疗器械", "设备类型") diff --git a/cmd/server/main.go b/cmd/server/main.go index c581b3d0..e203eede 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -19,7 +19,7 @@ func main() { envPath := flag.String("env", ".env", "环境配置文件路径") port := flag.Int("port", 0, "HTTP 服务端口 (默认读取配置文件或 8080)") host := flag.String("host", "", "HTTP 监听主机 (默认读取配置文件或 127.0.0.1)") - backend := flag.String("backend", "", "智能体后端调度覆盖 (stub, pi-agent, cursor-cli)") + backend := flag.String("backend", "", "智能体后端调度覆盖 (stub, pi-agent, pi-native)") flag.Parse() cfg, err := config.Load(*envPath) diff --git a/internal/agent/backend.go b/internal/agent/backend.go index 52922210..3a67a224 100644 --- a/internal/agent/backend.go +++ b/internal/agent/backend.go @@ -42,8 +42,8 @@ func NewBackend(name string, cfg *config.Config) Backend { switch name { case "pi-agent": inner = &PiAgentBackend{cfg: cfg} - case "cursor-cli": - inner = &CursorCLIBackend{cfg: cfg} + case "pi-native": + inner = &PiNativeBackend{cfg: cfg} default: inner = &StubBackend{} } diff --git a/internal/agent/cursor.go b/internal/agent/cursor.go deleted file mode 100644 index da78d50c..00000000 --- a/internal/agent/cursor.go +++ /dev/null @@ -1,114 +0,0 @@ -package agent - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "chat-agent-system/internal/config" -) - -type CursorCLIBackend struct { - cfg *config.Config -} - -func (c *CursorCLIBackend) Name() string { return "cursor-cli" } - -func (c *CursorCLIBackend) Execute(ctx context.Context, req Request) (*Result, error) { - if len(req.Targets) == 0 { - req.Targets = []string{"output.md"} - } - - workDir := req.WorkDir - if !filepath.IsAbs(workDir) { - wd, _ := os.Getwd() - workDir = filepath.Join(wd, workDir) - } - - payload, _ := json.Marshal(map[string]any{ - "workspace": workDir, - "api_key": c.cfg.CursorAPIKey, - "model": c.cfg.CursorModel, - "prompt": req.Prompt, - "instructions": req.Instructions, - "targets": req.Targets, - }) - - bridgeBin := c.cfg.CursorBridgeBin - if !filepath.IsAbs(bridgeBin) { - wd, _ := os.Getwd() - bridgeBin = filepath.Join(wd, bridgeBin) - } - - timeout := req.Timeout - if timeout <= 0 { - timeout = c.cfg.AgentTimeout - } - runCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - cmd := exec.CommandContext(runCtx, bridgeBin) - cmd.Dir = workDir - cmd.Stdin = bytes.NewReader(payload) - cmd.Env = append(os.Environ(), - "CURSOR_API_KEY="+c.cfg.CursorAPIKey, - "CURSOR_MODEL="+c.cfg.CursorModel, - ) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - if err != nil { - return &Result{ - Success: false, - ErrorReason: fmt.Sprintf("cursor execution error: %v, stderr: %s", err, stderr.String()), - }, nil - } - - type bridgeResponse struct { - OK bool `json:"ok"` - FallbackReason string `json:"fallback_reason"` - Stderr string `json:"stderr"` - } - var bResp bridgeResponse - lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") - if len(lines) > 0 { - _ = json.Unmarshal([]byte(lines[len(lines)-1]), &bResp) - } - - if !bResp.OK { - errMsg := bResp.FallbackReason - if bResp.Stderr != "" { - errMsg += " (" + bResp.Stderr + ")" - } - if errMsg == "" { - errMsg = "cursor bridge reported task failed" - } - return &Result{ - Success: false, - ErrorReason: errMsg, - Logs: stdout.String(), - }, nil - } - - patches := make(map[string][]byte) - for _, t := range req.Targets { - p := filepath.Join(workDir, t) - if b, err := os.ReadFile(p); err == nil { - patches[t] = b - } - } - - return &Result{ - Success: true, - Patches: patches, - Logs: stdout.String(), - }, nil -} diff --git a/internal/agent/pinative.go b/internal/agent/pinative.go new file mode 100644 index 00000000..1ec2c1e7 --- /dev/null +++ b/internal/agent/pinative.go @@ -0,0 +1,154 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/agentx" + "chat-agent-system/internal/agentx/llm" + "chat-agent-system/internal/agentx/session" + "chat-agent-system/internal/agentx/tools" + "chat-agent-system/internal/config" +) + +// PiNativeBackend runs the in-process Go agent loop. The container pi-agent backend stays available. +type PiNativeBackend struct { + cfg *config.Config + stream llm.StreamFunc +} + +func (p *PiNativeBackend) Name() string { return "pi-native" } + +func (p *PiNativeBackend) Execute(ctx context.Context, req Request) (*Result, error) { + if len(req.Targets) == 0 { + req.Targets = []string{"output.md"} + } + profile := strings.TrimSpace(req.ToolProfile) + if profile == "" { + profile = ToolProfileWorkspace + } + if profile != ToolProfileWorkspace && profile != ToolProfileReasoning { + return &Result{Success: false, ErrorReason: "unknown tool profile"}, nil + } + if p.stream == nil && (p.cfg == nil || strings.TrimSpace(p.cfg.AgentXAPIKey) == "") { + return &Result{Success: false, ErrorReason: "agentx api key is not configured"}, nil + } + + workDir := req.WorkDir + if !filepath.IsAbs(workDir) { + wd, _ := os.Getwd() + workDir = filepath.Join(wd, workDir) + } + if err := os.MkdirAll(workDir, 0o755); err != nil { + return &Result{Success: false, ErrorReason: err.Error()}, nil + } + if strings.TrimSpace(req.Instructions) != "" { + _ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644) + _ = os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(req.Instructions), 0o644) + } + if strings.TrimSpace(req.Prompt) != "" { + _ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(req.Prompt), 0o644) + } + + var reg *tools.Registry + if profile == ToolProfileWorkspace { + reg = tools.WorkspaceTools(workDir) + } + store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl")) + if err != nil { + return &Result{Success: false, ErrorReason: err.Error()}, nil + } + stream := p.stream + model := "MiniMax-M2.7-highspeed" + if p.cfg != nil && p.cfg.AgentXModel != "" { + model = p.cfg.AgentXModel + } + if stream == nil { + client := &llm.Client{BaseURL: p.cfg.AgentXAPIBase, APIKey: p.cfg.AgentXAPIKey} + stream = client.Stream + } + + timeout := req.Timeout + if timeout <= 0 && p.cfg != nil { + timeout = p.cfg.AgentXTimeout + } + if timeout <= 0 && p.cfg != nil { + timeout = p.cfg.AgentTimeout + } + runCtx := ctx + cancel := func() {} + if timeout > 0 { + runCtx, cancel = context.WithTimeout(ctx, timeout) + } + defer cancel() + + var logs strings.Builder + runRes, err := agentx.Run(runCtx, agentx.Config{ + Model: model, + SystemPrompt: nativeSystemPrompt(req, profile), + Tools: reg, + Stream: stream, + Session: store, + OnEvent: func(ev agentx.Event) { + logs.WriteString(ev.Type) + if ev.Tool != "" { + logs.WriteString(" ") + logs.WriteString(ev.Tool) + } + if ev.Text != "" { + logs.WriteString(" ") + logs.WriteString(ev.Text) + } + logs.WriteByte('\n') + }, + }, llm.Message{Role: llm.RoleUser, Content: req.Prompt}) + if err != nil { + return &Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil + } + _ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644) + + if profile == ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" { + for _, target := range req.Targets { + path := filepath.Join(workDir, filepath.FromSlash(target)) + if b, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(b))) > 0 { + continue + } + _ = os.MkdirAll(filepath.Dir(path), 0o755) + _ = os.WriteFile(path, []byte(runRes.Text), 0o644) + } + } + + if runRes.Err != nil { + reason := runRes.Err.Error() + res := &Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable} + if runRes.StopReason == llm.StopAborted { + res.Retryable = true + } + return res, nil + } + + patches, matErr := MaterializeTargets(workDir, req.Targets, nil) + if matErr != nil { + return &Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil + } + return &Result{Success: true, Patches: patches, Logs: logs.String()}, nil +} + +func nativeSystemPrompt(req Request, profile string) string { + var b strings.Builder + if strings.TrimSpace(req.Instructions) != "" { + b.WriteString(req.Instructions) + b.WriteString("\n\n") + } + b.WriteString("Deliverable files: ") + b.WriteString(strings.Join(req.Targets, ", ")) + b.WriteString(".") + if profile == ToolProfileWorkspace { + b.WriteString(" Use workspace tools to write those files. Do not invent paths outside the workspace.") + } else { + b.WriteString(" Reply with the deliverable text only. Tools are disabled.") + } + return b.String() +} diff --git a/internal/agent/pinative_test.go b/internal/agent/pinative_test.go new file mode 100644 index 00000000..e1242a9a --- /dev/null +++ b/internal/agent/pinative_test.go @@ -0,0 +1,83 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "chat-agent-system/internal/agentx/llm" + "chat-agent-system/internal/config" +) + +func TestPiNativeWorkspaceWritesTarget(t *testing.T) { + dir := t.TempDir() + b := &PiNativeBackend{cfg: &config.Config{}, stream: scriptedNative( + llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ + ID: "1", Name: "write_file", Arguments: `{"path":"output.md","content":"# hello"}`, + }}}, + llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, + )} + res, err := b.Execute(context.Background(), Request{ + WorkDir: dir, + Prompt: "write the doc", + Instructions: "be precise", + Targets: []string{"output.md"}, + ToolProfile: ToolProfileWorkspace, + }) + if err != nil { + t.Fatal(err) + } + if !res.Success || string(res.Patches["output.md"]) != "# hello" { + t.Fatalf("%#v", res) + } + if _, err := os.Stat(filepath.Join(dir, "execution.log")); err != nil { + t.Fatal(err) + } +} + +func TestPiNativeReasoningMaterializesText(t *testing.T) { + dir := t.TempDir() + b := &PiNativeBackend{cfg: &config.Config{}, stream: scriptedNative( + llm.Message{Role: llm.RoleAssistant, Content: "reply body", StopReason: llm.StopEnd}, + )} + res, err := b.Execute(context.Background(), Request{ + WorkDir: dir, + Prompt: "say hello", + Targets: []string{"reply.md"}, + ToolProfile: ToolProfileReasoning, + }) + if err != nil { + t.Fatal(err) + } + if !res.Success || !strings.Contains(string(res.Patches["reply.md"]), "reply body") { + t.Fatalf("%#v", res) + } +} + +func TestPiNativeRejectsUnknownProfileAndMissingKey(t *testing.T) { + b := &PiNativeBackend{cfg: &config.Config{}} + res, err := b.Execute(context.Background(), Request{WorkDir: t.TempDir(), ToolProfile: "shell"}) + if err != nil || res.Success || !strings.Contains(res.ErrorReason, "unknown tool profile") { + t.Fatalf("%v %#v", err, res) + } + res, err = b.Execute(context.Background(), Request{WorkDir: t.TempDir(), ToolProfile: ToolProfileWorkspace}) + if err != nil || res.Success || !strings.Contains(res.ErrorReason, "api key") { + t.Fatalf("%v %#v", err, res) + } +} + +func scriptedNative(turns ...llm.Message) llm.StreamFunc { + i := 0 + return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { + ch := make(chan llm.Event, 2) + go func() { + defer close(ch) + msg := turns[i] + i++ + ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg} + }() + return ch + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 98b9d548..fd0cfc18 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,10 +17,12 @@ type Config struct { HTTPPort int `json:"http_port"` AgentBackend string `json:"agent_backend"` AgentTimeout time.Duration `json:"agent_timeout"` - CursorAPIKey string `json:"cursor_api_key"` - CursorModel string `json:"cursor_model"` - CursorBin string `json:"cursor_bin"` - CursorBridgeBin string `json:"cursor_bridge_bin"` + AgentXAPIBase string `json:"agentx_api_base"` + AgentXAPIKey string `json:"agentx_api_key"` + AgentXModel string `json:"agentx_model"` + AgentXTimeout time.Duration `json:"agentx_timeout"` + WorkspaceTTL time.Duration `json:"workspace_ttl"` + WorkspaceQuotaBytes int64 `json:"workspace_quota_bytes"` PiAgentBin string `json:"pi_agent_bin"` PiAgentBridgeBin string `json:"pi_agent_bridge_bin"` PiAgentProvider string `json:"pi_agent_provider"` @@ -82,30 +84,32 @@ type Config struct { VaultStateRoot string `json:"vault_state_root"` VaultStorageIdentity string `json:"vault_storage_identity"` VaultMasterKey string `json:"vault_master_key"` - FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false - FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false + FAVDegradedRead bool `json:"fav_degraded_read"` // FAV_DEGRADED_READ; default false + FAVPublisherEnabled bool `json:"fav_publisher_enabled"` // FAV_PUBLISHER_ENABLED; default false } func Load(path string) (*Config, error) { cfg := &Config{ - Env: "dev", - DataDir: "./data/dev", - HTTPHost: "127.0.0.1", - HTTPPort: 8080, - AgentBackend: "pi-agent", - AgentTimeout: 30 * time.Minute, - CursorModel: "auto", - CursorBin: "/usr/local/bin/cursor", - CursorBridgeBin: "tools/cursor-bridge/index.mjs", - PiAgentBin: "/opt/homebrew/bin/pi", - PiAgentBridgeBin: "tools/pi-bridge/index.mjs", - PiAgentProvider: "minimax", - PiAgentModel: "MiniMax-M2.7-highspeed", - KbDbHost: "127.0.0.1", - KbDbPort: 5432, - KbDbUser: "regkb", - KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供 - KbDbName: "regulations", + Env: "dev", + DataDir: "./data/dev", + HTTPHost: "127.0.0.1", + HTTPPort: 8080, + AgentBackend: "pi-agent", + AgentTimeout: 30 * time.Minute, + AgentXAPIBase: "https://api.minimaxi.com/v1", + AgentXModel: "MiniMax-M2.7-highspeed", + AgentXTimeout: 30 * time.Minute, + WorkspaceTTL: 7 * 24 * time.Hour, + WorkspaceQuotaBytes: 20 << 30, + PiAgentBin: "/opt/homebrew/bin/pi", + PiAgentBridgeBin: "tools/pi-bridge/index.mjs", + PiAgentProvider: "minimax", + PiAgentModel: "MiniMax-M2.7-highspeed", + KbDbHost: "127.0.0.1", + KbDbPort: 5432, + KbDbUser: "regkb", + KbDbPassword: "", // 禁止静默默认口令;须由根 .env / 进程环境提供 + KbDbName: "regulations", ContextDbHost: "127.0.0.1", ContextDbPort: 5433, @@ -179,17 +183,33 @@ func Load(path string) (*Config, error) { cfg.AgentTimeout = d } } - if v := os.Getenv("CURSOR_API_KEY"); v != "" { - cfg.CursorAPIKey = v + if v := os.Getenv("AGENTX_API_BASE"); v != "" { + cfg.AgentXAPIBase = v + } else if v := os.Getenv("MINIMAX_ENDPOINT_URL"); v != "" { + cfg.AgentXAPIBase = v } - if v := os.Getenv("CURSOR_MODEL"); v != "" { - cfg.CursorModel = v + if v := os.Getenv("AGENTX_API_KEY"); v != "" { + cfg.AgentXAPIKey = v + } else if v := os.Getenv("MINIMAX_API_KEY"); v != "" { + cfg.AgentXAPIKey = v } - if v := os.Getenv("CURSOR_BIN"); v != "" { - cfg.CursorBin = v + if v := os.Getenv("AGENTX_MODEL"); v != "" { + cfg.AgentXModel = v } - if v := os.Getenv("CURSOR_BRIDGE_BIN"); v != "" { - cfg.CursorBridgeBin = v + if v := os.Getenv("AGENTX_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.AgentXTimeout = d + } + } + if v := os.Getenv("WORKSPACE_TTL"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.WorkspaceTTL = d + } + } + if v := os.Getenv("WORKSPACE_QUOTA_BYTES"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 { + cfg.WorkspaceQuotaBytes = n + } } if v := os.Getenv("PI_AGENT_BIN"); v != "" { cfg.PiAgentBin = v @@ -392,14 +412,24 @@ func applyEnvFile(cfg *Config, path string) error { if d, err := time.ParseDuration(val); err == nil { cfg.AgentTimeout = d } - case "CURSOR_API_KEY": - cfg.CursorAPIKey = val - case "CURSOR_MODEL": - cfg.CursorModel = val - case "CURSOR_BIN": - cfg.CursorBin = val - case "CURSOR_BRIDGE_BIN": - cfg.CursorBridgeBin = val + case "AGENTX_API_BASE", "MINIMAX_ENDPOINT_URL": + cfg.AgentXAPIBase = val + case "AGENTX_API_KEY", "MINIMAX_API_KEY": + cfg.AgentXAPIKey = val + case "AGENTX_MODEL": + cfg.AgentXModel = val + case "AGENTX_TIMEOUT": + if d, err := time.ParseDuration(val); err == nil { + cfg.AgentXTimeout = d + } + case "WORKSPACE_TTL": + if d, err := time.ParseDuration(val); err == nil { + cfg.WorkspaceTTL = d + } + case "WORKSPACE_QUOTA_BYTES": + if n, err := strconv.ParseInt(val, 10, 64); err == nil && n > 0 { + cfg.WorkspaceQuotaBytes = n + } case "PI_AGENT_BIN": cfg.PiAgentBin = val case "PI_AGENT_BRIDGE_BIN": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 4cfc48ee..584e8f87 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -89,10 +89,10 @@ func TestConfigLoadDSNOverrides(t *testing.T) { func TestConfigLoadRuntimeEnvironmentOverrides(t *testing.T) { t.Setenv("ENV", "dev") t.Setenv("AGENT_TIMEOUT", "47m") - t.Setenv("CURSOR_API_KEY", "cursor-test-key") - t.Setenv("CURSOR_MODEL", "cursor-test-model") - t.Setenv("CURSOR_BIN", "/test/cursor") - t.Setenv("CURSOR_BRIDGE_BIN", "/test/cursor-bridge.mjs") + t.Setenv("AGENTX_API_KEY", "agentx-test-key") + t.Setenv("AGENTX_MODEL", "agentx-test-model") + t.Setenv("AGENTX_API_BASE", "https://example.invalid/v1") + t.Setenv("AGENTX_TIMEOUT", "5m") t.Setenv("PI_AGENT_BRIDGE_BIN", "/test/pi-bridge.mjs") t.Setenv("EMBEDDING_MODEL", "embedding-test-model") t.Setenv("RERANKER_MODEL", "reranker-test-model") @@ -104,8 +104,8 @@ func TestConfigLoadRuntimeEnvironmentOverrides(t *testing.T) { if cfg.AgentTimeout != 47*time.Minute { t.Fatalf("expected environment AgentTimeout 47m, got %v", cfg.AgentTimeout) } - if cfg.CursorAPIKey != "cursor-test-key" || cfg.CursorModel != "cursor-test-model" || cfg.CursorBin != "/test/cursor" || cfg.CursorBridgeBin != "/test/cursor-bridge.mjs" { - t.Fatalf("cursor environment overrides were not applied") + if cfg.AgentXAPIKey != "agentx-test-key" || cfg.AgentXModel != "agentx-test-model" || cfg.AgentXAPIBase != "https://example.invalid/v1" || cfg.AgentXTimeout != 5*time.Minute { + t.Fatalf("agentx environment overrides were not applied: %+v", cfg.AgentXAPIKey) } if cfg.PiAgentBridgeBin != "/test/pi-bridge.mjs" { t.Fatalf("pi bridge environment override was not applied") diff --git a/internal/taskrun/backend.go b/internal/taskrun/backend.go index 67e4dbba..85654d19 100644 --- a/internal/taskrun/backend.go +++ b/internal/taskrun/backend.go @@ -12,9 +12,9 @@ var ( // Allowed backends are resolved by server policy. Callers cannot inject arbitrary strings. var allowedBackends = map[string]struct{}{ - "stub": {}, - "pi-agent": {}, - "cursor": {}, + "stub": {}, + "pi-agent": {}, + "pi-native": {}, } func isProductionEnv(env string) bool { diff --git a/internal/taskrun/manifest_test.go b/internal/taskrun/manifest_test.go index f081a314..6c73e5d2 100644 --- a/internal/taskrun/manifest_test.go +++ b/internal/taskrun/manifest_test.go @@ -61,6 +61,13 @@ func TestResolveBackend_StripsUnauthorized(t *testing.T) { if err != nil || got != "pi-agent" { t.Fatalf("got %q %v", got, err) } + if _, err := ResolveBackend("cursor", "pi-agent"); err == nil { + t.Fatal("cursor backend must stay unauthorized") + } + got, err = ResolveBackend("pi-native", "pi-agent") + if err != nil || got != "pi-native" { + t.Fatalf("pi-native %q %v", got, err) + } } func TestResolveBackendInEnv_RejectsStubInMain(t *testing.T) { diff --git a/tools/cursor-bridge/index.mjs b/tools/cursor-bridge/index.mjs deleted file mode 100755 index 2ac0fff8..00000000 --- a/tools/cursor-bridge/index.mjs +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env node -/** - * Cursor SDK bridge (stdio JSON). - * - * Input (stdin): - * { - * "workspace": "/abs/path", - * "api_key": "...", - * "model": "auto", - * "prompt": "...", - * "instructions": "...", - * "targets": ["docs/RD-UN.md"] - * } - * - * Output (stdout, last line JSON): - * { - * "ok": true, - * "agent_id": "...", - * "run_id": "...", - * "patches": [{ "rel_path": "docs/RD-UN.md", "content": "..." }], - * "fallback_reason": "" - * } - */ -import { readFileSync, existsSync } from "node:fs"; -import { resolve, relative, isAbsolute } from "node:path"; -import { Agent, CursorAgentError } from "@cursor/sdk"; - -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - return Buffer.concat(chunks).toString("utf8"); -} - -function fail(reason, extra = {}) { - process.stdout.write( - JSON.stringify({ - ok: false, - patches: [], - fallback_reason: reason, - ...extra, - }) + "\n", - ); - process.exit(0); -} - -function sanitizeApiKey(raw) { - if (!raw) return ""; - let key = String(raw).trim(); - const commentIdx = key.indexOf(" #"); - if (commentIdx !== -1) { - key = key.slice(0, commentIdx).trim(); - } - const tabCommentIdx = key.indexOf("\t#"); - if (tabCommentIdx !== -1) { - key = key.slice(0, tabCommentIdx).trim(); - } - return key; -} - -function safeRel(workspace, target) { - const abs = isAbsolute(target) ? target : resolve(workspace, target); - const rel = relative(workspace, abs); - if (!rel || rel.startsWith("..") || isAbsolute(rel)) { - return null; - } - return rel.replace(/\\/g, "/"); -} - -async function main() { - let req; - try { - const raw = await readStdin(); - req = JSON.parse(raw); - } catch (e) { - fail("invalid_json: " + (e && e.message ? e.message : String(e))); - return; - } - - const workspace = req.workspace; - const apiKey = sanitizeApiKey(req.api_key || process.env.CURSOR_API_KEY || ""); - const model = (req.model || process.env.CURSOR_MODEL || "auto").trim(); - const prompt = req.prompt || ""; - const instructions = req.instructions || ""; - const targets = Array.isArray(req.targets) ? req.targets : []; - - if (!workspace || !existsSync(workspace)) { - fail("workspace_missing"); - return; - } - if (!apiKey) { - fail("api_key_missing"); - return; - } - if (!prompt.trim()) { - fail("prompt_empty"); - return; - } - - const allowed = new Set(); - for (const t of targets) { - const rel = safeRel(workspace, t); - if (rel) allowed.add(rel); - } - if (allowed.size === 0) { - fail("no_targets"); - return; - } - - const fullPrompt = instructions ? `${instructions}\n\n---\n\n${prompt}` : prompt; - - let agentId = ""; - let runId = ""; - try { - const result = await Agent.prompt(fullPrompt, { - apiKey, - model: { id: model }, - local: { - cwd: workspace, - settingSources: [], - }, - }); - agentId = result?.agentId || result?.agent_id || ""; - runId = result?.id || result?.runId || result?.run_id || ""; - if (result?.status === "error") { - fail("run_error", { agent_id: agentId, run_id: runId }); - return; - } - } catch (e) { - const rawMsg = - e instanceof CursorAgentError - ? e.message - : e && e.message - ? e.message - : String(e); - let category = "startup_or_run"; - const lower = rawMsg.toLowerCase(); - if ( - lower.includes("invalid user api key") || - lower.includes("unauthorized") || - lower.includes("401") || - lower.includes("authentication") - ) { - category = "cursor_auth_failed"; - } else if ( - lower.includes("timeout") || - lower.includes("timed out") || - lower.includes("deadline") - ) { - category = "cursor_timeout"; - } else if ( - lower.includes("enotfound") || - lower.includes("econnrefused") || - lower.includes("fetch failed") || - lower.includes("network") - ) { - category = "cursor_network_error"; - } else if (lower.includes("cannot use this model") || lower.includes("available models")) { - category = "cursor_model_unsupported"; - } - fail(category + ": " + rawMsg, { agent_id: agentId, run_id: runId }); - return; - } - - const patches = []; - for (const rel of allowed) { - const abs = resolve(workspace, rel); - if (!existsSync(abs)) continue; - const content = readFileSync(abs, "utf8"); - patches.push({ rel_path: rel, content }); - } - - if (patches.length === 0) { - fail("empty_patches", { agent_id: agentId, run_id: runId }); - return; - } - - process.stdout.write( - JSON.stringify({ - ok: true, - agent_id: agentId, - run_id: runId, - patches, - fallback_reason: "", - }) + "\n", - ); -} - -main().catch((e) => { - fail("unhandled: " + (e && e.message ? e.message : String(e))); -}); diff --git a/tools/cursor-bridge/package.json b/tools/cursor-bridge/package.json deleted file mode 100644 index 80f258e9..00000000 --- a/tools/cursor-bridge/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "cursor-bridge", - "version": "1.0.0", - "private": true, - "description": "stdio JSON bridge: Cursor local Agent drafts document patches for agent-system", - "type": "module", - "bin": { - "cursor-bridge": "./index.mjs" - }, - "main": "index.mjs", - "engines": { - "node": ">=20" - }, - "dependencies": { - "@cursor/sdk": "1.0.31" - } -}