refactor: replace fmt.Errorf/os.* with go-io/go-log conventions
Replace all fmt.Errorf and errors.New in production code with
coreerr.E("caller.Method", "message", err) from go-log. Replace all
os.ReadFile/os.WriteFile/os.MkdirAll/os.Remove with coreio.Local
equivalents from go-io. Test files are untouched.
24 files across pkg/mcp/agentic/, pkg/mcp/brain/, pkg/mcp/ide/,
pkg/mcp/, and cmd/brain-seed/.
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
d6dccec914
commit
424b5eb91d
24 changed files with 217 additions and 209 deletions
|
|
@ -26,6 +26,9 @@ import (
|
|||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -272,26 +275,26 @@ func callBrainRemember(content, memType string, tags []string, project string, c
|
|||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal: %w", err)
|
||||
return coreerr.E("callBrainRemember", "marshal", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", *apiURL+"/tools/call", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("request: %w", err)
|
||||
return coreerr.E("callBrainRemember", "request", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+*apiKey)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http: %w", err)
|
||||
return coreerr.E("callBrainRemember", "http", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
return coreerr.E("callBrainRemember", "HTTP "+string(respBody), nil)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
|
|
@ -299,10 +302,10 @@ func callBrainRemember(content, memType string, tags []string, project string, c
|
|||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return fmt.Errorf("decode: %w", err)
|
||||
return coreerr.E("callBrainRemember", "decode", err)
|
||||
}
|
||||
if !result.Success {
|
||||
return fmt.Errorf("API: %s", result.Error)
|
||||
return coreerr.E("callBrainRemember", "API: "+result.Error, nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -361,13 +364,13 @@ var headingRe = regexp.MustCompile(`^#{1,3}\s+(.+)$`)
|
|||
|
||||
// parseMarkdownSections splits a markdown file by headings.
|
||||
func parseMarkdownSections(path string) []section {
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := coreio.Local.Read(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sections []section
|
||||
lines := strings.Split(string(data), "\n")
|
||||
lines := strings.Split(data, "\n")
|
||||
var curHeading string
|
||||
var curContent []string
|
||||
|
||||
|
|
@ -395,10 +398,10 @@ func parseMarkdownSections(path string) []section {
|
|||
}
|
||||
|
||||
// If no headings found, treat entire file as one section
|
||||
if len(sections) == 0 && strings.TrimSpace(string(data)) != "" {
|
||||
if len(sections) == 0 && strings.TrimSpace(data) != "" {
|
||||
sections = append(sections, section{
|
||||
heading: strings.TrimSuffix(filepath.Base(path), ".md"),
|
||||
content: strings.TrimSpace(string(data)),
|
||||
content: strings.TrimSpace(data),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -77,16 +79,16 @@ func agentCommand(agent, prompt string) (string, []string, error) {
|
|||
script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh")
|
||||
return "bash", []string{script, prompt}, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unknown agent: %s", agent)
|
||||
return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest, input DispatchInput) (*mcp.CallToolResult, DispatchOutput, error) {
|
||||
if input.Repo == "" {
|
||||
return nil, DispatchOutput{}, fmt.Errorf("repo is required")
|
||||
return nil, DispatchOutput{}, coreerr.E("dispatch", "repo is required", nil)
|
||||
}
|
||||
if input.Task == "" {
|
||||
return nil, DispatchOutput{}, fmt.Errorf("task is required")
|
||||
return nil, DispatchOutput{}, coreerr.E("dispatch", "task is required", nil)
|
||||
}
|
||||
if input.Org == "" {
|
||||
input.Org = "core"
|
||||
|
|
@ -111,7 +113,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
|||
}
|
||||
_, prepOut, err := s.prepWorkspace(ctx, req, prepInput)
|
||||
if err != nil {
|
||||
return nil, DispatchOutput{}, fmt.Errorf("prep workspace failed: %w", err)
|
||||
return nil, DispatchOutput{}, coreerr.E("dispatch", "prep workspace failed", err)
|
||||
}
|
||||
|
||||
wsDir := prepOut.WorkspaceDir
|
||||
|
|
@ -122,13 +124,13 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
|||
|
||||
if input.DryRun {
|
||||
// Read PROMPT.md for the dry run output
|
||||
promptContent, _ := os.ReadFile(filepath.Join(wsDir, "PROMPT.md"))
|
||||
promptRaw, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md"))
|
||||
return nil, DispatchOutput{
|
||||
Success: true,
|
||||
Agent: input.Agent,
|
||||
Repo: input.Repo,
|
||||
WorkspaceDir: wsDir,
|
||||
Prompt: string(promptContent),
|
||||
Prompt: promptRaw,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -164,7 +166,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
|||
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", input.Agent))
|
||||
outFile, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
return nil, DispatchOutput{}, fmt.Errorf("failed to create log file: %w", err)
|
||||
return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err)
|
||||
}
|
||||
|
||||
// Fully detach from terminal:
|
||||
|
|
@ -183,7 +185,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
|||
|
||||
if err := cmd.Start(); err != nil {
|
||||
outFile.Close()
|
||||
return nil, DispatchOutput{}, fmt.Errorf("failed to spawn %s: %w", input.Agent, err)
|
||||
return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to spawn "+input.Agent, err)
|
||||
}
|
||||
|
||||
pid := cmd.Process.Pid
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -53,13 +54,13 @@ func (s *PrepSubsystem) registerEpicTool(server *mcp.Server) {
|
|||
|
||||
func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest, input EpicInput) (*mcp.CallToolResult, EpicOutput, error) {
|
||||
if input.Title == "" {
|
||||
return nil, EpicOutput{}, fmt.Errorf("title is required")
|
||||
return nil, EpicOutput{}, coreerr.E("createEpic", "title is required", nil)
|
||||
}
|
||||
if len(input.Tasks) == 0 {
|
||||
return nil, EpicOutput{}, fmt.Errorf("at least one task is required")
|
||||
return nil, EpicOutput{}, coreerr.E("createEpic", "at least one task is required", nil)
|
||||
}
|
||||
if s.forgeToken == "" {
|
||||
return nil, EpicOutput{}, fmt.Errorf("no Forge token configured")
|
||||
return nil, EpicOutput{}, coreerr.E("createEpic", "no Forge token configured", nil)
|
||||
}
|
||||
if input.Org == "" {
|
||||
input.Org = "core"
|
||||
|
|
@ -112,7 +113,7 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest
|
|||
epicLabels := append(labelIDs, s.resolveLabelIDs(ctx, input.Org, input.Repo, []string{"epic"})...)
|
||||
epic, err := s.createIssue(ctx, input.Org, input.Repo, input.Title, body.String(), epicLabels)
|
||||
if err != nil {
|
||||
return nil, EpicOutput{}, fmt.Errorf("failed to create epic: %w", err)
|
||||
return nil, EpicOutput{}, coreerr.E("createEpic", "failed to create epic", err)
|
||||
}
|
||||
|
||||
out := EpicOutput{
|
||||
|
|
@ -162,12 +163,12 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
|
|||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return ChildRef{}, fmt.Errorf("create issue request failed: %w", err)
|
||||
return ChildRef{}, coreerr.E("createIssue", "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 {
|
||||
return ChildRef{}, fmt.Errorf("create issue returned %d", resp.StatusCode)
|
||||
return ChildRef{}, coreerr.E("createIssue", fmt.Sprintf("returned %d", resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
// ingestFindings reads the agent output log and creates issues via the API
|
||||
|
|
@ -26,12 +28,12 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
|
|||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(logFiles[0])
|
||||
if err != nil || len(content) < 100 {
|
||||
contentStr, err := coreio.Local.Read(logFiles[0])
|
||||
if err != nil || len(contentStr) < 100 {
|
||||
return
|
||||
}
|
||||
|
||||
body := string(content)
|
||||
body := contentStr
|
||||
|
||||
// Skip quota errors
|
||||
if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") {
|
||||
|
|
@ -93,11 +95,11 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p
|
|||
|
||||
// Read the agent API key from file
|
||||
home, _ := os.UserHomeDir()
|
||||
apiKeyData, err := os.ReadFile(filepath.Join(home, ".claude", "agent-api.key"))
|
||||
apiKeyData, err := coreio.Local.Read(filepath.Join(home, ".claude", "agent-api.key"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
apiKey := strings.TrimSpace(string(apiKeyData))
|
||||
apiKey := strings.TrimSpace(apiKeyData)
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
"title": title,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -145,10 +146,10 @@ func (s *PrepSubsystem) registerPlanTools(server *mcp.Server) {
|
|||
|
||||
func (s *PrepSubsystem) planCreate(_ context.Context, _ *mcp.CallToolRequest, input PlanCreateInput) (*mcp.CallToolResult, PlanCreateOutput, error) {
|
||||
if input.Title == "" {
|
||||
return nil, PlanCreateOutput{}, fmt.Errorf("title is required")
|
||||
return nil, PlanCreateOutput{}, coreerr.E("planCreate", "title is required", nil)
|
||||
}
|
||||
if input.Objective == "" {
|
||||
return nil, PlanCreateOutput{}, fmt.Errorf("objective is required")
|
||||
return nil, PlanCreateOutput{}, coreerr.E("planCreate", "objective is required", nil)
|
||||
}
|
||||
|
||||
id := generatePlanID(input.Title)
|
||||
|
|
@ -177,7 +178,7 @@ func (s *PrepSubsystem) planCreate(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
|
||||
path, err := writePlan(s.plansDir(), &plan)
|
||||
if err != nil {
|
||||
return nil, PlanCreateOutput{}, fmt.Errorf("failed to write plan: %w", err)
|
||||
return nil, PlanCreateOutput{}, coreerr.E("planCreate", "failed to write plan", err)
|
||||
}
|
||||
|
||||
return nil, PlanCreateOutput{
|
||||
|
|
@ -189,7 +190,7 @@ func (s *PrepSubsystem) planCreate(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
|
||||
func (s *PrepSubsystem) planRead(_ context.Context, _ *mcp.CallToolRequest, input PlanReadInput) (*mcp.CallToolResult, PlanReadOutput, error) {
|
||||
if input.ID == "" {
|
||||
return nil, PlanReadOutput{}, fmt.Errorf("id is required")
|
||||
return nil, PlanReadOutput{}, coreerr.E("planRead", "id is required", nil)
|
||||
}
|
||||
|
||||
plan, err := readPlan(s.plansDir(), input.ID)
|
||||
|
|
@ -205,7 +206,7 @@ func (s *PrepSubsystem) planRead(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
|
||||
func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, input PlanUpdateInput) (*mcp.CallToolResult, PlanUpdateOutput, error) {
|
||||
if input.ID == "" {
|
||||
return nil, PlanUpdateOutput{}, fmt.Errorf("id is required")
|
||||
return nil, PlanUpdateOutput{}, coreerr.E("planUpdate", "id is required", nil)
|
||||
}
|
||||
|
||||
plan, err := readPlan(s.plansDir(), input.ID)
|
||||
|
|
@ -216,7 +217,7 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
// Apply partial updates
|
||||
if input.Status != "" {
|
||||
if !validPlanStatus(input.Status) {
|
||||
return nil, PlanUpdateOutput{}, fmt.Errorf("invalid status: %s (valid: draft, ready, in_progress, needs_verification, verified, approved)", input.Status)
|
||||
return nil, PlanUpdateOutput{}, coreerr.E("planUpdate", "invalid status: "+input.Status+" (valid: draft, ready, in_progress, needs_verification, verified, approved)", nil)
|
||||
}
|
||||
plan.Status = input.Status
|
||||
}
|
||||
|
|
@ -239,7 +240,7 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
plan.UpdatedAt = time.Now()
|
||||
|
||||
if _, err := writePlan(s.plansDir(), plan); err != nil {
|
||||
return nil, PlanUpdateOutput{}, fmt.Errorf("failed to write plan: %w", err)
|
||||
return nil, PlanUpdateOutput{}, coreerr.E("planUpdate", "failed to write plan", err)
|
||||
}
|
||||
|
||||
return nil, PlanUpdateOutput{
|
||||
|
|
@ -250,16 +251,16 @@ func (s *PrepSubsystem) planUpdate(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
|
||||
func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, input PlanDeleteInput) (*mcp.CallToolResult, PlanDeleteOutput, error) {
|
||||
if input.ID == "" {
|
||||
return nil, PlanDeleteOutput{}, fmt.Errorf("id is required")
|
||||
return nil, PlanDeleteOutput{}, coreerr.E("planDelete", "id is required", nil)
|
||||
}
|
||||
|
||||
path := planPath(s.plansDir(), input.ID)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return nil, PlanDeleteOutput{}, fmt.Errorf("plan not found: %s", input.ID)
|
||||
return nil, PlanDeleteOutput{}, coreerr.E("planDelete", "plan not found: "+input.ID, nil)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
return nil, PlanDeleteOutput{}, fmt.Errorf("failed to delete plan: %w", err)
|
||||
if err := coreio.Local.Delete(path); err != nil {
|
||||
return nil, PlanDeleteOutput{}, coreerr.E("planDelete", "failed to delete plan", err)
|
||||
}
|
||||
|
||||
return nil, PlanDeleteOutput{
|
||||
|
|
@ -270,13 +271,13 @@ func (s *PrepSubsystem) planDelete(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
|
||||
func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, input PlanListInput) (*mcp.CallToolResult, PlanListOutput, error) {
|
||||
dir := s.plansDir()
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, PlanListOutput{}, fmt.Errorf("failed to access plans directory: %w", err)
|
||||
if err := coreio.Local.EnsureDir(dir); err != nil {
|
||||
return nil, PlanListOutput{}, coreerr.E("planList", "failed to access plans directory", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, PlanListOutput{}, fmt.Errorf("failed to read plans directory: %w", err)
|
||||
return nil, PlanListOutput{}, coreerr.E("planList", "failed to read plans directory", err)
|
||||
}
|
||||
|
||||
var plans []Plan
|
||||
|
|
@ -351,21 +352,21 @@ func generatePlanID(title string) string {
|
|||
}
|
||||
|
||||
func readPlan(dir, id string) (*Plan, error) {
|
||||
data, err := os.ReadFile(planPath(dir, id))
|
||||
data, err := coreio.Local.Read(planPath(dir, id))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan not found: %s", id)
|
||||
return nil, coreerr.E("readPlan", "plan not found: "+id, nil)
|
||||
}
|
||||
|
||||
var plan Plan
|
||||
if err := json.Unmarshal(data, &plan); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse plan %s: %w", id, err)
|
||||
if err := json.Unmarshal([]byte(data), &plan); err != nil {
|
||||
return nil, coreerr.E("readPlan", "failed to parse plan "+id, err)
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
func writePlan(dir string, plan *Plan) (string, error) {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create plans directory: %w", err)
|
||||
if err := coreio.Local.EnsureDir(dir); err != nil {
|
||||
return "", coreerr.E("writePlan", "failed to create plans directory", err)
|
||||
}
|
||||
|
||||
path := planPath(dir, plan.ID)
|
||||
|
|
@ -374,7 +375,7 @@ func writePlan(dir string, plan *Plan) (string, error) {
|
|||
return "", err
|
||||
}
|
||||
|
||||
return path, os.WriteFile(path, data, 0644)
|
||||
return path, coreio.Local.Write(path, string(data))
|
||||
}
|
||||
|
||||
func validPlanStatus(status string) bool {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -47,10 +48,10 @@ func (s *PrepSubsystem) registerCreatePRTool(server *mcp.Server) {
|
|||
|
||||
func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, input CreatePRInput) (*mcp.CallToolResult, CreatePROutput, error) {
|
||||
if input.Workspace == "" {
|
||||
return nil, CreatePROutput{}, fmt.Errorf("workspace is required")
|
||||
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace is required", nil)
|
||||
}
|
||||
if s.forgeToken == "" {
|
||||
return nil, CreatePROutput{}, fmt.Errorf("no Forge token configured")
|
||||
return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil)
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
|
|
@ -58,13 +59,13 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
srcDir := filepath.Join(wsDir, "src")
|
||||
|
||||
if _, err := os.Stat(srcDir); err != nil {
|
||||
return nil, CreatePROutput{}, fmt.Errorf("workspace not found: %s", input.Workspace)
|
||||
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil)
|
||||
}
|
||||
|
||||
// Read workspace status for repo, branch, issue context
|
||||
st, err := readStatus(wsDir)
|
||||
if err != nil {
|
||||
return nil, CreatePROutput{}, fmt.Errorf("no status.json: %w", err)
|
||||
return nil, CreatePROutput{}, coreerr.E("createPR", "no status.json", err)
|
||||
}
|
||||
|
||||
if st.Branch == "" {
|
||||
|
|
@ -73,7 +74,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
branchCmd.Dir = srcDir
|
||||
out, err := branchCmd.Output()
|
||||
if err != nil {
|
||||
return nil, CreatePROutput{}, fmt.Errorf("failed to detect branch: %w", err)
|
||||
return nil, CreatePROutput{}, coreerr.E("createPR", "failed to detect branch", err)
|
||||
}
|
||||
st.Branch = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
|
@ -116,13 +117,13 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
pushCmd.Dir = srcDir
|
||||
pushOut, err := pushCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, CreatePROutput{}, fmt.Errorf("git push failed: %s: %w", string(pushOut), err)
|
||||
return nil, CreatePROutput{}, coreerr.E("createPR", "git push failed: "+string(pushOut), err)
|
||||
}
|
||||
|
||||
// Create PR via Forge API
|
||||
prURL, prNum, err := s.forgeCreatePR(ctx, org, st.Repo, st.Branch, base, title, body)
|
||||
if err != nil {
|
||||
return nil, CreatePROutput{}, fmt.Errorf("failed to create PR: %w", err)
|
||||
return nil, CreatePROutput{}, coreerr.E("createPR", "failed to create PR", err)
|
||||
}
|
||||
|
||||
// Update status with PR URL
|
||||
|
|
@ -177,7 +178,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
|
|||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("request failed: %w", err)
|
||||
return "", 0, coreerr.E("forgeCreatePR", "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
|
@ -185,7 +186,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
|
|||
var errBody map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&errBody)
|
||||
msg, _ := errBody["message"].(string)
|
||||
return "", 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, msg)
|
||||
return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil)
|
||||
}
|
||||
|
||||
var pr struct {
|
||||
|
|
@ -252,7 +253,7 @@ func (s *PrepSubsystem) registerListPRsTool(server *mcp.Server) {
|
|||
|
||||
func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, input ListPRsInput) (*mcp.CallToolResult, ListPRsOutput, error) {
|
||||
if s.forgeToken == "" {
|
||||
return nil, ListPRsOutput{}, fmt.Errorf("no Forge token configured")
|
||||
return nil, ListPRsOutput{}, coreerr.E("listPRs", "no Forge token configured", nil)
|
||||
}
|
||||
|
||||
if input.Org == "" {
|
||||
|
|
@ -309,7 +310,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string
|
|||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to list PRs for %s: %v", repo, err)
|
||||
return nil, coreerr.E("listRepoPRs", "failed to list PRs for "+repo, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -43,8 +45,8 @@ func NewPrep() *PrepSubsystem {
|
|||
|
||||
brainKey := os.Getenv("CORE_BRAIN_KEY")
|
||||
if brainKey == "" {
|
||||
if data, err := os.ReadFile(filepath.Join(home, ".claude", "brain.key")); err == nil {
|
||||
brainKey = strings.TrimSpace(string(data))
|
||||
if data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil {
|
||||
brainKey = strings.TrimSpace(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +124,7 @@ type PrepOutput struct {
|
|||
|
||||
func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolRequest, input PrepInput) (*mcp.CallToolResult, PrepOutput, error) {
|
||||
if input.Repo == "" {
|
||||
return nil, PrepOutput{}, fmt.Errorf("repo is required")
|
||||
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "repo is required", nil)
|
||||
}
|
||||
if input.Org == "" {
|
||||
input.Org = "core"
|
||||
|
|
@ -171,29 +173,29 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
|||
branchCmd.Run()
|
||||
|
||||
// Create context dirs inside src/
|
||||
os.MkdirAll(filepath.Join(srcDir, "kb"), 0755)
|
||||
os.MkdirAll(filepath.Join(srcDir, "specs"), 0755)
|
||||
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb"))
|
||||
coreio.Local.EnsureDir(filepath.Join(srcDir, "specs"))
|
||||
|
||||
// Remote stays as local clone origin — agent cannot push to forge.
|
||||
// Reviewer pulls changes from workspace and pushes after verification.
|
||||
|
||||
// 2. Copy CLAUDE.md and GEMINI.md to workspace
|
||||
claudeMdPath := filepath.Join(repoPath, "CLAUDE.md")
|
||||
if data, err := os.ReadFile(claudeMdPath); err == nil {
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "CLAUDE.md"), data, 0644)
|
||||
if data, err := coreio.Local.Read(claudeMdPath); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "CLAUDE.md"), data)
|
||||
out.ClaudeMd = true
|
||||
}
|
||||
// Copy GEMINI.md from core/agent (ethics framework for all agents)
|
||||
agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md")
|
||||
if data, err := os.ReadFile(agentGeminiMd); err == nil {
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "GEMINI.md"), data, 0644)
|
||||
if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "GEMINI.md"), data)
|
||||
}
|
||||
|
||||
// Copy persona if specified
|
||||
if input.Persona != "" {
|
||||
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", input.Persona+".md")
|
||||
if data, err := os.ReadFile(personaPath); err == nil {
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "PERSONA.md"), data, 0644)
|
||||
if data, err := coreio.Local.Read(personaPath); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,7 +205,7 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
|||
} else if input.Task != "" {
|
||||
todo := fmt.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n",
|
||||
input.Task, input.Org, input.Repo, input.Task)
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "TODO.md"), []byte(todo), 0644)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), todo)
|
||||
}
|
||||
|
||||
// 4. Generate CONTEXT.md from OpenBrain
|
||||
|
|
@ -300,7 +302,7 @@ Do NOT push. Commit only — a reviewer will verify and push.
|
|||
prompt = "Read TODO.md and complete the task. Work in src/.\n"
|
||||
}
|
||||
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "PROMPT.md"), []byte(prompt), 0644)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "PROMPT.md"), prompt)
|
||||
}
|
||||
|
||||
// --- Plan template rendering ---
|
||||
|
|
@ -310,18 +312,16 @@ Do NOT push. Commit only — a reviewer will verify and push.
|
|||
func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) {
|
||||
// Look for template in core/agent/prompts/templates/
|
||||
templatePath := filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
|
||||
data, err := os.ReadFile(templatePath)
|
||||
content, err := coreio.Local.Read(templatePath)
|
||||
if err != nil {
|
||||
// Try .yml extension
|
||||
templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
|
||||
data, err = os.ReadFile(templatePath)
|
||||
content, err = coreio.Local.Read(templatePath)
|
||||
if err != nil {
|
||||
return // Template not found, skip silently
|
||||
}
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Substitute variables ({{variable_name}} → value)
|
||||
for key, value := range variables {
|
||||
content = strings.ReplaceAll(content, "{{"+key+"}}", value)
|
||||
|
|
@ -380,7 +380,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
|
|||
plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
|
||||
}
|
||||
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "PLAN.md"), []byte(plan.String()), 0644)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "PLAN.md"), plan.String())
|
||||
}
|
||||
|
||||
// --- Helpers (unchanged) ---
|
||||
|
|
@ -440,7 +440,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
|||
return '-'
|
||||
}, page.Title) + ".md"
|
||||
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "kb", filename), content, 0644)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "kb", filename), string(content))
|
||||
count++
|
||||
}
|
||||
|
||||
|
|
@ -453,8 +453,8 @@ func (s *PrepSubsystem) copySpecs(wsDir string) int {
|
|||
|
||||
for _, file := range specFiles {
|
||||
src := filepath.Join(s.specsPath, file)
|
||||
if data, err := os.ReadFile(src); err == nil {
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "specs", file), data, 0644)
|
||||
if data, err := coreio.Local.Read(src); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "specs", file), data)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
|
@ -503,7 +503,7 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
|
|||
content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent))
|
||||
}
|
||||
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "CONTEXT.md"), []byte(content.String()), 0644)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String())
|
||||
return len(result.Memories)
|
||||
}
|
||||
|
||||
|
|
@ -511,24 +511,24 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
|
|||
goWorkPath := filepath.Join(s.codePath, "go.work")
|
||||
modulePath := "forge.lthn.ai/core/" + repo
|
||||
|
||||
workData, err := os.ReadFile(goWorkPath)
|
||||
workData, err := coreio.Local.Read(goWorkPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
var consumers []string
|
||||
for _, line := range strings.Split(string(workData), "\n") {
|
||||
for _, line := range strings.Split(workData, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "./") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./"))
|
||||
goMod := filepath.Join(dir, "go.mod")
|
||||
modData, err := os.ReadFile(goMod)
|
||||
modData, err := coreio.Local.Read(goMod)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(modData), modulePath) && !strings.HasPrefix(string(modData), "module "+modulePath) {
|
||||
if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) {
|
||||
consumers = append(consumers, filepath.Base(dir))
|
||||
}
|
||||
}
|
||||
|
|
@ -540,7 +540,7 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
|
|||
content += "- " + c + "\n"
|
||||
}
|
||||
content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "CONSUMERS.md"), []byte(content), 0644)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "CONSUMERS.md"), content)
|
||||
}
|
||||
|
||||
return len(consumers)
|
||||
|
|
@ -557,7 +557,7 @@ func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int {
|
|||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
if len(lines) > 0 && lines[0] != "" {
|
||||
content := "# Recent Changes\n\n```\n" + string(output) + "```\n"
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "RECENT.md"), []byte(content), 0644)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "RECENT.md"), content)
|
||||
}
|
||||
|
||||
return len(lines)
|
||||
|
|
@ -590,5 +590,5 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
|
|||
content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo)
|
||||
content += "## Objective\n\n" + issueData.Body + "\n"
|
||||
|
||||
os.WriteFile(filepath.Join(wsDir, "src", "TODO.md"), []byte(content), 0644)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), content)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"syscall"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
|
@ -48,12 +49,12 @@ func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
|
|||
}
|
||||
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
data, err := coreio.Local.Read(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cfg AgentsConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
|
||||
continue
|
||||
}
|
||||
return &cfg
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import (
|
|||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -40,7 +42,7 @@ func (s *PrepSubsystem) registerResumeTool(server *mcp.Server) {
|
|||
|
||||
func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, input ResumeInput) (*mcp.CallToolResult, ResumeOutput, error) {
|
||||
if input.Workspace == "" {
|
||||
return nil, ResumeOutput{}, fmt.Errorf("workspace is required")
|
||||
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil)
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
|
|
@ -49,17 +51,17 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
|
||||
// Verify workspace exists
|
||||
if _, err := os.Stat(srcDir); err != nil {
|
||||
return nil, ResumeOutput{}, fmt.Errorf("workspace not found: %s", input.Workspace)
|
||||
return nil, ResumeOutput{}, coreerr.E("resume", "workspace not found: "+input.Workspace, nil)
|
||||
}
|
||||
|
||||
// Read current status
|
||||
st, err := readStatus(wsDir)
|
||||
if err != nil {
|
||||
return nil, ResumeOutput{}, fmt.Errorf("no status.json in workspace: %w", err)
|
||||
return nil, ResumeOutput{}, coreerr.E("resume", "no status.json in workspace", err)
|
||||
}
|
||||
|
||||
if st.Status != "blocked" && st.Status != "failed" && st.Status != "completed" {
|
||||
return nil, ResumeOutput{}, fmt.Errorf("workspace is %s, not resumable (must be blocked, failed, or completed)", st.Status)
|
||||
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is "+st.Status+", not resumable (must be blocked, failed, or completed)", nil)
|
||||
}
|
||||
|
||||
// Determine agent
|
||||
|
|
@ -72,8 +74,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
if input.Answer != "" {
|
||||
answerPath := filepath.Join(srcDir, "ANSWER.md")
|
||||
content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer)
|
||||
if err := os.WriteFile(answerPath, []byte(content), 0644); err != nil {
|
||||
return nil, ResumeOutput{}, fmt.Errorf("failed to write ANSWER.md: %w", err)
|
||||
if err := coreio.Local.Write(answerPath, content); err != nil {
|
||||
return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +115,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
|
||||
if err := cmd.Start(); err != nil {
|
||||
outFile.Close()
|
||||
return nil, ResumeOutput{}, fmt.Errorf("failed to spawn %s: %w", agent, err)
|
||||
return nil, ResumeOutput{}, coreerr.E("resume", "failed to spawn "+agent, err)
|
||||
}
|
||||
|
||||
// Update status
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ type ScanIssue struct {
|
|||
|
||||
func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input ScanInput) (*mcp.CallToolResult, ScanOutput, error) {
|
||||
if s.forgeToken == "" {
|
||||
return nil, ScanOutput{}, fmt.Errorf("no Forge token configured")
|
||||
return nil, ScanOutput{}, coreerr.E("scan", "no Forge token configured", nil)
|
||||
}
|
||||
|
||||
if input.Org == "" {
|
||||
|
|
@ -105,7 +106,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
|
|||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to list repos: %v", err)
|
||||
return nil, coreerr.E("listOrgRepos", "failed to list repos", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
|
@ -129,7 +130,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
|
|||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("failed to list issues for %s: %v", repo, err)
|
||||
return nil, coreerr.E("listRepoIssues", "failed to list issues for "+repo, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ package agentic
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -49,16 +50,16 @@ func writeStatus(wsDir string, status *WorkspaceStatus) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(wsDir, "status.json"), data, 0644)
|
||||
return coreio.Local.Write(filepath.Join(wsDir, "status.json"), string(data))
|
||||
}
|
||||
|
||||
func readStatus(wsDir string) (*WorkspaceStatus, error) {
|
||||
data, err := os.ReadFile(filepath.Join(wsDir, "status.json"))
|
||||
data, err := coreio.Local.Read(filepath.Join(wsDir, "status.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s WorkspaceStatus
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
if err := json.Unmarshal([]byte(data), &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
|
|
@ -99,7 +100,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
|
||||
entries, err := os.ReadDir(wsRoot)
|
||||
if err != nil {
|
||||
return nil, StatusOutput{}, fmt.Errorf("no workspaces found: %w", err)
|
||||
return nil, StatusOutput{}, coreerr.E("status", "no workspaces found", err)
|
||||
}
|
||||
|
||||
var workspaces []WorkspaceInfo
|
||||
|
|
@ -150,9 +151,9 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
if err != nil || proc.Signal(nil) != nil {
|
||||
// Process died — check for BLOCKED.md
|
||||
blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md")
|
||||
if data, err := os.ReadFile(blockedPath); err == nil {
|
||||
if data, err := coreio.Local.Read(blockedPath); err == nil {
|
||||
info.Status = "blocked"
|
||||
info.Question = strings.TrimSpace(string(data))
|
||||
info.Question = strings.TrimSpace(data)
|
||||
st.Status = "blocked"
|
||||
st.Question = info.Question
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,15 +6,15 @@ package brain
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/mcp/pkg/mcp/ide"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
|
||||
// but it has not been initialised (headless mode).
|
||||
var errBridgeNotAvailable = errors.New("brain: bridge not available")
|
||||
var errBridgeNotAvailable = coreerr.E("brain", "bridge not available", nil)
|
||||
|
||||
// Subsystem implements mcp.Subsystem for OpenBrain knowledge store operations.
|
||||
// It proxies brain_* tool calls to the Laravel backend via the shared IDE bridge.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -36,8 +38,8 @@ func NewDirect() *DirectSubsystem {
|
|||
|
||||
apiKey := os.Getenv("CORE_BRAIN_KEY")
|
||||
if apiKey == "" {
|
||||
if data, err := os.ReadFile(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil {
|
||||
apiKey = strings.TrimSpace(string(data))
|
||||
if data, err := coreio.Local.Read(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil {
|
||||
apiKey = strings.TrimSpace(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,21 +76,21 @@ func (s *DirectSubsystem) Shutdown(_ context.Context) error { return nil }
|
|||
|
||||
func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body any) (map[string]any, error) {
|
||||
if s.apiKey == "" {
|
||||
return nil, fmt.Errorf("brain: no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)")
|
||||
return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil)
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("brain: marshal request: %w", err)
|
||||
return nil, coreerr.E("brain.apiCall", "marshal request", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("brain: create request: %w", err)
|
||||
return nil, coreerr.E("brain.apiCall", "create request", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
|
@ -96,22 +98,22 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
|
|||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("brain: API call failed: %w", err)
|
||||
return nil, coreerr.E("brain.apiCall", "API call failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("brain: read response: %w", err)
|
||||
return nil, coreerr.E("brain.apiCall", "read response", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("brain: API returned %d: %s", resp.StatusCode, string(respData))
|
||||
return nil, coreerr.E("brain.apiCall", "API returned "+string(respData), nil)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respData, &result); err != nil {
|
||||
return nil, fmt.Errorf("brain: parse response: %w", err)
|
||||
return nil, coreerr.E("brain.apiCall", "parse response", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ package brain
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/mcp/pkg/mcp/ide"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -140,7 +140,7 @@ func (s *Subsystem) brainRemember(_ context.Context, _ *mcp.CallToolRequest, inp
|
|||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, RememberOutput{}, fmt.Errorf("failed to send brain_remember: %w", err)
|
||||
return nil, RememberOutput{}, coreerr.E("brain.remember", "failed to send brain_remember", err)
|
||||
}
|
||||
|
||||
return nil, RememberOutput{
|
||||
|
|
@ -163,7 +163,7 @@ func (s *Subsystem) brainRecall(_ context.Context, _ *mcp.CallToolRequest, input
|
|||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, RecallOutput{}, fmt.Errorf("failed to send brain_recall: %w", err)
|
||||
return nil, RecallOutput{}, coreerr.E("brain.recall", "failed to send brain_recall", err)
|
||||
}
|
||||
|
||||
return nil, RecallOutput{
|
||||
|
|
@ -185,7 +185,7 @@ func (s *Subsystem) brainForget(_ context.Context, _ *mcp.CallToolRequest, input
|
|||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ForgetOutput{}, fmt.Errorf("failed to send brain_forget: %w", err)
|
||||
return nil, ForgetOutput{}, coreerr.E("brain.forget", "failed to send brain_forget", err)
|
||||
}
|
||||
|
||||
return nil, ForgetOutput{
|
||||
|
|
@ -210,7 +210,7 @@ func (s *Subsystem) brainList(_ context.Context, _ *mcp.CallToolRequest, input L
|
|||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ListOutput{}, fmt.Errorf("failed to send brain_list: %w", err)
|
||||
return nil, ListOutput{}, coreerr.E("brain.list", "failed to send brain_list", err)
|
||||
}
|
||||
|
||||
return nil, ListOutput{
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@ package ide
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go-ws"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
|
@ -74,12 +72,12 @@ func (b *Bridge) Send(msg BridgeMessage) error {
|
|||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.conn == nil {
|
||||
return errors.New("bridge: not connected")
|
||||
return coreerr.E("bridge.Send", "not connected", nil)
|
||||
}
|
||||
msg.Timestamp = time.Now()
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bridge: marshal failed: %w", err)
|
||||
return coreerr.E("bridge.Send", "marshal failed", err)
|
||||
}
|
||||
return b.conn.WriteMessage(websocket.TextMessage, data)
|
||||
}
|
||||
|
|
@ -95,7 +93,7 @@ func (b *Bridge) connectLoop(ctx context.Context) {
|
|||
}
|
||||
|
||||
if err := b.dial(ctx); err != nil {
|
||||
log.Printf("ide bridge: connect failed: %v", err)
|
||||
coreerr.Warn("ide bridge: connect failed", "err", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
|
@ -132,7 +130,7 @@ func (b *Bridge) dial(ctx context.Context) error {
|
|||
b.connected = true
|
||||
b.mu.Unlock()
|
||||
|
||||
log.Printf("ide bridge: connected to %s", b.cfg.LaravelWSURL)
|
||||
coreerr.Info("ide bridge: connected", "url", b.cfg.LaravelWSURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -155,13 +153,13 @@ func (b *Bridge) readLoop(ctx context.Context) {
|
|||
|
||||
_, data, err := b.conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("ide bridge: read error: %v", err)
|
||||
coreerr.Warn("ide bridge: read error", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
var msg BridgeMessage
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
log.Printf("ide bridge: unmarshal error: %v", err)
|
||||
coreerr.Warn("ide bridge: unmarshal error", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +184,6 @@ func (b *Bridge) dispatch(msg BridgeMessage) {
|
|||
}
|
||||
|
||||
if err := b.hub.SendToChannel(channel, wsMsg); err != nil {
|
||||
log.Printf("ide bridge: dispatch to %s failed: %v", channel, err)
|
||||
coreerr.Warn("ide bridge: dispatch failed", "channel", channel, "err", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@ package ide
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go-ws"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
|
||||
// but it has not been initialised (headless mode).
|
||||
var errBridgeNotAvailable = errors.New("bridge not available")
|
||||
var errBridgeNotAvailable = coreerr.E("ide", "bridge not available", nil)
|
||||
|
||||
// Subsystem implements mcp.Subsystem and mcp.SubsystemWithShutdown for the IDE.
|
||||
type Subsystem struct {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ package ide
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ func (s *Subsystem) chatSend(_ context.Context, _ *mcp.CallToolRequest, input Ch
|
|||
Data: input.Message,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, ChatSendOutput{}, fmt.Errorf("failed to send message: %w", err)
|
||||
return nil, ChatSendOutput{}, coreerr.E("ide.chatSend", "failed to send message", err)
|
||||
}
|
||||
return nil, ChatSendOutput{
|
||||
Sent: true,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -54,11 +52,11 @@ func WithWorkspaceRoot(root string) Option {
|
|||
// Create sandboxed medium for this workspace
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid workspace root: %w", err)
|
||||
return log.E("WithWorkspaceRoot", "invalid workspace root", err)
|
||||
}
|
||||
m, err := io.NewSandboxed(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create workspace medium: %w", err)
|
||||
return log.E("WithWorkspaceRoot", "failed to create workspace medium", err)
|
||||
}
|
||||
s.workspaceRoot = abs
|
||||
s.medium = m
|
||||
|
|
@ -85,19 +83,19 @@ func New(opts ...Option) (*Service, error) {
|
|||
// Default to current working directory with sandboxed medium
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get working directory: %w", err)
|
||||
return nil, log.E("mcp.New", "failed to get working directory", err)
|
||||
}
|
||||
s.workspaceRoot = cwd
|
||||
m, err := io.NewSandboxed(cwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sandboxed medium: %w", err)
|
||||
return nil, log.E("mcp.New", "failed to create sandboxed medium", err)
|
||||
}
|
||||
s.medium = m
|
||||
|
||||
// Apply options
|
||||
for _, opt := range opts {
|
||||
if err := opt(s); err != nil {
|
||||
return nil, fmt.Errorf("failed to apply option: %w", err)
|
||||
return nil, log.E("mcp.New", "failed to apply option", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,7 +134,7 @@ func (s *Service) Shutdown(ctx context.Context) error {
|
|||
for _, sub := range s.subsystems {
|
||||
if sh, ok := sub.(SubsystemWithShutdown); ok {
|
||||
if err := sh.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("shutdown %s: %w", sub.Name(), err)
|
||||
return log.E("mcp.Shutdown", "shutdown "+sub.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -363,7 +361,7 @@ type EditDiffOutput struct {
|
|||
func (s *Service) readFile(ctx context.Context, req *mcp.CallToolRequest, input ReadFileInput) (*mcp.CallToolResult, ReadFileOutput, error) {
|
||||
content, err := s.medium.Read(input.Path)
|
||||
if err != nil {
|
||||
return nil, ReadFileOutput{}, fmt.Errorf("failed to read file: %w", err)
|
||||
return nil, ReadFileOutput{}, log.E("mcp.readFile", "failed to read file", err)
|
||||
}
|
||||
return nil, ReadFileOutput{
|
||||
Content: content,
|
||||
|
|
@ -375,7 +373,7 @@ func (s *Service) readFile(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
func (s *Service) writeFile(ctx context.Context, req *mcp.CallToolRequest, input WriteFileInput) (*mcp.CallToolResult, WriteFileOutput, error) {
|
||||
// Medium.Write creates parent directories automatically
|
||||
if err := s.medium.Write(input.Path, input.Content); err != nil {
|
||||
return nil, WriteFileOutput{}, fmt.Errorf("failed to write file: %w", err)
|
||||
return nil, WriteFileOutput{}, log.E("mcp.writeFile", "failed to write file", err)
|
||||
}
|
||||
return nil, WriteFileOutput{Success: true, Path: input.Path}, nil
|
||||
}
|
||||
|
|
@ -383,7 +381,7 @@ func (s *Service) writeFile(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, input ListDirectoryInput) (*mcp.CallToolResult, ListDirectoryOutput, error) {
|
||||
entries, err := s.medium.List(input.Path)
|
||||
if err != nil {
|
||||
return nil, ListDirectoryOutput{}, fmt.Errorf("failed to list directory: %w", err)
|
||||
return nil, ListDirectoryOutput{}, log.E("mcp.listDirectory", "failed to list directory", err)
|
||||
}
|
||||
result := make([]DirectoryEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
|
|
@ -407,21 +405,21 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i
|
|||
|
||||
func (s *Service) createDirectory(ctx context.Context, req *mcp.CallToolRequest, input CreateDirectoryInput) (*mcp.CallToolResult, CreateDirectoryOutput, error) {
|
||||
if err := s.medium.EnsureDir(input.Path); err != nil {
|
||||
return nil, CreateDirectoryOutput{}, fmt.Errorf("failed to create directory: %w", err)
|
||||
return nil, CreateDirectoryOutput{}, log.E("mcp.createDirectory", "failed to create directory", err)
|
||||
}
|
||||
return nil, CreateDirectoryOutput{Success: true, Path: input.Path}, nil
|
||||
}
|
||||
|
||||
func (s *Service) deleteFile(ctx context.Context, req *mcp.CallToolRequest, input DeleteFileInput) (*mcp.CallToolResult, DeleteFileOutput, error) {
|
||||
if err := s.medium.Delete(input.Path); err != nil {
|
||||
return nil, DeleteFileOutput{}, fmt.Errorf("failed to delete file: %w", err)
|
||||
return nil, DeleteFileOutput{}, log.E("mcp.deleteFile", "failed to delete file", err)
|
||||
}
|
||||
return nil, DeleteFileOutput{Success: true, Path: input.Path}, nil
|
||||
}
|
||||
|
||||
func (s *Service) renameFile(ctx context.Context, req *mcp.CallToolRequest, input RenameFileInput) (*mcp.CallToolResult, RenameFileOutput, error) {
|
||||
if err := s.medium.Rename(input.OldPath, input.NewPath); err != nil {
|
||||
return nil, RenameFileOutput{}, fmt.Errorf("failed to rename file: %w", err)
|
||||
return nil, RenameFileOutput{}, log.E("mcp.renameFile", "failed to rename file", err)
|
||||
}
|
||||
return nil, RenameFileOutput{Success: true, OldPath: input.OldPath, NewPath: input.NewPath}, nil
|
||||
}
|
||||
|
|
@ -472,12 +470,12 @@ func (s *Service) getSupportedLanguages(ctx context.Context, req *mcp.CallToolRe
|
|||
|
||||
func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input EditDiffInput) (*mcp.CallToolResult, EditDiffOutput, error) {
|
||||
if input.OldString == "" {
|
||||
return nil, EditDiffOutput{}, errors.New("old_string cannot be empty")
|
||||
return nil, EditDiffOutput{}, log.E("mcp.editDiff", "old_string cannot be empty", nil)
|
||||
}
|
||||
|
||||
content, err := s.medium.Read(input.Path)
|
||||
if err != nil {
|
||||
return nil, EditDiffOutput{}, fmt.Errorf("failed to read file: %w", err)
|
||||
return nil, EditDiffOutput{}, log.E("mcp.editDiff", "failed to read file", err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
|
|
@ -485,19 +483,19 @@ func (s *Service) editDiff(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
if input.ReplaceAll {
|
||||
count = strings.Count(content, input.OldString)
|
||||
if count == 0 {
|
||||
return nil, EditDiffOutput{}, errors.New("old_string not found in file")
|
||||
return nil, EditDiffOutput{}, log.E("mcp.editDiff", "old_string not found in file", nil)
|
||||
}
|
||||
content = strings.ReplaceAll(content, input.OldString, input.NewString)
|
||||
} else {
|
||||
if !strings.Contains(content, input.OldString) {
|
||||
return nil, EditDiffOutput{}, errors.New("old_string not found in file")
|
||||
return nil, EditDiffOutput{}, log.E("mcp.editDiff", "old_string not found in file", nil)
|
||||
}
|
||||
content = strings.Replace(content, input.OldString, input.NewString, 1)
|
||||
count = 1
|
||||
}
|
||||
|
||||
if err := s.medium.Write(input.Path, content); err != nil {
|
||||
return nil, EditDiffOutput{}, fmt.Errorf("failed to write file: %w", err)
|
||||
return nil, EditDiffOutput{}, log.E("mcp.editDiff", "failed to write file", err)
|
||||
}
|
||||
|
||||
return nil, EditDiffOutput{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -80,7 +79,7 @@ func (s *Service) metricsRecord(ctx context.Context, req *mcp.CallToolRequest, i
|
|||
|
||||
// Validate input
|
||||
if input.Type == "" {
|
||||
return nil, MetricsRecordOutput{}, errors.New("type cannot be empty")
|
||||
return nil, MetricsRecordOutput{}, log.E("metricsRecord", "type cannot be empty", nil)
|
||||
}
|
||||
|
||||
// Create the event
|
||||
|
|
@ -95,7 +94,7 @@ func (s *Service) metricsRecord(ctx context.Context, req *mcp.CallToolRequest, i
|
|||
// Record the event
|
||||
if err := ai.Record(event); err != nil {
|
||||
log.Error("mcp: metrics record failed", "type", input.Type, "err", err)
|
||||
return nil, MetricsRecordOutput{}, fmt.Errorf("failed to record metrics: %w", err)
|
||||
return nil, MetricsRecordOutput{}, log.E("metricsRecord", "failed to record metrics", err)
|
||||
}
|
||||
|
||||
return nil, MetricsRecordOutput{
|
||||
|
|
@ -117,7 +116,7 @@ func (s *Service) metricsQuery(ctx context.Context, req *mcp.CallToolRequest, in
|
|||
// Parse the duration
|
||||
duration, err := parseDuration(since)
|
||||
if err != nil {
|
||||
return nil, MetricsQueryOutput{}, fmt.Errorf("invalid since value: %w", err)
|
||||
return nil, MetricsQueryOutput{}, log.E("metricsQuery", "invalid since value", err)
|
||||
}
|
||||
|
||||
sinceTime := time.Now().Add(-duration)
|
||||
|
|
@ -126,7 +125,7 @@ func (s *Service) metricsQuery(ctx context.Context, req *mcp.CallToolRequest, in
|
|||
events, err := ai.ReadEvents(sinceTime)
|
||||
if err != nil {
|
||||
log.Error("mcp: metrics query failed", "since", since, "err", err)
|
||||
return nil, MetricsQueryOutput{}, fmt.Errorf("failed to read metrics: %w", err)
|
||||
return nil, MetricsQueryOutput{}, log.E("metricsQuery", "failed to read metrics", err)
|
||||
}
|
||||
|
||||
// Get summary
|
||||
|
|
@ -179,12 +178,12 @@ func convertMetricCounts(data any) []MetricCount {
|
|||
// parseDuration parses a duration string like "7d", "24h", "30m".
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
if s == "" {
|
||||
return 0, errors.New("duration cannot be empty")
|
||||
return 0, log.E("parseDuration", "duration cannot be empty", nil)
|
||||
}
|
||||
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 2 {
|
||||
return 0, fmt.Errorf("invalid duration format: %q", s)
|
||||
return 0, log.E("parseDuration", "invalid duration format: "+s, nil)
|
||||
}
|
||||
|
||||
// Get the numeric part and unit
|
||||
|
|
@ -193,11 +192,11 @@ func parseDuration(s string) (time.Duration, error) {
|
|||
|
||||
num, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration number: %q", numStr)
|
||||
return 0, log.E("parseDuration", "invalid duration number: "+numStr, err)
|
||||
}
|
||||
|
||||
if num <= 0 {
|
||||
return 0, fmt.Errorf("duration must be positive: %d", num)
|
||||
return 0, log.E("parseDuration", fmt.Sprintf("duration must be positive: %d", num), nil)
|
||||
}
|
||||
|
||||
switch unit {
|
||||
|
|
@ -208,6 +207,6 @@ func parseDuration(s string) (time.Duration, error) {
|
|||
case 'm':
|
||||
return time.Duration(num) * time.Minute, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid duration unit: %q (expected d, h, or m)", string(unit))
|
||||
return 0, log.E("parseDuration", "invalid duration unit: "+string(unit)+" (expected d, h, or m)", nil)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go-log"
|
||||
|
|
@ -12,7 +10,7 @@ import (
|
|||
)
|
||||
|
||||
// errIDEmpty is returned when a process tool call omits the required ID.
|
||||
var errIDEmpty = errors.New("id cannot be empty")
|
||||
var errIDEmpty = log.E("process", "id cannot be empty", nil)
|
||||
|
||||
// ProcessStartInput contains parameters for starting a new process.
|
||||
type ProcessStartInput struct {
|
||||
|
|
@ -148,7 +146,7 @@ func (s *Service) processStart(ctx context.Context, req *mcp.CallToolRequest, in
|
|||
s.logger.Security("MCP tool execution", "tool", "process_start", "command", input.Command, "args", input.Args, "dir", input.Dir, "user", log.Username())
|
||||
|
||||
if input.Command == "" {
|
||||
return nil, ProcessStartOutput{}, errors.New("command cannot be empty")
|
||||
return nil, ProcessStartOutput{}, log.E("processStart", "command cannot be empty", nil)
|
||||
}
|
||||
|
||||
opts := process.RunOptions{
|
||||
|
|
@ -161,7 +159,7 @@ func (s *Service) processStart(ctx context.Context, req *mcp.CallToolRequest, in
|
|||
proc, err := s.processService.StartWithOptions(ctx, opts)
|
||||
if err != nil {
|
||||
log.Error("mcp: process start failed", "command", input.Command, "err", err)
|
||||
return nil, ProcessStartOutput{}, fmt.Errorf("failed to start process: %w", err)
|
||||
return nil, ProcessStartOutput{}, log.E("processStart", "failed to start process", err)
|
||||
}
|
||||
|
||||
info := proc.Info()
|
||||
|
|
@ -185,14 +183,14 @@ func (s *Service) processStop(ctx context.Context, req *mcp.CallToolRequest, inp
|
|||
proc, err := s.processService.Get(input.ID)
|
||||
if err != nil {
|
||||
log.Error("mcp: process stop failed", "id", input.ID, "err", err)
|
||||
return nil, ProcessStopOutput{}, fmt.Errorf("process not found: %w", err)
|
||||
return nil, ProcessStopOutput{}, log.E("processStop", "process not found", err)
|
||||
}
|
||||
|
||||
// For graceful stop, we use Kill() which sends SIGKILL
|
||||
// A more sophisticated implementation could use SIGTERM first
|
||||
if err := proc.Kill(); err != nil {
|
||||
log.Error("mcp: process stop kill failed", "id", input.ID, "err", err)
|
||||
return nil, ProcessStopOutput{}, fmt.Errorf("failed to stop process: %w", err)
|
||||
return nil, ProcessStopOutput{}, log.E("processStop", "failed to stop process", err)
|
||||
}
|
||||
|
||||
return nil, ProcessStopOutput{
|
||||
|
|
@ -212,7 +210,7 @@ func (s *Service) processKill(ctx context.Context, req *mcp.CallToolRequest, inp
|
|||
|
||||
if err := s.processService.Kill(input.ID); err != nil {
|
||||
log.Error("mcp: process kill failed", "id", input.ID, "err", err)
|
||||
return nil, ProcessKillOutput{}, fmt.Errorf("failed to kill process: %w", err)
|
||||
return nil, ProcessKillOutput{}, log.E("processKill", "failed to kill process", err)
|
||||
}
|
||||
|
||||
return nil, ProcessKillOutput{
|
||||
|
|
@ -266,7 +264,7 @@ func (s *Service) processOutput(ctx context.Context, req *mcp.CallToolRequest, i
|
|||
output, err := s.processService.Output(input.ID)
|
||||
if err != nil {
|
||||
log.Error("mcp: process output failed", "id", input.ID, "err", err)
|
||||
return nil, ProcessOutputOutput{}, fmt.Errorf("failed to get process output: %w", err)
|
||||
return nil, ProcessOutputOutput{}, log.E("processOutput", "failed to get process output", err)
|
||||
}
|
||||
|
||||
return nil, ProcessOutputOutput{
|
||||
|
|
@ -283,18 +281,18 @@ func (s *Service) processInput(ctx context.Context, req *mcp.CallToolRequest, in
|
|||
return nil, ProcessInputOutput{}, errIDEmpty
|
||||
}
|
||||
if input.Input == "" {
|
||||
return nil, ProcessInputOutput{}, errors.New("input cannot be empty")
|
||||
return nil, ProcessInputOutput{}, log.E("processInput", "input cannot be empty", nil)
|
||||
}
|
||||
|
||||
proc, err := s.processService.Get(input.ID)
|
||||
if err != nil {
|
||||
log.Error("mcp: process input get failed", "id", input.ID, "err", err)
|
||||
return nil, ProcessInputOutput{}, fmt.Errorf("process not found: %w", err)
|
||||
return nil, ProcessInputOutput{}, log.E("processInput", "process not found", err)
|
||||
}
|
||||
|
||||
if err := proc.SendInput(input.Input); err != nil {
|
||||
log.Error("mcp: process input send failed", "id", input.ID, "err", err)
|
||||
return nil, ProcessInputOutput{}, fmt.Errorf("failed to send input: %w", err)
|
||||
return nil, ProcessInputOutput{}, log.E("processInput", "failed to send input", err)
|
||||
}
|
||||
|
||||
return nil, ProcessInputOutput{
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go-rag"
|
||||
"forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go-rag"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -108,14 +107,14 @@ func (s *Service) ragQuery(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
|
||||
// Validate input
|
||||
if input.Question == "" {
|
||||
return nil, RAGQueryOutput{}, errors.New("question cannot be empty")
|
||||
return nil, RAGQueryOutput{}, log.E("ragQuery", "question cannot be empty", nil)
|
||||
}
|
||||
|
||||
// Call the RAG query function
|
||||
results, err := rag.QueryDocs(ctx, input.Question, collection, topK)
|
||||
if err != nil {
|
||||
log.Error("mcp: rag query failed", "question", input.Question, "collection", collection, "err", err)
|
||||
return nil, RAGQueryOutput{}, fmt.Errorf("failed to query RAG: %w", err)
|
||||
return nil, RAGQueryOutput{}, log.E("ragQuery", "failed to query RAG", err)
|
||||
}
|
||||
|
||||
// Convert results
|
||||
|
|
@ -151,14 +150,14 @@ func (s *Service) ragIngest(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
|
||||
// Validate input
|
||||
if input.Path == "" {
|
||||
return nil, RAGIngestOutput{}, errors.New("path cannot be empty")
|
||||
return nil, RAGIngestOutput{}, log.E("ragIngest", "path cannot be empty", nil)
|
||||
}
|
||||
|
||||
// Check if path is a file or directory using the medium
|
||||
info, err := s.medium.Stat(input.Path)
|
||||
if err != nil {
|
||||
log.Error("mcp: rag ingest stat failed", "path", input.Path, "err", err)
|
||||
return nil, RAGIngestOutput{}, fmt.Errorf("failed to access path: %w", err)
|
||||
return nil, RAGIngestOutput{}, log.E("ragIngest", "failed to access path", err)
|
||||
}
|
||||
|
||||
var message string
|
||||
|
|
@ -168,7 +167,7 @@ func (s *Service) ragIngest(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
err = rag.IngestDirectory(ctx, input.Path, collection, input.Recreate)
|
||||
if err != nil {
|
||||
log.Error("mcp: rag ingest directory failed", "path", input.Path, "collection", collection, "err", err)
|
||||
return nil, RAGIngestOutput{}, fmt.Errorf("failed to ingest directory: %w", err)
|
||||
return nil, RAGIngestOutput{}, log.E("ragIngest", "failed to ingest directory", err)
|
||||
}
|
||||
message = fmt.Sprintf("Successfully ingested directory %s into collection %s", input.Path, collection)
|
||||
} else {
|
||||
|
|
@ -176,7 +175,7 @@ func (s *Service) ragIngest(ctx context.Context, req *mcp.CallToolRequest, input
|
|||
chunks, err = rag.IngestSingleFile(ctx, input.Path, collection)
|
||||
if err != nil {
|
||||
log.Error("mcp: rag ingest file failed", "path", input.Path, "collection", collection, "err", err)
|
||||
return nil, RAGIngestOutput{}, fmt.Errorf("failed to ingest file: %w", err)
|
||||
return nil, RAGIngestOutput{}, log.E("ragIngest", "failed to ingest file", err)
|
||||
}
|
||||
message = fmt.Sprintf("Successfully ingested file %s (%d chunks) into collection %s", input.Path, chunks, collection)
|
||||
}
|
||||
|
|
@ -198,7 +197,7 @@ func (s *Service) ragCollections(ctx context.Context, req *mcp.CallToolRequest,
|
|||
qdrantClient, err := rag.NewQdrantClient(rag.DefaultQdrantConfig())
|
||||
if err != nil {
|
||||
log.Error("mcp: rag collections connect failed", "err", err)
|
||||
return nil, RAGCollectionsOutput{}, fmt.Errorf("failed to connect to Qdrant: %w", err)
|
||||
return nil, RAGCollectionsOutput{}, log.E("ragCollections", "failed to connect to Qdrant", err)
|
||||
}
|
||||
defer func() { _ = qdrantClient.Close() }()
|
||||
|
||||
|
|
@ -206,7 +205,7 @@ func (s *Service) ragCollections(ctx context.Context, req *mcp.CallToolRequest,
|
|||
collectionNames, err := qdrantClient.ListCollections(ctx)
|
||||
if err != nil {
|
||||
log.Error("mcp: rag collections list failed", "err", err)
|
||||
return nil, RAGCollectionsOutput{}, fmt.Errorf("failed to list collections: %w", err)
|
||||
return nil, RAGCollectionsOutput{}, log.E("ragCollections", "failed to list collections", err)
|
||||
}
|
||||
|
||||
// Build collection info list
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
|
|
@ -18,8 +17,8 @@ var webviewInstance *webview.Webview
|
|||
|
||||
// Sentinel errors for webview tools.
|
||||
var (
|
||||
errNotConnected = errors.New("not connected; use webview_connect first")
|
||||
errSelectorRequired = errors.New("selector is required")
|
||||
errNotConnected = log.E("webview", "not connected; use webview_connect first", nil)
|
||||
errSelectorRequired = log.E("webview", "selector is required", nil)
|
||||
)
|
||||
|
||||
// WebviewConnectInput contains parameters for connecting to Chrome DevTools.
|
||||
|
|
@ -210,7 +209,7 @@ func (s *Service) webviewConnect(ctx context.Context, req *mcp.CallToolRequest,
|
|||
s.logger.Security("MCP tool execution", "tool", "webview_connect", "debug_url", input.DebugURL, "user", log.Username())
|
||||
|
||||
if input.DebugURL == "" {
|
||||
return nil, WebviewConnectOutput{}, errors.New("debug_url is required")
|
||||
return nil, WebviewConnectOutput{}, log.E("webviewConnect", "debug_url is required", nil)
|
||||
}
|
||||
|
||||
// Close existing connection if any
|
||||
|
|
@ -232,7 +231,7 @@ func (s *Service) webviewConnect(ctx context.Context, req *mcp.CallToolRequest,
|
|||
wv, err := webview.New(opts...)
|
||||
if err != nil {
|
||||
log.Error("mcp: webview connect failed", "debug_url", input.DebugURL, "err", err)
|
||||
return nil, WebviewConnectOutput{}, fmt.Errorf("failed to connect: %w", err)
|
||||
return nil, WebviewConnectOutput{}, log.E("webviewConnect", "failed to connect", err)
|
||||
}
|
||||
|
||||
webviewInstance = wv
|
||||
|
|
@ -256,7 +255,7 @@ func (s *Service) webviewDisconnect(ctx context.Context, req *mcp.CallToolReques
|
|||
|
||||
if err := webviewInstance.Close(); err != nil {
|
||||
log.Error("mcp: webview disconnect failed", "err", err)
|
||||
return nil, WebviewDisconnectOutput{}, fmt.Errorf("failed to disconnect: %w", err)
|
||||
return nil, WebviewDisconnectOutput{}, log.E("webviewDisconnect", "failed to disconnect", err)
|
||||
}
|
||||
|
||||
webviewInstance = nil
|
||||
|
|
@ -276,12 +275,12 @@ func (s *Service) webviewNavigate(ctx context.Context, req *mcp.CallToolRequest,
|
|||
}
|
||||
|
||||
if input.URL == "" {
|
||||
return nil, WebviewNavigateOutput{}, errors.New("url is required")
|
||||
return nil, WebviewNavigateOutput{}, log.E("webviewNavigate", "url is required", nil)
|
||||
}
|
||||
|
||||
if err := webviewInstance.Navigate(input.URL); err != nil {
|
||||
log.Error("mcp: webview navigate failed", "url", input.URL, "err", err)
|
||||
return nil, WebviewNavigateOutput{}, fmt.Errorf("failed to navigate: %w", err)
|
||||
return nil, WebviewNavigateOutput{}, log.E("webviewNavigate", "failed to navigate", err)
|
||||
}
|
||||
|
||||
return nil, WebviewNavigateOutput{
|
||||
|
|
@ -304,7 +303,7 @@ func (s *Service) webviewClick(ctx context.Context, req *mcp.CallToolRequest, in
|
|||
|
||||
if err := webviewInstance.Click(input.Selector); err != nil {
|
||||
log.Error("mcp: webview click failed", "selector", input.Selector, "err", err)
|
||||
return nil, WebviewClickOutput{}, fmt.Errorf("failed to click: %w", err)
|
||||
return nil, WebviewClickOutput{}, log.E("webviewClick", "failed to click", err)
|
||||
}
|
||||
|
||||
return nil, WebviewClickOutput{Success: true}, nil
|
||||
|
|
@ -324,7 +323,7 @@ func (s *Service) webviewType(ctx context.Context, req *mcp.CallToolRequest, inp
|
|||
|
||||
if err := webviewInstance.Type(input.Selector, input.Text); err != nil {
|
||||
log.Error("mcp: webview type failed", "selector", input.Selector, "err", err)
|
||||
return nil, WebviewTypeOutput{}, fmt.Errorf("failed to type: %w", err)
|
||||
return nil, WebviewTypeOutput{}, log.E("webviewType", "failed to type", err)
|
||||
}
|
||||
|
||||
return nil, WebviewTypeOutput{Success: true}, nil
|
||||
|
|
@ -346,7 +345,7 @@ func (s *Service) webviewQuery(ctx context.Context, req *mcp.CallToolRequest, in
|
|||
elements, err := webviewInstance.QuerySelectorAll(input.Selector)
|
||||
if err != nil {
|
||||
log.Error("mcp: webview query all failed", "selector", input.Selector, "err", err)
|
||||
return nil, WebviewQueryOutput{}, fmt.Errorf("failed to query: %w", err)
|
||||
return nil, WebviewQueryOutput{}, log.E("webviewQuery", "failed to query", err)
|
||||
}
|
||||
|
||||
output := WebviewQueryOutput{
|
||||
|
|
@ -429,7 +428,7 @@ func (s *Service) webviewEval(ctx context.Context, req *mcp.CallToolRequest, inp
|
|||
}
|
||||
|
||||
if input.Script == "" {
|
||||
return nil, WebviewEvalOutput{}, errors.New("script is required")
|
||||
return nil, WebviewEvalOutput{}, log.E("webviewEval", "script is required", nil)
|
||||
}
|
||||
|
||||
result, err := webviewInstance.Evaluate(input.Script)
|
||||
|
|
@ -463,7 +462,7 @@ func (s *Service) webviewScreenshot(ctx context.Context, req *mcp.CallToolReques
|
|||
data, err := webviewInstance.Screenshot()
|
||||
if err != nil {
|
||||
log.Error("mcp: webview screenshot failed", "err", err)
|
||||
return nil, WebviewScreenshotOutput{}, fmt.Errorf("failed to capture screenshot: %w", err)
|
||||
return nil, WebviewScreenshotOutput{}, log.E("webviewScreenshot", "failed to capture screenshot", err)
|
||||
}
|
||||
|
||||
return nil, WebviewScreenshotOutput{
|
||||
|
|
@ -487,7 +486,7 @@ func (s *Service) webviewWait(ctx context.Context, req *mcp.CallToolRequest, inp
|
|||
|
||||
if err := webviewInstance.WaitForSelector(input.Selector); err != nil {
|
||||
log.Error("mcp: webview wait failed", "selector", input.Selector, "err", err)
|
||||
return nil, WebviewWaitOutput{}, fmt.Errorf("failed to wait for selector: %w", err)
|
||||
return nil, WebviewWaitOutput{}, log.E("webviewWait", "failed to wait for selector", err)
|
||||
}
|
||||
|
||||
return nil, WebviewWaitOutput{
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ func (s *Service) wsStart(ctx context.Context, req *mcp.CallToolRequest, input W
|
|||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
log.Error("mcp: ws start listen failed", "addr", addr, "err", err)
|
||||
return nil, WSStartOutput{}, fmt.Errorf("failed to listen on %s: %w", addr, err)
|
||||
return nil, WSStartOutput{}, log.E("wsStart", "failed to listen on "+addr, err)
|
||||
}
|
||||
|
||||
actualAddr := ln.Addr().String()
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"forge.lthn.ai/core/go-io"
|
||||
"forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ import (
|
|||
// It accepts connections and spawns a new MCP server session for each connection.
|
||||
func (s *Service) ServeUnix(ctx context.Context, socketPath string) error {
|
||||
// Clean up any stale socket file
|
||||
if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) {
|
||||
if err := io.Local.Delete(socketPath); err != nil {
|
||||
s.logger.Warn("Failed to remove stale socket", "path", socketPath, "err", err)
|
||||
}
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ func (s *Service) ServeUnix(ctx context.Context, socketPath string) error {
|
|||
}
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
_ = os.Remove(socketPath)
|
||||
_ = io.Local.Delete(socketPath)
|
||||
}()
|
||||
|
||||
// Close listener when context is cancelled to unblock Accept
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue