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:
Snider 2026-03-16 21:57:12 +00:00
parent d6dccec914
commit 424b5eb91d
24 changed files with 217 additions and 209 deletions

View file

@ -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),
})
}

View file

@ -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

View file

@ -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 {

View file

@ -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,

View file

@ -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 {

View file

@ -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()

View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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 {

View file

@ -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.

View file

@ -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

View file

@ -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{

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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,

View file

@ -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{

View file

@ -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)
}
}

View file

@ -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{

View file

@ -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

View file

@ -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{

View file

@ -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()

View file

@ -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