Compare commits
9 commits
agent/read
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8144fde09 | ||
|
|
f9c5362151 | ||
|
|
8f3afaa42a | ||
|
|
f8f137b465 | ||
|
|
429f1c2b6c | ||
|
|
9f7dd84d4a | ||
|
|
9bd3084da4 | ||
|
|
20e4a381cf | ||
|
|
cd305904e5 |
51 changed files with 557 additions and 491 deletions
|
|
@ -27,8 +27,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
coreio "dappco.re/go/core/io"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"dappco.re/go/core/cli/pkg/cli"
|
||||||
mcpcmd "dappco.re/go/mcp/cmd/mcpcmd"
|
mcpcmd "dappco.re/go/mcp/cmd/mcpcmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
"dappco.re/go/mcp/pkg/mcp"
|
"dappco.re/go/mcp/pkg/mcp"
|
||||||
"dappco.re/go/mcp/pkg/mcp/agentic"
|
"dappco.re/go/mcp/pkg/mcp/agentic"
|
||||||
"dappco.re/go/mcp/pkg/mcp/brain"
|
"dappco.re/go/mcp/pkg/mcp/brain"
|
||||||
"forge.lthn.ai/core/cli/pkg/cli"
|
"dappco.re/go/core/cli/pkg/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
var workspaceFlag string
|
var workspaceFlag string
|
||||||
|
|
|
||||||
27
go.mod
27
go.mod
|
|
@ -4,26 +4,25 @@ go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.8.0-alpha.1
|
dappco.re/go/core v0.8.0-alpha.1
|
||||||
forge.lthn.ai/core/api v0.1.5
|
dappco.re/go/core/ai v0.2.2
|
||||||
forge.lthn.ai/core/cli v0.3.7
|
dappco.re/go/core/api v0.3.0
|
||||||
forge.lthn.ai/core/go-ai v0.1.12
|
dappco.re/go/core/cli v0.5.2
|
||||||
forge.lthn.ai/core/go-io v0.1.7
|
dappco.re/go/core/io v0.4.1
|
||||||
forge.lthn.ai/core/go-log v0.0.4
|
dappco.re/go/core/log v0.1.2
|
||||||
forge.lthn.ai/core/go-process v0.2.9
|
dappco.re/go/core/process v0.5.0
|
||||||
forge.lthn.ai/core/go-rag v0.1.11
|
dappco.re/go/core/rag v0.1.13
|
||||||
forge.lthn.ai/core/go-webview v0.1.6
|
dappco.re/go/core/webview v0.2.1
|
||||||
forge.lthn.ai/core/go-ws v0.2.5
|
dappco.re/go/core/ws v0.4.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/go v0.3.3 // indirect
|
dappco.re/go/core/i18n v0.2.3 // indirect
|
||||||
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
|
dappco.re/go/core/inference v0.3.0 // indirect
|
||||||
forge.lthn.ai/core/go-inference v0.1.6 // indirect
|
|
||||||
github.com/99designs/gqlgen v0.17.88 // indirect
|
github.com/99designs/gqlgen v0.17.88 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||||
|
|
@ -149,3 +148,5 @@ require (
|
||||||
google.golang.org/grpc v1.79.2 // indirect
|
google.golang.org/grpc v1.79.2 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
replace dappco.re/go/core/process => ../go-process
|
||||||
|
|
|
||||||
48
go.sum
48
go.sum
|
|
@ -1,29 +1,25 @@
|
||||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
||||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o=
|
dappco.re/go/core/ai v0.2.2 h1:fkSKm3ezAljYbghlax5qHDm11uq7LUyIedIQO1PtdcY=
|
||||||
forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII=
|
dappco.re/go/core/ai v0.2.2/go.mod h1:+MZN/EArn/W2ag91McL034WxdMSO4IPqFcQER5/POGU=
|
||||||
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
|
dappco.re/go/core/api v0.3.0 h1:uWYgDQ+B4e5pXPX3S5lMsqSJamfpui3LWD5hcdwvWew=
|
||||||
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
|
dappco.re/go/core/api v0.3.0/go.mod h1:1ZDNwPHV6YjkUsjtC3nfLk6U4eqWlQ6qj6yT/MB8r6k=
|
||||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
dappco.re/go/core/cli v0.5.2 h1:mo+PERo3lUytE+r3ArHr8o2nTftXjgPPsU/rn3ETXDM=
|
||||||
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
dappco.re/go/core/cli v0.5.2/go.mod h1:D4zfn3ec/hb72AWX/JWDvkW+h2WDKQcxGUrzoss7q2s=
|
||||||
forge.lthn.ai/core/go-ai v0.1.12 h1:OHt0bUABlyhvgxZxyMwueRoh8rS3YKWGFY6++zCAwC8=
|
dappco.re/go/core/i18n v0.2.3 h1:GqFaTR1I0SfSEc4WtsAkgao+jp8X5qcMPqrX0eMAOrY=
|
||||||
forge.lthn.ai/core/go-ai v0.1.12/go.mod h1:5Pc9lszxgkO7Aj2Z3dtq4L9Xk9l/VNN+Baj1t///OCM=
|
dappco.re/go/core/i18n v0.2.3/go.mod h1:LoyX/4fIEJO/wiHY3Q682+4P0Ob7zPemcATfwp0JBUg=
|
||||||
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
dappco.re/go/core/inference v0.3.0 h1:ANFnlVO1LEYDipeDeBgqmb8CHvOTUFhMPyfyHGqO0IY=
|
||||||
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
dappco.re/go/core/inference v0.3.0/go.mod h1:wbRY0v6iwOoJCpTvcBFarAM08bMgpPcrF6yv3vccYoA=
|
||||||
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
|
dappco.re/go/core/io v0.4.1 h1:15dm7ldhFIAuZOrBiQG6XVZDpSvCxtZsUXApwTAB3wQ=
|
||||||
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
dappco.re/go/core/io v0.4.1/go.mod h1:w71dukyunczLb8frT9JOd5B78PjwWQD3YAXiCt3AcPA=
|
||||||
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
|
dappco.re/go/core/log v0.1.2 h1:pQSZxKD8VycdvjNJmatXbPSq2OxcP2xHbF20zgFIiZI=
|
||||||
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
|
dappco.re/go/core/log v0.1.2/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
dappco.re/go/core/rag v0.1.13 h1:R2Q+Xw5YenT4uFemXLBu+xQYtyUIYGSmMln5/Z+nol4=
|
||||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
dappco.re/go/core/rag v0.1.13/go.mod h1:wthXtCqYEChjlGIHcJXetlgk49lPDmzG6jFWd1PEIZc=
|
||||||
forge.lthn.ai/core/go-process v0.2.9 h1:Wql+5TUF+lfU2oJ9I+S764MkTqJhBsuyMM0v1zsfZC4=
|
dappco.re/go/core/webview v0.2.1 h1:rdy2sV+MS6RZsav8BiARJxtWhfx7eOAJp3b1Ynp1sYs=
|
||||||
forge.lthn.ai/core/go-process v0.2.9/go.mod h1:NIzZOF5IVYYCjHkcNIGcg1mZH+bzGoie4SlZUDYOKIM=
|
dappco.re/go/core/webview v0.2.1/go.mod h1:Qdo1V/sJJwOnL0hYd3+vzVUJxWYC8eGyILZROya6KoM=
|
||||||
forge.lthn.ai/core/go-rag v0.1.11 h1:KXTOtnOdrx8YKmvnj0EOi2EI/+cKjE8w2PpJCQIrSd8=
|
dappco.re/go/core/ws v0.4.0 h1:yEDV9whXyo+GWzBSjuB3NiLiH2bmBPBWD6rydwHyBn8=
|
||||||
forge.lthn.ai/core/go-rag v0.1.11/go.mod h1:vIlOKVD1SdqqjkJ2XQyXPuKPtiajz/STPLCaDpqOzk8=
|
dappco.re/go/core/ws v0.4.0/go.mod h1:L1rrgW6zU+DztcVBJW2yO5Lm3rGXpyUMOA8OL9zsAok=
|
||||||
forge.lthn.ai/core/go-webview v0.1.6 h1:szXQxRJf2bOZJKh3v1P01B1Vf9mgXaBCXzh0EZu9aoc=
|
|
||||||
forge.lthn.ai/core/go-webview v0.1.6/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
|
|
||||||
forge.lthn.ai/core/go-ws v0.2.5 h1:ZIV7Yrv01R/xpJUogA5vrfP9yB9li1w7EV3eZFMt8h0=
|
|
||||||
forge.lthn.ai/core/go-ws v0.2.5/go.mod h1:C3riJyLLcV6QhLvYlq3P/XkGTsN598qQeGBoLdoHBU4=
|
|
||||||
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
||||||
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
|
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
|
|
@ -222,8 +218,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,15 @@ package agentic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -54,7 +52,7 @@ func (s *PrepSubsystem) registerDispatchTool(svc *coremcp.Service) {
|
||||||
// agentCommand returns the command and args for a given agent type.
|
// agentCommand returns the command and args for a given agent type.
|
||||||
// Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku".
|
// Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku".
|
||||||
func agentCommand(agent, prompt string) (string, []string, error) {
|
func agentCommand(agent, prompt string) (string, []string, error) {
|
||||||
parts := strings.SplitN(agent, ":", 2)
|
parts := core.SplitN(agent, ":", 2)
|
||||||
base := parts[0]
|
base := parts[0]
|
||||||
model := ""
|
model := ""
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
|
|
@ -78,7 +76,7 @@ func agentCommand(agent, prompt string) (string, []string, error) {
|
||||||
return "claude", args, nil
|
return "claude", args, nil
|
||||||
case "local":
|
case "local":
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh")
|
script := core.Path(home, "Code", "core", "agent", "scripts", "local-agent.sh")
|
||||||
return "bash", []string{script, prompt}, nil
|
return "bash", []string{script, prompt}, nil
|
||||||
default:
|
default:
|
||||||
return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil)
|
return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil)
|
||||||
|
|
@ -119,14 +117,14 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
||||||
}
|
}
|
||||||
|
|
||||||
wsDir := prepOut.WorkspaceDir
|
wsDir := prepOut.WorkspaceDir
|
||||||
srcDir := filepath.Join(wsDir, "src")
|
srcDir := core.Path(wsDir, "src")
|
||||||
|
|
||||||
// The prompt is just: read PROMPT.md and do the work
|
// The prompt is just: read PROMPT.md and do the work
|
||||||
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
|
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
|
||||||
|
|
||||||
if input.DryRun {
|
if input.DryRun {
|
||||||
// Read PROMPT.md for the dry run output
|
// Read PROMPT.md for the dry run output
|
||||||
promptRaw, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md"))
|
promptRaw, _ := coreio.Local.Read(core.Path(wsDir, "PROMPT.md"))
|
||||||
return nil, DispatchOutput{
|
return nil, DispatchOutput{
|
||||||
Success: true,
|
Success: true,
|
||||||
Agent: input.Agent,
|
Agent: input.Agent,
|
||||||
|
|
@ -181,7 +179,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
||||||
return nil, DispatchOutput{}, err
|
return nil, DispatchOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", input.Agent))
|
outputFile := core.Path(wsDir, core.Sprintf("agent-%s.log", input.Agent))
|
||||||
outFile, err := os.Create(outputFile)
|
outFile, err := os.Create(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err)
|
return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err)
|
||||||
|
|
@ -247,7 +245,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
||||||
status := "completed"
|
status := "completed"
|
||||||
channel := coremcp.ChannelAgentComplete
|
channel := coremcp.ChannelAgentComplete
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"workspace": filepath.Base(wsDir),
|
"workspace": core.PathBase(wsDir),
|
||||||
"repo": input.Repo,
|
"repo": input.Repo,
|
||||||
"org": input.Org,
|
"org": input.Org,
|
||||||
"agent": input.Agent,
|
"agent": input.Agent,
|
||||||
|
|
@ -257,11 +255,11 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
||||||
// Update status to completed or blocked.
|
// Update status to completed or blocked.
|
||||||
if st, err := readStatus(wsDir); err == nil {
|
if st, err := readStatus(wsDir); err == nil {
|
||||||
st.PID = 0
|
st.PID = 0
|
||||||
if data, err := coreio.Local.Read(filepath.Join(wsDir, "src", "BLOCKED.md")); err == nil {
|
if data, err := coreio.Local.Read(core.Path(wsDir, "src", "BLOCKED.md")); err == nil {
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
channel = coremcp.ChannelAgentBlocked
|
channel = coremcp.ChannelAgentBlocked
|
||||||
st.Status = status
|
st.Status = status
|
||||||
st.Question = strings.TrimSpace(data)
|
st.Question = core.Trim(data)
|
||||||
if st.Question != "" {
|
if st.Question != "" {
|
||||||
payload["question"] = st.Question
|
payload["question"] = st.Question
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -101,14 +100,14 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Build epic body with checklist
|
// Step 2: Build epic body with checklist
|
||||||
var body strings.Builder
|
body := core.NewBuilder()
|
||||||
if input.Body != "" {
|
if input.Body != "" {
|
||||||
body.WriteString(input.Body)
|
body.WriteString(input.Body)
|
||||||
body.WriteString("\n\n")
|
body.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
body.WriteString("## Tasks\n\n")
|
body.WriteString("## Tasks\n\n")
|
||||||
for _, child := range children {
|
for _, child := range children {
|
||||||
body.WriteString(fmt.Sprintf("- [ ] #%d %s\n", child.Number, child.Title))
|
body.WriteString(core.Sprintf("- [ ] #%d %s\n", child.Number, child.Title))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Create epic issue
|
// Step 3: Create epic issue
|
||||||
|
|
@ -157,8 +156,12 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
|
||||||
payload["labels"] = labelIDs
|
payload["labels"] = labelIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
data, _ := json.Marshal(payload)
|
r := core.JSONMarshal(payload)
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo)
|
if !r.OK {
|
||||||
|
return ChildRef{}, coreerr.E("createIssue", "failed to encode issue payload", nil)
|
||||||
|
}
|
||||||
|
data := r.Value.([]byte)
|
||||||
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo)
|
||||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
|
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||||
|
|
@ -170,7 +173,7 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != 201 {
|
if resp.StatusCode != 201 {
|
||||||
return ChildRef{}, coreerr.E("createIssue", fmt.Sprintf("returned %d", resp.StatusCode), nil)
|
return ChildRef{}, coreerr.E("createIssue", core.Sprintf("returned %d", resp.StatusCode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
|
|
@ -193,7 +196,7 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch existing labels
|
// Fetch existing labels
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo)
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo)
|
||||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||||
|
|
||||||
|
|
@ -246,12 +249,16 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string)
|
||||||
colour = "#6b7280"
|
colour = "#6b7280"
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, _ := json.Marshal(map[string]string{
|
r := core.JSONMarshal(map[string]string{
|
||||||
"name": name,
|
"name": name,
|
||||||
"color": colour,
|
"color": colour,
|
||||||
})
|
})
|
||||||
|
if !r.OK {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
payload := r.Value.([]byte)
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo)
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo)
|
||||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,12 @@
|
||||||
package agentic
|
package agentic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ingestFindings reads the agent output log and creates issues via the API
|
// ingestFindings reads the agent output log and creates issues via the API
|
||||||
|
|
@ -25,10 +20,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the log file
|
// Read the log file
|
||||||
logFiles, err := filepath.Glob(filepath.Join(wsDir, "agent-*.log"))
|
logFiles := core.PathGlob(core.Path(wsDir, "agent-*.log"))
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(logFiles) == 0 {
|
if len(logFiles) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -41,7 +33,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
|
||||||
body := contentStr
|
body := contentStr
|
||||||
|
|
||||||
// Skip quota errors
|
// Skip quota errors
|
||||||
if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") {
|
if core.Contains(body, "QUOTA_EXHAUSTED") || core.Contains(body, "QuotaError") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,13 +48,13 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
|
||||||
// Determine issue type from the template used
|
// Determine issue type from the template used
|
||||||
issueType := "task"
|
issueType := "task"
|
||||||
priority := "normal"
|
priority := "normal"
|
||||||
if strings.Contains(body, "security") || strings.Contains(body, "Security") {
|
if core.Contains(body, "security") || core.Contains(body, "Security") {
|
||||||
issueType = "bug"
|
issueType = "bug"
|
||||||
priority = "high"
|
priority = "high"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a single issue per repo with all findings in the body
|
// Create a single issue per repo with all findings in the body
|
||||||
title := fmt.Sprintf("Scan findings for %s (%d items)", st.Repo, findings)
|
title := core.Sprintf("Scan findings for %s (%d items)", st.Repo, findings)
|
||||||
|
|
||||||
// Truncate body to reasonable size for issue description
|
// Truncate body to reasonable size for issue description
|
||||||
description := body
|
description := body
|
||||||
|
|
@ -86,7 +78,7 @@ func countFileRefs(body string) int {
|
||||||
}
|
}
|
||||||
if j < len(body) && body[j] == '`' {
|
if j < len(body) && body[j] == '`' {
|
||||||
ref := body[i+1 : j]
|
ref := body[i+1 : j]
|
||||||
if strings.Contains(ref, ".go:") || strings.Contains(ref, ".php:") {
|
if core.Contains(ref, ".go:") || core.Contains(ref, ".php:") {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -102,25 +94,22 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read the agent API key from file
|
// Read the agent API key from file
|
||||||
home, _ := os.UserHomeDir()
|
home := core.Env("HOME")
|
||||||
apiKeyData, err := coreio.Local.Read(filepath.Join(home, ".claude", "agent-api.key"))
|
apiKeyData, err := coreio.Local.Read(core.Path(home, ".claude", "agent-api.key"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
apiKey := strings.TrimSpace(apiKeyData)
|
apiKey := core.Trim(apiKeyData)
|
||||||
|
|
||||||
payload, err := json.Marshal(map[string]string{
|
payloadStr := core.JSONMarshalString(map[string]string{
|
||||||
"title": title,
|
"title": title,
|
||||||
"description": description,
|
"description": description,
|
||||||
"type": issueType,
|
"type": issueType,
|
||||||
"priority": priority,
|
"priority": priority,
|
||||||
"reporter": "cladius",
|
"reporter": "cladius",
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload))
|
req, err := http.NewRequest("POST", s.brainURL+"/v1/issues", core.NewReader(payloadStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -77,10 +77,10 @@ func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequ
|
||||||
return nil, DispatchOutput{}, err
|
return nil, DispatchOutput{}, err
|
||||||
}
|
}
|
||||||
if issue.State != "open" {
|
if issue.State != "open" {
|
||||||
return nil, DispatchOutput{}, coreerr.E("dispatchIssue", fmt.Sprintf("issue %d is %s, not open", input.Issue, issue.State), nil)
|
return nil, DispatchOutput{}, coreerr.E("dispatchIssue", core.Sprintf("issue %d is %s, not open", input.Issue, issue.State), nil)
|
||||||
}
|
}
|
||||||
if issue.Assignee != nil && issue.Assignee.Login != "" {
|
if issue.Assignee != nil && issue.Assignee.Login != "" {
|
||||||
return nil, DispatchOutput{}, coreerr.E("dispatchIssue", fmt.Sprintf("issue %d is already assigned to %s", input.Issue, issue.Assignee.Login), nil)
|
return nil, DispatchOutput{}, coreerr.E("dispatchIssue", core.Sprintf("issue %d is already assigned to %s", input.Issue, issue.Assignee.Login), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !input.DryRun {
|
if !input.DryRun {
|
||||||
|
|
@ -124,7 +124,7 @@ func (s *PrepSubsystem) dispatchIssue(ctx context.Context, req *mcp.CallToolRequ
|
||||||
func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue int, labels []struct {
|
func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue int, labels []struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}) error {
|
}) error {
|
||||||
updateURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
updateURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
||||||
issueLabels := make([]string, 0, len(labels))
|
issueLabels := make([]string, 0, len(labels))
|
||||||
for _, label := range labels {
|
for _, label := range labels {
|
||||||
if label.Name == "in-progress" {
|
if label.Name == "in-progress" {
|
||||||
|
|
@ -135,13 +135,14 @@ func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue
|
||||||
if issueLabels == nil {
|
if issueLabels == nil {
|
||||||
issueLabels = []string{}
|
issueLabels = []string{}
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(map[string]any{
|
r := core.JSONMarshal(map[string]any{
|
||||||
"assignees": []string{},
|
"assignees": []string{},
|
||||||
"labels": issueLabels,
|
"labels": issueLabels,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return coreerr.E("unlockIssue", "failed to encode issue unlock", err)
|
return coreerr.E("unlockIssue", "failed to encode issue unlock", nil)
|
||||||
}
|
}
|
||||||
|
payload := r.Value.([]byte)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -156,14 +157,14 @@ func (s *PrepSubsystem) unlockIssue(ctx context.Context, org, repo string, issue
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= http.StatusBadRequest {
|
if resp.StatusCode >= http.StatusBadRequest {
|
||||||
return coreerr.E("unlockIssue", fmt.Sprintf("issue unlock returned %d", resp.StatusCode), nil)
|
return coreerr.E("unlockIssue", core.Sprintf("issue unlock returned %d", resp.StatusCode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue int) (*forgeIssue, error) {
|
func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue int) (*forgeIssue, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("fetchIssue", "failed to build request", err)
|
return nil, coreerr.E("fetchIssue", "failed to build request", err)
|
||||||
|
|
@ -176,7 +177,7 @@ func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, coreerr.E("fetchIssue", fmt.Sprintf("issue %d not found in %s/%s", issue, org, repo), nil)
|
return nil, coreerr.E("fetchIssue", core.Sprintf("issue %d not found in %s/%s", issue, org, repo), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out forgeIssue
|
var out forgeIssue
|
||||||
|
|
@ -187,14 +188,15 @@ func (s *PrepSubsystem) fetchIssue(ctx context.Context, org, repo string, issue
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) lockIssue(ctx context.Context, org, repo string, issue int, assignee string) error {
|
func (s *PrepSubsystem) lockIssue(ctx context.Context, org, repo string, issue int, assignee string) error {
|
||||||
updateURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
updateURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
||||||
payload, err := json.Marshal(map[string]any{
|
r := core.JSONMarshal(map[string]any{
|
||||||
"assignees": []string{assignee},
|
"assignees": []string{assignee},
|
||||||
"labels": []string{"in-progress"},
|
"labels": []string{"in-progress"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return coreerr.E("lockIssue", "failed to encode issue update", err)
|
return coreerr.E("lockIssue", "failed to encode issue update", nil)
|
||||||
}
|
}
|
||||||
|
payload := r.Value.([]byte)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, updateURL, bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -209,7 +211,7 @@ func (s *PrepSubsystem) lockIssue(ctx context.Context, org, repo string, issue i
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= http.StatusBadRequest {
|
if resp.StatusCode >= http.StatusBadRequest {
|
||||||
return coreerr.E("lockIssue", fmt.Sprintf("issue update returned %d", resp.StatusCode), nil)
|
return coreerr.E("lockIssue", core.Sprintf("issue update returned %d", resp.StatusCode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,11 @@ package agentic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -64,7 +63,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
skipped := make([]string, 0)
|
skipped := make([]string, 0)
|
||||||
|
|
||||||
for _, repo := range repos {
|
for _, repo := range repos {
|
||||||
repoDir := filepath.Join(basePath, repo)
|
repoDir := core.Path(basePath, repo)
|
||||||
if !hasRemote(repoDir, "github") {
|
if !hasRemote(repoDir, "github") {
|
||||||
skipped = append(skipped, repo+": no github remote")
|
skipped = append(skipped, repo+": no github remote")
|
||||||
continue
|
continue
|
||||||
|
|
@ -88,7 +87,7 @@ func (s *PrepSubsystem) mirror(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
}
|
}
|
||||||
|
|
||||||
if files > maxFiles {
|
if files > maxFiles {
|
||||||
sync.Skipped = fmt.Sprintf("%d files exceeds limit of %d", files, maxFiles)
|
sync.Skipped = core.Sprintf("%d files exceeds limit of %d", files, maxFiles)
|
||||||
synced = append(synced, sync)
|
synced = append(synced, sync)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,12 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -349,11 +348,11 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
|
|
||||||
var plans []Plan
|
var plans []Plan
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
id := strings.TrimSuffix(entry.Name(), ".json")
|
id := core.TrimSuffix(entry.Name(), ".json")
|
||||||
plan, err := readPlan(dir, id)
|
plan, err := readPlan(dir, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
@ -422,41 +421,41 @@ func (s *PrepSubsystem) planCheckpoint(_ context.Context, _ *mcp.CallToolRequest
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
func (s *PrepSubsystem) plansDir() string {
|
func (s *PrepSubsystem) plansDir() string {
|
||||||
return filepath.Join(s.codePath, ".core", "plans")
|
return core.Path(s.codePath, ".core", "plans")
|
||||||
}
|
}
|
||||||
|
|
||||||
func planPath(dir, id string) string {
|
func planPath(dir, id string) string {
|
||||||
return filepath.Join(dir, id+".json")
|
return core.Path(dir, id+".json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func generatePlanID(title string) string {
|
func generatePlanID(title string) string {
|
||||||
slug := strings.Map(func(r rune) rune {
|
b := core.NewBuilder()
|
||||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' {
|
b.Grow(len(title))
|
||||||
return r
|
for _, r := range title {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
b.WriteRune(r + 32)
|
||||||
|
case r == ' ':
|
||||||
|
b.WriteByte('-')
|
||||||
}
|
}
|
||||||
if r >= 'A' && r <= 'Z' {
|
|
||||||
return r + 32
|
|
||||||
}
|
}
|
||||||
if r == ' ' {
|
slug := b.String()
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}, title)
|
|
||||||
|
|
||||||
// Trim consecutive dashes and cap length
|
// Collapse consecutive dashes and cap length
|
||||||
for strings.Contains(slug, "--") {
|
for core.Contains(slug, "--") {
|
||||||
slug = strings.ReplaceAll(slug, "--", "-")
|
slug = core.Replace(slug, "--", "-")
|
||||||
}
|
}
|
||||||
slug = strings.Trim(slug, "-")
|
slug = trimDashes(slug)
|
||||||
if len(slug) > 30 {
|
if len(slug) > 30 {
|
||||||
slug = slug[:30]
|
slug = trimDashes(slug[:30])
|
||||||
}
|
}
|
||||||
slug = strings.TrimRight(slug, "-")
|
|
||||||
|
|
||||||
// Append short random suffix for uniqueness
|
// Append short random suffix for uniqueness
|
||||||
b := make([]byte, 3)
|
rnd := make([]byte, 3)
|
||||||
rand.Read(b)
|
rand.Read(rnd)
|
||||||
return slug + "-" + hex.EncodeToString(b)
|
return slug + "-" + hex.EncodeToString(rnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readPlan(dir, id string) (*Plan, error) {
|
func readPlan(dir, id string) (*Plan, error) {
|
||||||
|
|
@ -466,8 +465,8 @@ func readPlan(dir, id string) (*Plan, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var plan Plan
|
var plan Plan
|
||||||
if err := json.Unmarshal([]byte(data), &plan); err != nil {
|
if r := core.JSONUnmarshal([]byte(data), &plan); !r.OK {
|
||||||
return nil, coreerr.E("readPlan", "failed to parse plan "+id, err)
|
return nil, coreerr.E("readPlan", "failed to parse plan "+id, nil)
|
||||||
}
|
}
|
||||||
return &plan, nil
|
return &plan, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,13 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -66,8 +64,8 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
||||||
return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil)
|
return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
wsDir := filepath.Join(s.workspaceRoot(), input.Workspace)
|
wsDir := core.Path(s.workspaceRoot(), input.Workspace)
|
||||||
srcDir := filepath.Join(wsDir, "src")
|
srcDir := core.Path(wsDir, "src")
|
||||||
|
|
||||||
if _, err := coreio.Local.List(srcDir); err != nil {
|
if _, err := coreio.Local.List(srcDir); err != nil {
|
||||||
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil)
|
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil)
|
||||||
|
|
@ -87,7 +85,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, CreatePROutput{}, coreerr.E("createPR", "failed to detect branch", err)
|
return nil, CreatePROutput{}, coreerr.E("createPR", "failed to detect branch", err)
|
||||||
}
|
}
|
||||||
st.Branch = strings.TrimSpace(string(out))
|
st.Branch = core.Trim(string(out))
|
||||||
}
|
}
|
||||||
|
|
||||||
org := st.Org
|
org := st.Org
|
||||||
|
|
@ -105,7 +103,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
||||||
title = st.Task
|
title = st.Task
|
||||||
}
|
}
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = fmt.Sprintf("Agent work on %s", st.Branch)
|
title = core.Sprintf("Agent work on %s", st.Branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build PR body
|
// Build PR body
|
||||||
|
|
@ -143,7 +141,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
||||||
|
|
||||||
// Comment on issue if tracked
|
// Comment on issue if tracked
|
||||||
if st.Issue > 0 {
|
if st.Issue > 0 {
|
||||||
comment := fmt.Sprintf("Pull request created: %s", prURL)
|
comment := core.Sprintf("Pull request created: %s", prURL)
|
||||||
s.commentOnIssue(ctx, org, st.Repo, st.Issue, comment)
|
s.commentOnIssue(ctx, org, st.Repo, st.Issue, comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,17 +157,17 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string {
|
func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string {
|
||||||
var b strings.Builder
|
b := core.NewBuilder()
|
||||||
b.WriteString("## Summary\n\n")
|
b.WriteString("## Summary\n\n")
|
||||||
if st.Task != "" {
|
if st.Task != "" {
|
||||||
b.WriteString(st.Task)
|
b.WriteString(st.Task)
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
if st.Issue > 0 {
|
if st.Issue > 0 {
|
||||||
b.WriteString(fmt.Sprintf("Closes #%d\n\n", st.Issue))
|
b.WriteString(core.Sprintf("Closes #%d\n\n", st.Issue))
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("**Agent:** %s\n", st.Agent))
|
b.WriteString(core.Sprintf("**Agent:** %s\n", st.Agent))
|
||||||
b.WriteString(fmt.Sprintf("**Runs:** %d\n", st.Runs))
|
b.WriteString(core.Sprintf("**Runs:** %d\n", st.Runs))
|
||||||
b.WriteString("\n---\n*Created by agentic dispatch*\n")
|
b.WriteString("\n---\n*Created by agentic dispatch*\n")
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +183,7 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
|
||||||
return "", 0, coreerr.E("forgeCreatePR", "failed to marshal PR payload", err)
|
return "", 0, coreerr.E("forgeCreatePR", "failed to marshal PR payload", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo)
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo)
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, coreerr.E("forgeCreatePR", "failed to build PR request", err)
|
return "", 0, coreerr.E("forgeCreatePR", "failed to build PR request", err)
|
||||||
|
|
@ -202,10 +200,10 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
|
||||||
if resp.StatusCode != 201 {
|
if resp.StatusCode != 201 {
|
||||||
var errBody map[string]any
|
var errBody map[string]any
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&errBody); err != nil {
|
||||||
return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d with unreadable error body", resp.StatusCode), err)
|
return "", 0, coreerr.E("forgeCreatePR", core.Sprintf("HTTP %d with unreadable error body", resp.StatusCode), err)
|
||||||
}
|
}
|
||||||
msg, _ := errBody["message"].(string)
|
msg, _ := errBody["message"].(string)
|
||||||
return "", 0, coreerr.E("forgeCreatePR", fmt.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil)
|
return "", 0, coreerr.E("forgeCreatePR", core.Sprintf("HTTP %d: %s", resp.StatusCode, msg), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pr struct {
|
var pr struct {
|
||||||
|
|
@ -225,7 +223,7 @@ func (s *PrepSubsystem) commentOnIssue(ctx context.Context, org, repo string, is
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue)
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue)
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
@ -337,7 +335,7 @@ func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, inp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) {
|
func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string) ([]PRInfo, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10",
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10",
|
||||||
s.forgeURL, org, repo, state)
|
s.forgeURL, org, repo, state)
|
||||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||||
|
|
@ -348,7 +346,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, coreerr.E("listRepoPRs", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
|
return nil, coreerr.E("listRepoPRs", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var prs []struct {
|
var prs []struct {
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,14 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
goio "io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -46,17 +42,17 @@ var (
|
||||||
//
|
//
|
||||||
// prep := NewPrep()
|
// prep := NewPrep()
|
||||||
func NewPrep() *PrepSubsystem {
|
func NewPrep() *PrepSubsystem {
|
||||||
home, _ := os.UserHomeDir()
|
home := core.Env("HOME")
|
||||||
|
|
||||||
forgeToken := os.Getenv("FORGE_TOKEN")
|
forgeToken := core.Env("FORGE_TOKEN")
|
||||||
if forgeToken == "" {
|
if forgeToken == "" {
|
||||||
forgeToken = os.Getenv("GITEA_TOKEN")
|
forgeToken = core.Env("GITEA_TOKEN")
|
||||||
}
|
}
|
||||||
|
|
||||||
brainKey := os.Getenv("CORE_BRAIN_KEY")
|
brainKey := core.Env("CORE_BRAIN_KEY")
|
||||||
if brainKey == "" {
|
if brainKey == "" {
|
||||||
if data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil {
|
if data, err := coreio.Local.Read(core.Path(home, ".claude", "brain.key")); err == nil {
|
||||||
brainKey = strings.TrimSpace(data)
|
brainKey = core.Trim(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,8 +61,8 @@ func NewPrep() *PrepSubsystem {
|
||||||
forgeToken: forgeToken,
|
forgeToken: forgeToken,
|
||||||
brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"),
|
brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"),
|
||||||
brainKey: brainKey,
|
brainKey: brainKey,
|
||||||
specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")),
|
specsPath: envOr("SPECS_PATH", core.Path(home, "Code", "host-uk", "specs")),
|
||||||
codePath: envOr("CODE_PATH", filepath.Join(home, "Code")),
|
codePath: envOr("CODE_PATH", core.Path(home, "Code")),
|
||||||
client: &http.Client{Timeout: 30 * time.Second},
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,24 +80,24 @@ func (s *PrepSubsystem) emitChannel(ctx context.Context, channel string, data an
|
||||||
}
|
}
|
||||||
|
|
||||||
func envOr(key, fallback string) string {
|
func envOr(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := core.Env(key); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeRepoPathSegment(value, field string, allowSubdirs bool) (string, error) {
|
func sanitizeRepoPathSegment(value, field string, allowSubdirs bool) (string, error) {
|
||||||
if strings.TrimSpace(value) != value {
|
if core.Trim(value) != value {
|
||||||
return "", coreerr.E("prepWorkspace", field+" contains whitespace", nil)
|
return "", coreerr.E("prepWorkspace", field+" contains whitespace", nil)
|
||||||
}
|
}
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if strings.Contains(value, "\\") {
|
if core.Contains(value, "\\") {
|
||||||
return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil)
|
return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(value, "/")
|
parts := core.Split(value, "/")
|
||||||
if !allowSubdirs && len(parts) != 1 {
|
if !allowSubdirs && len(parts) != 1 {
|
||||||
return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil)
|
return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil)
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +157,7 @@ func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil }
|
||||||
|
|
||||||
// workspaceRoot returns the base directory for agent workspaces.
|
// workspaceRoot returns the base directory for agent workspaces.
|
||||||
func (s *PrepSubsystem) workspaceRoot() string {
|
func (s *PrepSubsystem) workspaceRoot() string {
|
||||||
return filepath.Join(s.codePath, ".core", "workspace")
|
return core.Path(s.codePath, ".core", "workspace")
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Input/Output types ---
|
// --- Input/Output types ---
|
||||||
|
|
@ -227,8 +223,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
||||||
// Workspace root: .core/workspace/{repo}-{timestamp}/
|
// Workspace root: .core/workspace/{repo}-{timestamp}/
|
||||||
wsRoot := s.workspaceRoot()
|
wsRoot := s.workspaceRoot()
|
||||||
coreio.Local.EnsureDir(wsRoot)
|
coreio.Local.EnsureDir(wsRoot)
|
||||||
wsName := fmt.Sprintf("%s-%d", input.Repo, time.Now().Unix())
|
wsName := core.Sprintf("%s-%d", input.Repo, time.Now().Unix())
|
||||||
wsDir := filepath.Join(wsRoot, wsName)
|
wsDir := core.Path(wsRoot, wsName)
|
||||||
|
|
||||||
// Create workspace structure
|
// Create workspace structure
|
||||||
// kb/ and specs/ will be created inside src/ after clone
|
// kb/ and specs/ will be created inside src/ after clone
|
||||||
|
|
@ -236,10 +232,10 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
||||||
out := PrepOutput{WorkspaceDir: wsDir}
|
out := PrepOutput{WorkspaceDir: wsDir}
|
||||||
|
|
||||||
// Source repo path
|
// Source repo path
|
||||||
repoPath := filepath.Join(s.codePath, "core", input.Repo)
|
repoPath := core.Path(s.codePath, "core", input.Repo)
|
||||||
|
|
||||||
// 1. Clone repo into src/ and create feature branch
|
// 1. Clone repo into src/ and create feature branch
|
||||||
srcDir := filepath.Join(wsDir, "src")
|
srcDir := core.Path(wsDir, "src")
|
||||||
cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir)
|
cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir)
|
||||||
if err := cloneCmd.Run(); err != nil {
|
if err := cloneCmd.Run(); err != nil {
|
||||||
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err)
|
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err)
|
||||||
|
|
@ -251,12 +247,12 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
||||||
taskSlug := branchSlug(input.Task)
|
taskSlug := branchSlug(input.Task)
|
||||||
if input.Issue > 0 {
|
if input.Issue > 0 {
|
||||||
issueSlug := branchSlug(input.Task)
|
issueSlug := branchSlug(input.Task)
|
||||||
branchName = fmt.Sprintf("agent/issue-%d", input.Issue)
|
branchName = core.Sprintf("agent/issue-%d", input.Issue)
|
||||||
if issueSlug != "" {
|
if issueSlug != "" {
|
||||||
branchName += "-" + issueSlug
|
branchName += "-" + issueSlug
|
||||||
}
|
}
|
||||||
} else if taskSlug != "" {
|
} else if taskSlug != "" {
|
||||||
branchName = fmt.Sprintf("agent/%s", taskSlug)
|
branchName = core.Sprintf("agent/%s", taskSlug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if branchName != "" {
|
if branchName != "" {
|
||||||
|
|
@ -269,29 +265,29 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create context dirs inside src/
|
// Create context dirs inside src/
|
||||||
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb"))
|
coreio.Local.EnsureDir(core.Path(srcDir, "kb"))
|
||||||
coreio.Local.EnsureDir(filepath.Join(srcDir, "specs"))
|
coreio.Local.EnsureDir(core.Path(srcDir, "specs"))
|
||||||
|
|
||||||
// Remote stays as local clone origin — agent cannot push to forge.
|
// Remote stays as local clone origin — agent cannot push to forge.
|
||||||
// Reviewer pulls changes from workspace and pushes after verification.
|
// Reviewer pulls changes from workspace and pushes after verification.
|
||||||
|
|
||||||
// 2. Copy CLAUDE.md and GEMINI.md to workspace
|
// 2. Copy CLAUDE.md and GEMINI.md to workspace
|
||||||
claudeMdPath := filepath.Join(repoPath, "CLAUDE.md")
|
claudeMdPath := core.Path(repoPath, "CLAUDE.md")
|
||||||
if data, err := coreio.Local.Read(claudeMdPath); err == nil {
|
if data, err := coreio.Local.Read(claudeMdPath); err == nil {
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "CLAUDE.md"), data)
|
_ = writeAtomic(core.Path(wsDir, "src", "CLAUDE.md"), data)
|
||||||
out.ClaudeMd = true
|
out.ClaudeMd = true
|
||||||
}
|
}
|
||||||
// Copy GEMINI.md from core/agent (ethics framework for all agents)
|
// Copy GEMINI.md from core/agent (ethics framework for all agents)
|
||||||
agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md")
|
agentGeminiMd := core.Path(s.codePath, "core", "agent", "GEMINI.md")
|
||||||
if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
|
if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "GEMINI.md"), data)
|
_ = writeAtomic(core.Path(wsDir, "src", "GEMINI.md"), data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy persona if specified
|
// Copy persona if specified
|
||||||
if persona != "" {
|
if persona != "" {
|
||||||
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", persona+".md")
|
personaPath := core.Path(s.codePath, "core", "agent", "prompts", "personas", persona+".md")
|
||||||
if data, err := coreio.Local.Read(personaPath); err == nil {
|
if data, err := coreio.Local.Read(personaPath); err == nil {
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "PERSONA.md"), data)
|
_ = writeAtomic(core.Path(wsDir, "src", "PERSONA.md"), data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,9 +295,9 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
||||||
if input.Issue > 0 {
|
if input.Issue > 0 {
|
||||||
s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir)
|
s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir)
|
||||||
} else if input.Task != "" {
|
} else if input.Task != "" {
|
||||||
todo := fmt.Sprintf("# TASK: %s\n\n**Repo:** %s/%s\n**Status:** ready\n\n## Objective\n\n%s\n",
|
todo := core.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)
|
input.Task, input.Org, input.Repo, input.Task)
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "TODO.md"), todo)
|
_ = writeAtomic(core.Path(wsDir, "src", "TODO.md"), todo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Generate CONTEXT.md from OpenBrain
|
// 4. Generate CONTEXT.md from OpenBrain
|
||||||
|
|
@ -333,12 +329,12 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
||||||
|
|
||||||
// branchSlug converts a free-form string into a git-friendly branch suffix.
|
// branchSlug converts a free-form string into a git-friendly branch suffix.
|
||||||
func branchSlug(value string) string {
|
func branchSlug(value string) string {
|
||||||
value = strings.ToLower(strings.TrimSpace(value))
|
value = core.Lower(core.Trim(value))
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
b := core.NewBuilder()
|
||||||
b.Grow(len(value))
|
b.Grow(len(value))
|
||||||
lastDash := false
|
lastDash := false
|
||||||
for _, r := range value {
|
for _, r := range value {
|
||||||
|
|
@ -359,14 +355,42 @@ func branchSlug(value string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slug := strings.Trim(b.String(), "-")
|
slug := trimDashes(b.String())
|
||||||
if len(slug) > 40 {
|
if len(slug) > 40 {
|
||||||
slug = slug[:40]
|
slug = trimDashes(slug[:40])
|
||||||
slug = strings.Trim(slug, "-")
|
|
||||||
}
|
}
|
||||||
return slug
|
return slug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeFilename replaces non-alphanumeric characters (except - _ .) with dashes.
|
||||||
|
func sanitizeFilename(title string) string {
|
||||||
|
b := core.NewBuilder()
|
||||||
|
b.Grow(len(title))
|
||||||
|
for _, r := range title {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
|
||||||
|
r == '-', r == '_', r == '.':
|
||||||
|
b.WriteRune(r)
|
||||||
|
default:
|
||||||
|
b.WriteByte('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimDashes strips leading and trailing dash characters from a string.
|
||||||
|
func trimDashes(s string) string {
|
||||||
|
start := 0
|
||||||
|
for start < len(s) && s[start] == '-' {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
end := len(s)
|
||||||
|
for end > start && s[end-1] == '-' {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return s[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
// --- Prompt templates ---
|
// --- Prompt templates ---
|
||||||
|
|
||||||
func (s *PrepSubsystem) writePromptTemplate(template, wsDir string) {
|
func (s *PrepSubsystem) writePromptTemplate(template, wsDir string) {
|
||||||
|
|
@ -434,7 +458,7 @@ Do NOT push. Commit only — a reviewer will verify and push.
|
||||||
prompt = "Read TODO.md and complete the task. Work in src/.\n"
|
prompt = "Read TODO.md and complete the task. Work in src/.\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "PROMPT.md"), prompt)
|
_ = writeAtomic(core.Path(wsDir, "src", "PROMPT.md"), prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Plan template rendering ---
|
// --- Plan template rendering ---
|
||||||
|
|
@ -443,11 +467,11 @@ Do NOT push. Commit only — a reviewer will verify and push.
|
||||||
// and writes PLAN.md into the workspace src/ directory.
|
// and writes PLAN.md into the workspace src/ directory.
|
||||||
func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) {
|
func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) {
|
||||||
// Look for template in core/agent/prompts/templates/
|
// Look for template in core/agent/prompts/templates/
|
||||||
templatePath := filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
|
templatePath := core.Path(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
|
||||||
content, err := coreio.Local.Read(templatePath)
|
content, err := coreio.Local.Read(templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try .yml extension
|
// Try .yml extension
|
||||||
templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
|
templatePath = core.Path(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
|
||||||
content, err = coreio.Local.Read(templatePath)
|
content, err = coreio.Local.Read(templatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return // Template not found, skip silently
|
return // Template not found, skip silently
|
||||||
|
|
@ -456,8 +480,8 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
|
||||||
|
|
||||||
// Substitute variables ({{variable_name}} → value)
|
// Substitute variables ({{variable_name}} → value)
|
||||||
for key, value := range variables {
|
for key, value := range variables {
|
||||||
content = strings.ReplaceAll(content, "{{"+key+"}}", value)
|
content = core.Replace(content, "{{"+key+"}}", value)
|
||||||
content = strings.ReplaceAll(content, "{{ "+key+" }}", value)
|
content = core.Replace(content, "{{ "+key+" }}", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the YAML to render as markdown
|
// Parse the YAML to render as markdown
|
||||||
|
|
@ -477,7 +501,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render as PLAN.md
|
// Render as PLAN.md
|
||||||
var plan strings.Builder
|
plan := core.NewBuilder()
|
||||||
plan.WriteString("# Plan: " + tmpl.Name + "\n\n")
|
plan.WriteString("# Plan: " + tmpl.Name + "\n\n")
|
||||||
if task != "" {
|
if task != "" {
|
||||||
plan.WriteString("**Task:** " + task + "\n\n")
|
plan.WriteString("**Task:** " + task + "\n\n")
|
||||||
|
|
@ -495,7 +519,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, phase := range tmpl.Phases {
|
for i, phase := range tmpl.Phases {
|
||||||
plan.WriteString(fmt.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name))
|
plan.WriteString(core.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name))
|
||||||
if phase.Description != "" {
|
if phase.Description != "" {
|
||||||
plan.WriteString(phase.Description + "\n\n")
|
plan.WriteString(phase.Description + "\n\n")
|
||||||
}
|
}
|
||||||
|
|
@ -512,7 +536,7 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
|
||||||
plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
|
plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "PLAN.md"), plan.String())
|
_ = writeAtomic(core.Path(wsDir, "src", "PLAN.md"), plan.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers (unchanged) ---
|
// --- Helpers (unchanged) ---
|
||||||
|
|
@ -522,7 +546,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo)
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -553,7 +577,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
||||||
subURL = page.Title
|
subURL = page.Title
|
||||||
}
|
}
|
||||||
|
|
||||||
pageURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL)
|
pageURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/page/%s", s.forgeURL, org, repo, subURL)
|
||||||
pageReq, err := http.NewRequestWithContext(ctx, "GET", pageURL, nil)
|
pageReq, err := http.NewRequestWithContext(ctx, "GET", pageURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
@ -585,14 +609,9 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
filename := strings.Map(func(r rune) rune {
|
filename := sanitizeFilename(page.Title) + ".md"
|
||||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
return '-'
|
|
||||||
}, page.Title) + ".md"
|
|
||||||
|
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "kb", filename), string(content))
|
_ = writeAtomic(core.Path(wsDir, "src", "kb", filename), string(content))
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -604,9 +623,9 @@ func (s *PrepSubsystem) copySpecs(wsDir string) int {
|
||||||
count := 0
|
count := 0
|
||||||
|
|
||||||
for _, file := range specFiles {
|
for _, file := range specFiles {
|
||||||
src := filepath.Join(s.specsPath, file)
|
src := core.Path(s.specsPath, file)
|
||||||
if data, err := coreio.Local.Read(src); err == nil {
|
if data, err := coreio.Local.Read(src); err == nil {
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "specs", file), data)
|
_ = writeAtomic(core.Path(wsDir, "src", "specs", file), data)
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -629,7 +648,7 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", strings.NewReader(string(body)))
|
req, err := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", core.NewReader(string(body)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
@ -646,18 +665,18 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
respData, err := goio.ReadAll(resp.Body)
|
readResult := core.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if !readResult.OK {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
var result struct {
|
var result struct {
|
||||||
Memories []map[string]any `json:"memories"`
|
Memories []map[string]any `json:"memories"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(respData, &result); err != nil {
|
if ur := core.JSONUnmarshal([]byte(readResult.Value.(string)), &result); !ur.OK {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var content strings.Builder
|
content := core.NewBuilder()
|
||||||
content.WriteString("# Context — " + repo + "\n\n")
|
content.WriteString("# Context — " + repo + "\n\n")
|
||||||
content.WriteString("> Relevant knowledge from OpenBrain.\n\n")
|
content.WriteString("> Relevant knowledge from OpenBrain.\n\n")
|
||||||
|
|
||||||
|
|
@ -666,15 +685,15 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
|
||||||
memContent, _ := mem["content"].(string)
|
memContent, _ := mem["content"].(string)
|
||||||
memProject, _ := mem["project"].(string)
|
memProject, _ := mem["project"].(string)
|
||||||
score, _ := mem["score"].(float64)
|
score, _ := mem["score"].(float64)
|
||||||
content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent))
|
content.WriteString(core.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent))
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String())
|
_ = writeAtomic(core.Path(wsDir, "src", "CONTEXT.md"), content.String())
|
||||||
return len(result.Memories)
|
return len(result.Memories)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
|
func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
|
||||||
goWorkPath := filepath.Join(s.codePath, "go.work")
|
goWorkPath := core.Path(s.codePath, "go.work")
|
||||||
modulePath := "forge.lthn.ai/core/" + repo
|
modulePath := "forge.lthn.ai/core/" + repo
|
||||||
|
|
||||||
workData, err := coreio.Local.Read(goWorkPath)
|
workData, err := coreio.Local.Read(goWorkPath)
|
||||||
|
|
@ -683,19 +702,19 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
|
||||||
}
|
}
|
||||||
|
|
||||||
var consumers []string
|
var consumers []string
|
||||||
for _, line := range strings.Split(workData, "\n") {
|
for _, line := range core.Split(workData, "\n") {
|
||||||
line = strings.TrimSpace(line)
|
line = core.Trim(line)
|
||||||
if !strings.HasPrefix(line, "./") {
|
if !core.HasPrefix(line, "./") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./"))
|
dir := core.Path(s.codePath, core.TrimPrefix(line, "./"))
|
||||||
goMod := filepath.Join(dir, "go.mod")
|
goMod := core.Path(dir, "go.mod")
|
||||||
modData, err := coreio.Local.Read(goMod)
|
modData, err := coreio.Local.Read(goMod)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) {
|
if core.Contains(modData, modulePath) && !core.HasPrefix(modData, "module "+modulePath) {
|
||||||
consumers = append(consumers, filepath.Base(dir))
|
consumers = append(consumers, core.PathBase(dir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -705,8 +724,8 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
|
||||||
for _, c := range consumers {
|
for _, c := range consumers {
|
||||||
content += "- " + c + "\n"
|
content += "- " + c + "\n"
|
||||||
}
|
}
|
||||||
content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
|
content += core.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "CONSUMERS.md"), content)
|
_ = writeAtomic(core.Path(wsDir, "src", "CONSUMERS.md"), content)
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(consumers)
|
return len(consumers)
|
||||||
|
|
@ -720,10 +739,10 @@ func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
lines := core.Split(core.Trim(string(output)), "\n")
|
||||||
if len(lines) > 0 && lines[0] != "" {
|
if len(lines) > 0 && lines[0] != "" {
|
||||||
content := "# Recent Changes\n\n```\n" + string(output) + "```\n"
|
content := "# Recent Changes\n\n```\n" + string(output) + "```\n"
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "RECENT.md"), content)
|
_ = writeAtomic(core.Path(wsDir, "src", "RECENT.md"), content)
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(lines)
|
return len(lines)
|
||||||
|
|
@ -734,7 +753,7 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
||||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||||
|
|
||||||
|
|
@ -753,11 +772,11 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
|
||||||
}
|
}
|
||||||
json.NewDecoder(resp.Body).Decode(&issueData)
|
json.NewDecoder(resp.Body).Decode(&issueData)
|
||||||
|
|
||||||
content := fmt.Sprintf("# TASK: %s\n\n", issueData.Title)
|
content := core.Sprintf("# TASK: %s\n\n", issueData.Title)
|
||||||
content += fmt.Sprintf("**Status:** ready\n")
|
content += core.Sprintf("**Status:** ready\n")
|
||||||
content += fmt.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue)
|
content += core.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue)
|
||||||
content += fmt.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo)
|
content += core.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo)
|
||||||
content += "## Objective\n\n" + issueData.Body + "\n"
|
content += "## Objective\n\n" + issueData.Body + "\n"
|
||||||
|
|
||||||
_ = writeAtomic(filepath.Join(wsDir, "src", "TODO.md"), content)
|
_ = writeAtomic(core.Path(wsDir, "src", "TODO.md"), content)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,19 @@
|
||||||
package agentic
|
package agentic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// os.Create, os.Open, os.DevNull, os.Environ, os.FindProcess are used for
|
||||||
|
// process spawning and management — no core equivalents for these OS primitives.
|
||||||
|
|
||||||
// DispatchConfig controls agent dispatch behaviour.
|
// DispatchConfig controls agent dispatch behaviour.
|
||||||
type DispatchConfig struct {
|
type DispatchConfig struct {
|
||||||
DefaultAgent string `yaml:"default_agent"`
|
DefaultAgent string `yaml:"default_agent"`
|
||||||
|
|
@ -43,7 +44,7 @@ type AgentsConfig struct {
|
||||||
// loadAgentsConfig reads config/agents.yaml from the code path.
|
// loadAgentsConfig reads config/agents.yaml from the code path.
|
||||||
func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
|
func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
|
||||||
paths := []string{
|
paths := []string{
|
||||||
filepath.Join(s.codePath, ".core", "agents.yaml"),
|
core.Path(s.codePath, ".core", "agents.yaml"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
|
|
@ -79,9 +80,16 @@ func (s *PrepSubsystem) delayForAgent(agent string) time.Duration {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse reset time
|
// Parse reset time (format: "HH:MM")
|
||||||
resetHour, resetMin := 6, 0
|
resetHour, resetMin := 6, 0
|
||||||
fmt.Sscanf(rate.ResetUTC, "%d:%d", &resetHour, &resetMin)
|
if parts := core.Split(rate.ResetUTC, ":"); len(parts) == 2 {
|
||||||
|
if h, ok := parseSimpleInt(parts[0]); ok {
|
||||||
|
resetHour = h
|
||||||
|
}
|
||||||
|
if m, ok := parseSimpleInt(parts[1]); ok {
|
||||||
|
resetMin = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC)
|
resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC)
|
||||||
|
|
@ -115,9 +123,9 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Join(wsRoot, entry.Name())
|
path := core.Path(wsRoot, entry.Name())
|
||||||
// Check if this dir has a status.json (it's a workspace)
|
// Check if this dir has a status.json (it's a workspace)
|
||||||
if coreio.Local.IsFile(filepath.Join(path, "status.json")) {
|
if coreio.Local.IsFile(core.Path(path, "status.json")) {
|
||||||
dirs = append(dirs, path)
|
dirs = append(dirs, path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -128,8 +136,8 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string {
|
||||||
}
|
}
|
||||||
for _, sub := range subEntries {
|
for _, sub := range subEntries {
|
||||||
if sub.IsDir() {
|
if sub.IsDir() {
|
||||||
subPath := filepath.Join(path, sub.Name())
|
subPath := core.Path(path, sub.Name())
|
||||||
if coreio.Local.IsFile(filepath.Join(subPath, "status.json")) {
|
if coreio.Local.IsFile(core.Path(subPath, "status.json")) {
|
||||||
dirs = append(dirs, subPath)
|
dirs = append(dirs, subPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -146,7 +154,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
|
||||||
if err != nil || st.Status != "running" {
|
if err != nil || st.Status != "running" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
stBase := strings.SplitN(st.Agent, ":", 2)[0]
|
stBase := core.SplitN(st.Agent, ":", 2)[0]
|
||||||
if stBase != agent {
|
if stBase != agent {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +170,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
|
||||||
|
|
||||||
// baseAgent strips the model variant (gemini:flash → gemini).
|
// baseAgent strips the model variant (gemini:flash → gemini).
|
||||||
func baseAgent(agent string) string {
|
func baseAgent(agent string) string {
|
||||||
return strings.SplitN(agent, ":", 2)[0]
|
return core.SplitN(agent, ":", 2)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// canDispatchAgent checks if we're under the concurrency limit for a specific agent type.
|
// canDispatchAgent checks if we're under the concurrency limit for a specific agent type.
|
||||||
|
|
@ -176,6 +184,23 @@ func (s *PrepSubsystem) canDispatchAgent(agent string) bool {
|
||||||
return s.countRunningByAgent(base) < limit
|
return s.countRunningByAgent(base) < limit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseSimpleInt parses a small non-negative integer from a string.
|
||||||
|
// Returns (value, true) on success, (0, false) on failure.
|
||||||
|
func parseSimpleInt(s string) (int, bool) {
|
||||||
|
s = core.Trim(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
n = n*10 + int(r-'0')
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
|
||||||
// canDispatch is kept for backwards compat.
|
// canDispatch is kept for backwards compat.
|
||||||
func (s *PrepSubsystem) canDispatch() bool {
|
func (s *PrepSubsystem) canDispatch() bool {
|
||||||
return true
|
return true
|
||||||
|
|
@ -205,7 +230,7 @@ func (s *PrepSubsystem) drainQueue() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
srcDir := filepath.Join(wsDir, "src")
|
srcDir := core.Path(wsDir, "src")
|
||||||
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
|
prompt := "Read PROMPT.md for instructions. All context files (CLAUDE.md, TODO.md, CONTEXT.md, CONSUMERS.md, RECENT.md) are in the parent directory. Work in this directory."
|
||||||
|
|
||||||
command, args, err := agentCommand(st.Agent, prompt)
|
command, args, err := agentCommand(st.Agent, prompt)
|
||||||
|
|
@ -213,7 +238,7 @@ func (s *PrepSubsystem) drainQueue() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", st.Agent))
|
outputFile := core.Path(wsDir, core.Sprintf("agent-%s.log", st.Agent))
|
||||||
outFile, err := os.Create(outputFile)
|
outFile, err := os.Create(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -5,19 +5,18 @@ package agentic
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func listLocalRepos(basePath string) []string {
|
func listLocalRepos(basePath string) []string {
|
||||||
entries, err := os.ReadDir(basePath)
|
entries, err := coreio.Local.List(basePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +34,7 @@ func hasRemote(repoDir, remote string) bool {
|
||||||
cmd := exec.Command("git", "remote", "get-url", remote)
|
cmd := exec.Command("git", "remote", "get-url", remote)
|
||||||
cmd.Dir = repoDir
|
cmd.Dir = repoDir
|
||||||
if out, err := cmd.Output(); err == nil {
|
if out, err := cmd.Output(); err == nil {
|
||||||
return strings.TrimSpace(string(out)) != ""
|
return core.Trim(string(out)) != ""
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +47,7 @@ func commitsAhead(repoDir, baseRef, headRef string) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := parsePositiveInt(strings.TrimSpace(string(out)))
|
count, err := parsePositiveInt(core.Trim(string(out)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
@ -64,8 +63,8 @@ func filesChanged(repoDir, baseRef, headRef string) int {
|
||||||
}
|
}
|
||||||
|
|
||||||
count := 0
|
count := 0
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range core.Split(core.Trim(string(out)), "\n") {
|
||||||
if strings.TrimSpace(line) != "" {
|
if core.Trim(line) != "" {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -79,11 +78,11 @@ func gitOutput(repoDir string, args ...string) (string, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", coreerr.E("gitOutput", string(out), err)
|
return "", coreerr.E("gitOutput", string(out), err)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(string(out)), nil
|
return core.Trim(string(out)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func parsePositiveInt(value string) (int, error) {
|
func parsePositiveInt(value string) (int, error) {
|
||||||
value = strings.TrimSpace(value)
|
value = core.Trim(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return 0, coreerr.E("parsePositiveInt", "empty value", nil)
|
return 0, coreerr.E("parsePositiveInt", "empty value", nil)
|
||||||
}
|
}
|
||||||
|
|
@ -148,11 +147,11 @@ func createGitHubPR(ctx context.Context, repoDir, repo string, commits, files in
|
||||||
return "", coreerr.E("createGitHubPR", string(out), err)
|
return "", coreerr.E("createGitHubPR", string(out), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
lines := core.Split(core.Trim(string(out)), "\n")
|
||||||
if len(lines) == 0 {
|
if len(lines) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(lines[len(lines)-1]), nil
|
return core.Trim(lines[len(lines)-1]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureDevBranch(repoDir string) error {
|
func ensureDevBranch(repoDir string) error {
|
||||||
|
|
@ -194,7 +193,7 @@ func parseRetryAfter(detail string) time.Duration {
|
||||||
return 5 * time.Minute
|
return 5 * time.Minute
|
||||||
}
|
}
|
||||||
|
|
||||||
switch strings.ToLower(match[2]) {
|
switch core.Lower(match[2]) {
|
||||||
case "hour", "hours":
|
case "hour", "hours":
|
||||||
return time.Duration(n) * time.Hour
|
return time.Duration(n) * time.Hour
|
||||||
case "second", "seconds":
|
case "second", "seconds":
|
||||||
|
|
@ -205,5 +204,5 @@ func parseRetryAfter(detail string) time.Duration {
|
||||||
}
|
}
|
||||||
|
|
||||||
func repoRootFromCodePath(codePath string) string {
|
func repoRootFromCodePath(codePath string) string {
|
||||||
return filepath.Join(codePath, "core")
|
return core.Path(codePath, "core")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,14 @@ package agentic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -52,8 +50,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil)
|
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
wsDir := filepath.Join(s.workspaceRoot(), input.Workspace)
|
wsDir := core.Path(s.workspaceRoot(), input.Workspace)
|
||||||
srcDir := filepath.Join(wsDir, "src")
|
srcDir := core.Path(wsDir, "src")
|
||||||
|
|
||||||
// Verify workspace exists
|
// Verify workspace exists
|
||||||
if _, err := coreio.Local.List(srcDir); err != nil {
|
if _, err := coreio.Local.List(srcDir); err != nil {
|
||||||
|
|
@ -78,8 +76,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
|
|
||||||
// Write ANSWER.md if answer provided
|
// Write ANSWER.md if answer provided
|
||||||
if input.Answer != "" {
|
if input.Answer != "" {
|
||||||
answerPath := filepath.Join(srcDir, "ANSWER.md")
|
answerPath := core.Path(srcDir, "ANSWER.md")
|
||||||
content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer)
|
content := core.Sprintf("# Answer\n\n%s\n", input.Answer)
|
||||||
if err := writeAtomic(answerPath, content); err != nil {
|
if err := writeAtomic(answerPath, content); err != nil {
|
||||||
return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err)
|
return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err)
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +100,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn agent as detached process (survives parent death)
|
// Spawn agent as detached process (survives parent death)
|
||||||
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s-run%d.log", agent, st.Runs+1))
|
outputFile := core.Path(wsDir, core.Sprintf("agent-%s-run%d.log", agent, st.Runs+1))
|
||||||
|
|
||||||
command, args, err := agentCommand(agent, prompt)
|
command, args, err := agentCommand(agent, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -154,10 +152,10 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
"branch": st.Branch,
|
"branch": st.Branch,
|
||||||
}
|
}
|
||||||
|
|
||||||
if data, err := coreio.Local.Read(filepath.Join(srcDir, "BLOCKED.md")); err == nil {
|
if data, err := coreio.Local.Read(core.Path(srcDir, "BLOCKED.md")); err == nil {
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
channel = coremcp.ChannelAgentBlocked
|
channel = coremcp.ChannelAgentBlocked
|
||||||
st.Question = strings.TrimSpace(data)
|
st.Question = core.Trim(data)
|
||||||
if st.Question != "" {
|
if st.Question != "" {
|
||||||
payload["question"] = st.Question
|
payload["question"] = st.Question
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,14 @@ package agentic
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -93,7 +91,7 @@ func (s *PrepSubsystem) reviewQueue(ctx context.Context, _ *mcp.CallToolRequest,
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
repoDir := filepath.Join(basePath, repo)
|
repoDir := core.Path(basePath, repo)
|
||||||
reviewer := input.Reviewer
|
reviewer := input.Reviewer
|
||||||
if reviewer == "" {
|
if reviewer == "" {
|
||||||
reviewer = "coderabbit"
|
reviewer = "coderabbit"
|
||||||
|
|
@ -137,7 +135,7 @@ func (s *PrepSubsystem) findReviewCandidates(basePath string) []string {
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
repoDir := filepath.Join(basePath, entry.Name())
|
repoDir := core.Path(basePath, entry.Name())
|
||||||
if !hasRemote(repoDir, "github") {
|
if !hasRemote(repoDir, "github") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -154,22 +152,22 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
|
||||||
|
|
||||||
if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) {
|
if rl := s.loadRateLimitState(); rl != nil && rl.Limited && time.Now().Before(rl.RetryAt) {
|
||||||
result.Verdict = "rate_limited"
|
result.Verdict = "rate_limited"
|
||||||
result.Detail = fmt.Sprintf("retry after %s", rl.RetryAt.Format(time.RFC3339))
|
result.Detail = core.Sprintf("retry after %s", rl.RetryAt.Format(time.RFC3339))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := reviewerCommand(ctx, repoDir, reviewer)
|
cmd := reviewerCommand(ctx, repoDir, reviewer)
|
||||||
cmd.Dir = repoDir
|
cmd.Dir = repoDir
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
output := strings.TrimSpace(string(out))
|
output := core.Trim(string(out))
|
||||||
|
|
||||||
if strings.Contains(strings.ToLower(output), "rate limit") {
|
if core.Contains(core.Lower(output), "rate limit") {
|
||||||
result.Verdict = "rate_limited"
|
result.Verdict = "rate_limited"
|
||||||
result.Detail = output
|
result.Detail = output
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil && !strings.Contains(output, "No findings") && !strings.Contains(output, "no issues") {
|
if err != nil && !core.Contains(output, "No findings") && !core.Contains(output, "no issues") {
|
||||||
result.Verdict = "error"
|
result.Verdict = "error"
|
||||||
if output != "" {
|
if output != "" {
|
||||||
result.Detail = output
|
result.Detail = output
|
||||||
|
|
@ -182,7 +180,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
|
||||||
s.storeReviewOutput(repoDir, repo, reviewer, output)
|
s.storeReviewOutput(repoDir, repo, reviewer, output)
|
||||||
result.Findings = countFindingHints(output)
|
result.Findings = countFindingHints(output)
|
||||||
|
|
||||||
if strings.Contains(output, "No findings") || strings.Contains(output, "no issues") || strings.Contains(output, "LGTM") {
|
if core.Contains(output, "No findings") || core.Contains(output, "no issues") || core.Contains(output, "LGTM") {
|
||||||
result.Verdict = "clean"
|
result.Verdict = "clean"
|
||||||
if dryRun {
|
if dryRun {
|
||||||
result.Action = "skipped (dry run)"
|
result.Action = "skipped (dry run)"
|
||||||
|
|
@ -198,7 +196,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
|
||||||
mergeCmd.Dir = repoDir
|
mergeCmd.Dir = repoDir
|
||||||
if mergeOut, err := mergeCmd.CombinedOutput(); err == nil {
|
if mergeOut, err := mergeCmd.CombinedOutput(); err == nil {
|
||||||
result.Action = "merged"
|
result.Action = "merged"
|
||||||
result.Detail = strings.TrimSpace(string(mergeOut))
|
result.Detail = core.Trim(string(mergeOut))
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -219,7 +217,7 @@ func (s *PrepSubsystem) reviewRepo(ctx context.Context, repoDir, repo, reviewer
|
||||||
|
|
||||||
func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) {
|
func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string) {
|
||||||
home := reviewQueueHomeDir()
|
home := reviewQueueHomeDir()
|
||||||
dataDir := filepath.Join(home, ".core", "training", "reviews")
|
dataDir := core.Path(home, ".core", "training", "reviews")
|
||||||
if err := coreio.Local.EnsureDir(dataDir); err != nil {
|
if err := coreio.Local.EnsureDir(dataDir); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -235,13 +233,13 @@ func (s *PrepSubsystem) storeReviewOutput(repoDir, repo, reviewer, output string
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
name := fmt.Sprintf("%s-%s-%d.json", repo, reviewer, time.Now().Unix())
|
name := core.Sprintf("%s-%s-%d.json", repo, reviewer, time.Now().Unix())
|
||||||
_ = writeAtomic(filepath.Join(dataDir, name), string(data))
|
_ = writeAtomic(core.Path(dataDir, name), string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) {
|
func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) {
|
||||||
home := reviewQueueHomeDir()
|
home := reviewQueueHomeDir()
|
||||||
path := filepath.Join(home, ".core", "coderabbit-ratelimit.json")
|
path := core.Path(home, ".core", "coderabbit-ratelimit.json")
|
||||||
data, err := json.Marshal(info)
|
data, err := json.Marshal(info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
@ -251,7 +249,7 @@ func (s *PrepSubsystem) saveRateLimitState(info *RateLimitInfo) {
|
||||||
|
|
||||||
func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo {
|
func (s *PrepSubsystem) loadRateLimitState() *RateLimitInfo {
|
||||||
home := reviewQueueHomeDir()
|
home := reviewQueueHomeDir()
|
||||||
path := filepath.Join(home, ".core", "coderabbit-ratelimit.json")
|
path := core.Path(home, ".core", "coderabbit-ratelimit.json")
|
||||||
data, err := coreio.Local.Read(path)
|
data, err := coreio.Local.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,10 @@ package agentic
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -81,7 +80,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var unique []ScanIssue
|
var unique []ScanIssue
|
||||||
for _, issue := range allIssues {
|
for _, issue := range allIssues {
|
||||||
key := fmt.Sprintf("%s#%d", issue.Repo, issue.Number)
|
key := core.Sprintf("%s#%d", issue.Repo, issue.Number)
|
||||||
if !seen[key] {
|
if !seen[key] {
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
unique = append(unique, issue)
|
unique = append(unique, issue)
|
||||||
|
|
@ -100,7 +99,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, error) {
|
func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org)
|
url := core.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org)
|
||||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||||
|
|
||||||
|
|
@ -110,7 +109,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, coreerr.E("listOrgRepos", fmt.Sprintf("HTTP %d listing repos", resp.StatusCode), nil)
|
return nil, coreerr.E("listOrgRepos", core.Sprintf("HTTP %d listing repos", resp.StatusCode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var repos []struct {
|
var repos []struct {
|
||||||
|
|
@ -126,7 +125,7 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) {
|
func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label string) ([]ScanIssue, error) {
|
||||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues",
|
url := core.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues",
|
||||||
s.forgeURL, org, repo, label)
|
s.forgeURL, org, repo, label)
|
||||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||||
|
|
@ -137,7 +136,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
return nil, coreerr.E("listRepoIssues", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
|
return nil, coreerr.E("listRepoIssues", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var issues []struct {
|
var issues []struct {
|
||||||
|
|
@ -170,7 +169,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
Labels: labels,
|
Labels: labels,
|
||||||
Assignee: assignee,
|
Assignee: assignee,
|
||||||
URL: strings.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL, 1),
|
URL: core.Replace(issue.HTMLURL, "https://forge.lthn.ai", s.forgeURL),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,18 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// os.Stat and os.FindProcess are used for workspace age detection and PID
|
||||||
|
// liveness checks — these are OS-level queries with no core equivalent.
|
||||||
|
|
||||||
// Workspace status file convention:
|
// Workspace status file convention:
|
||||||
//
|
//
|
||||||
// {workspace}/status.json — current state of the workspace
|
// {workspace}/status.json — current state of the workspace
|
||||||
|
|
@ -57,23 +59,23 @@ func writeStatus(wsDir string, status *WorkspaceStatus) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeAtomic(filepath.Join(wsDir, "status.json"), string(data))
|
return writeAtomic(core.JoinPath(wsDir, "status.json"), string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) saveStatus(wsDir string, status *WorkspaceStatus) {
|
func (s *PrepSubsystem) saveStatus(wsDir string, status *WorkspaceStatus) {
|
||||||
if err := writeStatus(wsDir, status); err != nil {
|
if err := writeStatus(wsDir, status); err != nil {
|
||||||
coreerr.Warn("failed to write workspace status", "workspace", filepath.Base(wsDir), "err", err)
|
coreerr.Warn("failed to write workspace status", "workspace", core.PathBase(wsDir), "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readStatus(wsDir string) (*WorkspaceStatus, error) {
|
func readStatus(wsDir string) (*WorkspaceStatus, error) {
|
||||||
data, err := coreio.Local.Read(filepath.Join(wsDir, "status.json"))
|
data, err := coreio.Local.Read(core.JoinPath(wsDir, "status.json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var s WorkspaceStatus
|
var s WorkspaceStatus
|
||||||
if err := json.Unmarshal([]byte(data), &s); err != nil {
|
if r := core.JSONUnmarshal([]byte(data), &s); !r.OK {
|
||||||
return nil, err
|
return nil, coreerr.E("readStatus", "failed to parse status.json", nil)
|
||||||
}
|
}
|
||||||
return &s, nil
|
return &s, nil
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +128,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
var workspaces []WorkspaceInfo
|
var workspaces []WorkspaceInfo
|
||||||
|
|
||||||
for _, wsDir := range wsDirs {
|
for _, wsDir := range wsDirs {
|
||||||
name := filepath.Base(wsDir)
|
name := core.PathBase(wsDir)
|
||||||
|
|
||||||
// Filter by specific workspace if requested
|
// Filter by specific workspace if requested
|
||||||
if input.Workspace != "" && name != input.Workspace {
|
if input.Workspace != "" && name != input.Workspace {
|
||||||
|
|
@ -139,7 +141,7 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
st, err := readStatus(wsDir)
|
st, err := readStatus(wsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Legacy workspace (no status.json) — check for log file
|
// Legacy workspace (no status.json) — check for log file
|
||||||
logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log"))
|
logFiles := core.PathGlob(core.Path(wsDir, "agent-*.log"))
|
||||||
if len(logFiles) > 0 {
|
if len(logFiles) > 0 {
|
||||||
info.Status = "completed"
|
info.Status = "completed"
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -177,10 +179,10 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process died — check for BLOCKED.md
|
// Process died — check for BLOCKED.md
|
||||||
blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md")
|
blockedPath := core.Path(wsDir, "src", "BLOCKED.md")
|
||||||
if data, err := coreio.Local.Read(blockedPath); err == nil {
|
if data, err := coreio.Local.Read(blockedPath); err == nil {
|
||||||
info.Status = "blocked"
|
info.Status = "blocked"
|
||||||
info.Question = strings.TrimSpace(data)
|
info.Question = core.Trim(data)
|
||||||
st.Status = "blocked"
|
st.Status = "blocked"
|
||||||
st.Question = info.Question
|
st.Question = info.Question
|
||||||
status = "blocked"
|
status = "blocked"
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ package agentic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -153,15 +153,15 @@ func (s *PrepSubsystem) findActiveWorkspaces() []string {
|
||||||
}
|
}
|
||||||
switch st.Status {
|
switch st.Status {
|
||||||
case "running", "queued":
|
case "running", "queued":
|
||||||
active = append(active, filepath.Base(wsDir))
|
active = append(active, core.PathBase(wsDir))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return active
|
return active
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *PrepSubsystem) resolveWorkspaceDir(name string) string {
|
func (s *PrepSubsystem) resolveWorkspaceDir(name string) string {
|
||||||
if filepath.IsAbs(name) {
|
if core.PathIsAbs(name) {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
return filepath.Join(s.workspaceRoot(), name)
|
return core.JoinPath(s.workspaceRoot(), name)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,26 @@ package agentic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// os.CreateTemp, os.Remove, os.Rename are framework-boundary calls for
|
||||||
|
// atomic file writes — no core equivalent exists for temp file creation.
|
||||||
|
|
||||||
// writeAtomic writes content to path by staging it in a temporary file and
|
// writeAtomic writes content to path by staging it in a temporary file and
|
||||||
// renaming it into place.
|
// renaming it into place.
|
||||||
//
|
//
|
||||||
// This avoids exposing partially written workspace files to agents that may
|
// This avoids exposing partially written workspace files to agents that may
|
||||||
// read status, prompt, or plan documents while they are being updated.
|
// read status, prompt, or plan documents while they are being updated.
|
||||||
func writeAtomic(path, content string) error {
|
func writeAtomic(path, content string) error {
|
||||||
dir := filepath.Dir(path)
|
dir := core.PathDir(path)
|
||||||
if err := coreio.Local.EnsureDir(dir); err != nil {
|
if err := coreio.Local.EnsureDir(dir); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
tmp, err := os.CreateTemp(dir, "."+core.PathBase(path)+".*.tmp")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
|
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
"dappco.re/go/mcp/pkg/mcp/ide"
|
"dappco.re/go/mcp/pkg/mcp/ide"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
|
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,15 @@
|
||||||
package brain
|
package brain
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
goio "io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -58,15 +53,16 @@ func (s *DirectSubsystem) OnChannel(fn func(ctx context.Context, channel string,
|
||||||
// Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back
|
// Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back
|
||||||
// to ~/.claude/brain.key for the API key.
|
// to ~/.claude/brain.key for the API key.
|
||||||
func NewDirect() *DirectSubsystem {
|
func NewDirect() *DirectSubsystem {
|
||||||
apiURL := os.Getenv("CORE_BRAIN_URL")
|
apiURL := core.Env("CORE_BRAIN_URL")
|
||||||
if apiURL == "" {
|
if apiURL == "" {
|
||||||
apiURL = "https://api.lthn.sh"
|
apiURL = "https://api.lthn.sh"
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey := os.Getenv("CORE_BRAIN_KEY")
|
apiKey := core.Env("CORE_BRAIN_KEY")
|
||||||
if apiKey == "" {
|
if apiKey == "" {
|
||||||
if data, err := coreio.Local.Read(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil {
|
home := core.Env("HOME")
|
||||||
apiKey = strings.TrimSpace(data)
|
if data, err := coreio.Local.Read(core.Path(home, ".claude", "brain.key")); err == nil {
|
||||||
|
apiKey = core.Trim(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,16 +108,12 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
|
||||||
return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil)
|
return nil, coreerr.E("brain.apiCall", "no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var reqBody goio.Reader
|
var bodyStr string
|
||||||
if body != nil {
|
if body != nil {
|
||||||
data, err := json.Marshal(body)
|
bodyStr = core.JSONMarshalString(body)
|
||||||
if err != nil {
|
|
||||||
return nil, coreerr.E("brain.apiCall", "marshal request", err)
|
|
||||||
}
|
|
||||||
reqBody = bytes.NewReader(data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody)
|
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, core.NewReader(bodyStr))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, coreerr.E("brain.apiCall", "create request", err)
|
return nil, coreerr.E("brain.apiCall", "create request", err)
|
||||||
}
|
}
|
||||||
|
|
@ -135,18 +127,22 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
respData, err := goio.ReadAll(resp.Body)
|
r := core.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if !r.OK {
|
||||||
return nil, coreerr.E("brain.apiCall", "read response", err)
|
if readErr, ok := r.Value.(error); ok {
|
||||||
|
return nil, coreerr.E("brain.apiCall", "read response", readErr)
|
||||||
}
|
}
|
||||||
|
return nil, coreerr.E("brain.apiCall", "read response failed", nil)
|
||||||
|
}
|
||||||
|
respData := r.Value.(string)
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
return nil, coreerr.E("brain.apiCall", "API returned "+string(respData), nil)
|
return nil, coreerr.E("brain.apiCall", "API returned "+respData, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]any
|
var result map[string]any
|
||||||
if err := json.Unmarshal(respData, &result); err != nil {
|
if ur := core.JSONUnmarshal([]byte(respData), &result); !ur.OK {
|
||||||
return nil, coreerr.E("brain.apiCall", "parse response", err)
|
return nil, coreerr.E("brain.apiCall", "parse response", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
@ -200,30 +196,7 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in
|
||||||
return nil, RecallOutput{}, err
|
return nil, RecallOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var memories []Memory
|
memories := memoriesFromResult(result)
|
||||||
if mems, ok := result["memories"].([]any); ok {
|
|
||||||
for _, m := range mems {
|
|
||||||
if mm, ok := m.(map[string]any); ok {
|
|
||||||
mem := Memory{
|
|
||||||
Content: fmt.Sprintf("%v", mm["content"]),
|
|
||||||
Type: fmt.Sprintf("%v", mm["type"]),
|
|
||||||
Project: fmt.Sprintf("%v", mm["project"]),
|
|
||||||
AgentID: fmt.Sprintf("%v", mm["agent_id"]),
|
|
||||||
CreatedAt: fmt.Sprintf("%v", mm["created_at"]),
|
|
||||||
}
|
|
||||||
if id, ok := mm["id"].(string); ok {
|
|
||||||
mem.ID = id
|
|
||||||
}
|
|
||||||
if score, ok := mm["score"].(float64); ok {
|
|
||||||
mem.Confidence = score
|
|
||||||
}
|
|
||||||
if source, ok := mm["source"].(string); ok {
|
|
||||||
mem.Tags = append(mem.Tags, "source:"+source)
|
|
||||||
}
|
|
||||||
memories = append(memories, mem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.onChannel != nil {
|
if s.onChannel != nil {
|
||||||
s.onChannel(ctx, coremcp.ChannelBrainRecallDone, map[string]any{
|
s.onChannel(ctx, coremcp.ChannelBrainRecallDone, map[string]any{
|
||||||
|
|
@ -274,37 +247,14 @@ func (s *DirectSubsystem) list(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
if input.AgentID != "" {
|
if input.AgentID != "" {
|
||||||
values.Set("agent_id", input.AgentID)
|
values.Set("agent_id", input.AgentID)
|
||||||
}
|
}
|
||||||
values.Set("limit", fmt.Sprintf("%d", limit))
|
values.Set("limit", core.Sprintf("%d", limit))
|
||||||
|
|
||||||
result, err := s.apiCall(ctx, http.MethodGet, "/v1/brain/list?"+values.Encode(), nil)
|
result, err := s.apiCall(ctx, http.MethodGet, "/v1/brain/list?"+values.Encode(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ListOutput{}, err
|
return nil, ListOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var memories []Memory
|
memories := memoriesFromResult(result)
|
||||||
if mems, ok := result["memories"].([]any); ok {
|
|
||||||
for _, m := range mems {
|
|
||||||
if mm, ok := m.(map[string]any); ok {
|
|
||||||
mem := Memory{
|
|
||||||
Content: fmt.Sprintf("%v", mm["content"]),
|
|
||||||
Type: fmt.Sprintf("%v", mm["type"]),
|
|
||||||
Project: fmt.Sprintf("%v", mm["project"]),
|
|
||||||
AgentID: fmt.Sprintf("%v", mm["agent_id"]),
|
|
||||||
CreatedAt: fmt.Sprintf("%v", mm["created_at"]),
|
|
||||||
}
|
|
||||||
if id, ok := mm["id"].(string); ok {
|
|
||||||
mem.ID = id
|
|
||||||
}
|
|
||||||
if score, ok := mm["score"].(float64); ok {
|
|
||||||
mem.Confidence = score
|
|
||||||
}
|
|
||||||
if source, ok := mm["source"].(string); ok {
|
|
||||||
mem.Tags = append(mem.Tags, "source:"+source)
|
|
||||||
}
|
|
||||||
memories = append(memories, mem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.onChannel != nil {
|
if s.onChannel != nil {
|
||||||
s.onChannel(ctx, coremcp.ChannelBrainListDone, map[string]any{
|
s.onChannel(ctx, coremcp.ChannelBrainListDone, map[string]any{
|
||||||
|
|
@ -321,3 +271,49 @@ func (s *DirectSubsystem) list(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
Memories: memories,
|
Memories: memories,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memoriesFromResult extracts Memory entries from an API response map.
|
||||||
|
func memoriesFromResult(result map[string]any) []Memory {
|
||||||
|
var memories []Memory
|
||||||
|
mems, ok := result["memories"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return memories
|
||||||
|
}
|
||||||
|
for _, m := range mems {
|
||||||
|
mm, ok := m.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mem := Memory{
|
||||||
|
Content: stringFromMap(mm, "content"),
|
||||||
|
Type: stringFromMap(mm, "type"),
|
||||||
|
Project: stringFromMap(mm, "project"),
|
||||||
|
AgentID: stringFromMap(mm, "agent_id"),
|
||||||
|
CreatedAt: stringFromMap(mm, "created_at"),
|
||||||
|
}
|
||||||
|
if id, ok := mm["id"].(string); ok {
|
||||||
|
mem.ID = id
|
||||||
|
}
|
||||||
|
if score, ok := mm["score"].(float64); ok {
|
||||||
|
mem.Confidence = score
|
||||||
|
}
|
||||||
|
if source, ok := mm["source"].(string); ok {
|
||||||
|
mem.Tags = append(mem.Tags, "source:"+source)
|
||||||
|
}
|
||||||
|
memories = append(memories, mem)
|
||||||
|
}
|
||||||
|
return memories
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringFromMap extracts a string value from a map, returning "" if missing or wrong type.
|
||||||
|
func stringFromMap(m map[string]any, key string) string {
|
||||||
|
v, ok := m[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return core.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import (
|
||||||
|
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
"dappco.re/go/mcp/pkg/mcp/ide"
|
"dappco.re/go/mcp/pkg/mcp/ide"
|
||||||
"forge.lthn.ai/core/api"
|
"dappco.re/go/core/api"
|
||||||
"forge.lthn.ai/core/api/pkg/provider"
|
"dappco.re/go/core/api/pkg/provider"
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
|
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
"dappco.re/go/mcp/pkg/mcp/ide"
|
"dappco.re/go/mcp/pkg/mcp/ide"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
coreerr "dappco.re/go/core/log"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,12 @@
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxBodySize is the maximum request body size accepted by bridged tool endpoints.
|
// maxBodySize is the maximum request body size accepted by bridged tool endpoints.
|
||||||
|
|
@ -48,7 +47,7 @@ func BridgeToAPI(svc *Service, bridge *api.ToolBridge) {
|
||||||
if !r.OK {
|
if !r.OK {
|
||||||
if err, ok := r.Value.(error); ok {
|
if err, ok := r.Value.(error); ok {
|
||||||
var maxBytesErr *http.MaxBytesError
|
var maxBytesErr *http.MaxBytesError
|
||||||
if errors.As(err, &maxBytesErr) || core.Contains(err.Error(), "request body too large") {
|
if core.As(err, &maxBytesErr) || core.Contains(err.Error(), "request body too large") {
|
||||||
c.JSON(http.StatusRequestEntityTooLarge, api.Fail("request_too_large", "Request body exceeds 10 MB limit"))
|
c.JSON(http.StatusRequestEntityTooLarge, api.Fail("request_too_large", "Request body exceeds 10 MB limit"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +62,7 @@ func BridgeToAPI(svc *Service, bridge *api.ToolBridge) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Body present + error = likely bad input (malformed JSON).
|
// Body present + error = likely bad input (malformed JSON).
|
||||||
// No body + error = tool execution failure.
|
// No body + error = tool execution failure.
|
||||||
if errors.Is(err, errInvalidRESTInput) {
|
if core.Is(err, errInvalidRESTInput) {
|
||||||
c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Malformed JSON in request body"))
|
c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Malformed JSON in request body"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import (
|
||||||
"dappco.re/go/mcp/pkg/mcp/agentic"
|
"dappco.re/go/mcp/pkg/mcp/agentic"
|
||||||
"dappco.re/go/mcp/pkg/mcp/brain"
|
"dappco.re/go/mcp/pkg/mcp/brain"
|
||||||
"dappco.re/go/mcp/pkg/mcp/ide"
|
"dappco.re/go/mcp/pkg/mcp/ide"
|
||||||
api "forge.lthn.ai/core/api"
|
api "dappco.re/go/core/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -250,7 +250,7 @@ func TestBridgeToAPI_Good_EndToEnd(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify a tool endpoint is reachable through the engine.
|
// Verify a tool endpoint is reachable through the engine.
|
||||||
resp2, err := http.Post(srv.URL+"/tools/lang_list", "application/json", nil)
|
resp2, err := http.Post(srv.URL+"/tools/lang_list", "application/json", strings.NewReader("{}"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("lang_list request failed: %v", err)
|
t.Fatalf("lang_list request failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,13 @@ package ide
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
coreerr "dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
|
// errBridgeNotAvailable is returned when a tool requires the Laravel bridge
|
||||||
|
|
@ -556,7 +555,7 @@ func stringFromAny(v any) string {
|
||||||
switch value := v.(type) {
|
switch value := v.(type) {
|
||||||
case string:
|
case string:
|
||||||
return value
|
return value
|
||||||
case fmt.Stringer:
|
case interface{ String() string }:
|
||||||
return value.String()
|
return value.String()
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
coreerr "dappco.re/go/core/log"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coremcp "dappco.re/go/mcp/pkg/mcp"
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,20 @@
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"iter"
|
"iter"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-io"
|
"dappco.re/go/core/io"
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/go-process"
|
"dappco.re/go/core/process"
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -74,7 +72,8 @@ func New(opts Options) (*Service, error) {
|
||||||
|
|
||||||
server := mcp.NewServer(impl, &mcp.ServerOptions{
|
server := mcp.NewServer(impl, &mcp.ServerOptions{
|
||||||
Capabilities: &mcp.ServerCapabilities{
|
Capabilities: &mcp.ServerCapabilities{
|
||||||
Tools: &mcp.ToolCapabilities{ListChanged: true},
|
Resources: &mcp.ResourceCapabilities{ListChanged: false},
|
||||||
|
Tools: &mcp.ToolCapabilities{ListChanged: false},
|
||||||
Logging: &mcp.LoggingCapabilities{},
|
Logging: &mcp.LoggingCapabilities{},
|
||||||
Experimental: channelCapability(),
|
Experimental: channelCapability(),
|
||||||
},
|
},
|
||||||
|
|
@ -245,15 +244,15 @@ func (s *Service) resolveWorkspacePath(path string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.workspaceRoot == "" {
|
if s.workspaceRoot == "" {
|
||||||
return filepath.Clean(path)
|
return core.CleanPath(path, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
clean := filepath.Clean(string(filepath.Separator) + path)
|
clean := core.CleanPath(string(filepath.Separator)+path, "/")
|
||||||
clean = strings.TrimPrefix(clean, string(filepath.Separator))
|
clean = core.TrimPrefix(clean, string(filepath.Separator))
|
||||||
if clean == "." || clean == "" {
|
if clean == "." || clean == "" {
|
||||||
return s.workspaceRoot
|
return s.workspaceRoot
|
||||||
}
|
}
|
||||||
return filepath.Join(s.workspaceRoot, clean)
|
return core.Path(s.workspaceRoot, clean)
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerTools adds the built-in tool groups to the MCP server.
|
// registerTools adds the built-in tool groups to the MCP server.
|
||||||
|
|
@ -543,8 +542,8 @@ func (s *Service) listDirectory(ctx context.Context, req *mcp.CallToolRequest, i
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ListDirectoryOutput{}, log.E("mcp.listDirectory", "failed to list directory", err)
|
return nil, ListDirectoryOutput{}, log.E("mcp.listDirectory", "failed to list directory", err)
|
||||||
}
|
}
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
slices.SortFunc(entries, func(a, b os.DirEntry) int {
|
||||||
return entries[i].Name() < entries[j].Name()
|
return cmp.Compare(a.Name(), b.Name())
|
||||||
})
|
})
|
||||||
result := make([]DirectoryEntry, 0, len(entries))
|
result := make([]DirectoryEntry, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
|
@ -615,7 +614,7 @@ func (s *Service) fileExists(ctx context.Context, req *mcp.CallToolRequest, inpu
|
||||||
|
|
||||||
info, err := s.medium.Stat(input.Path)
|
info, err := s.medium.Stat(input.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if core.Is(err, os.ErrNotExist) {
|
||||||
return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil
|
return nil, FileExistsOutput{Exists: false, IsDir: false, Path: input.Path}, nil
|
||||||
}
|
}
|
||||||
return nil, FileExistsOutput{}, log.E("mcp.fileExists", "failed to stat path", err)
|
return nil, FileExistsOutput{}, log.E("mcp.fileExists", "failed to stat path", err)
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,17 @@
|
||||||
package mcp
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"iter"
|
"iter"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -203,7 +203,7 @@ func (s *Service) ChannelSend(ctx context.Context, channel string, data any) {
|
||||||
if s == nil || s.server == nil {
|
if s == nil || s.server == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(channel) == "" {
|
if core.Trim(channel) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx = normalizeNotificationContext(ctx)
|
ctx = normalizeNotificationContext(ctx)
|
||||||
|
|
@ -218,7 +218,7 @@ func (s *Service) ChannelSendToSession(ctx context.Context, session *mcp.ServerS
|
||||||
if s == nil || s.server == nil || session == nil {
|
if s == nil || s.server == nil || session == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(channel) == "" {
|
if core.Trim(channel) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx = normalizeNotificationContext(ctx)
|
ctx = normalizeNotificationContext(ctx)
|
||||||
|
|
@ -275,6 +275,15 @@ func (s *Service) debugNotify(msg string, args ...any) {
|
||||||
s.logger.Debug(msg, args...)
|
s.logger.Debug(msg, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotifySession sends a raw JSON-RPC notification to a specific MCP session.
|
||||||
|
//
|
||||||
|
// coremcp.NotifySession(ctx, session, "notifications/claude/channel", map[string]any{
|
||||||
|
// "content": "build failed", "meta": map[string]string{"severity": "high"},
|
||||||
|
// })
|
||||||
|
func NotifySession(ctx context.Context, session *mcp.ServerSession, method string, payload any) error {
|
||||||
|
return sendSessionNotification(ctx, session, method, payload)
|
||||||
|
}
|
||||||
|
|
||||||
func sendSessionNotification(ctx context.Context, session *mcp.ServerSession, method string, payload any) error {
|
func sendSessionNotification(ctx context.Context, session *mcp.ServerSession, method string, payload any) error {
|
||||||
if session == nil {
|
if session == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -353,8 +362,8 @@ func snapshotSessions(server *mcp.Server) []*mcp.ServerSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(sessions, func(i, j int) bool {
|
slices.SortFunc(sessions, func(a, b *mcp.ServerSession) int {
|
||||||
return sessions[i].ID() < sessions[j].ID()
|
return cmp.Compare(a.ID(), b.ID())
|
||||||
})
|
})
|
||||||
|
|
||||||
return sessions
|
return sessions
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
type processRuntime struct {
|
type processRuntime struct {
|
||||||
|
|
@ -50,19 +50,20 @@ func (s *Service) forgetProcessRuntime(id string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func isTestProcess(command string, args []string) bool {
|
func isTestProcess(command string, args []string) bool {
|
||||||
base := strings.ToLower(filepath.Base(command))
|
base := core.Lower(core.PathBase(command))
|
||||||
if base == "" {
|
if base == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
switch base {
|
switch base {
|
||||||
case "go":
|
case "go":
|
||||||
return len(args) > 0 && strings.EqualFold(args[0], "test")
|
return len(args) > 0 && core.Lower(args[0]) == "test"
|
||||||
case "cargo":
|
case "cargo":
|
||||||
return len(args) > 0 && strings.EqualFold(args[0], "test")
|
return len(args) > 0 && core.Lower(args[0]) == "test"
|
||||||
case "npm", "pnpm", "yarn", "bun":
|
case "npm", "pnpm", "yarn", "bun":
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if strings.EqualFold(arg, "test") || strings.HasPrefix(strings.ToLower(arg), "test:") {
|
lower := core.Lower(arg)
|
||||||
|
if lower == "test" || core.HasPrefix(lower, "test:") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-process"
|
"dappco.re/go/core/process"
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register is the service factory for core.WithService.
|
// Register is the service factory for core.WithService.
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
"dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-process"
|
"dappco.re/go/core/process"
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegister_Good_WiresOptionalServices(t *testing.T) {
|
func TestRegister_Good_WiresOptionalServices(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,40 @@ type ToolRecord struct {
|
||||||
// return nil, ReadFileOutput{Path: "src/main.go"}, nil
|
// return nil, ReadFileOutput{Path: "src/main.go"}, nil
|
||||||
// })
|
// })
|
||||||
func AddToolRecorded[In, Out any](s *Service, server *mcp.Server, group string, t *mcp.Tool, h mcp.ToolHandlerFor[In, Out]) {
|
func AddToolRecorded[In, Out any](s *Service, server *mcp.Server, group string, t *mcp.Tool, h mcp.ToolHandlerFor[In, Out]) {
|
||||||
mcp.AddTool(server, t, h)
|
// Set inputSchema from struct reflection if not already set.
|
||||||
|
// Use server.AddTool (non-generic) to avoid auto-generated outputSchema.
|
||||||
|
// The go-sdk's generic mcp.AddTool generates outputSchema from the Out type,
|
||||||
|
// but Claude Code's protocol (2025-03-26) doesn't support outputSchema.
|
||||||
|
// Removing it reduces tools/list from 214KB to ~74KB.
|
||||||
|
if t.InputSchema == nil {
|
||||||
|
t.InputSchema = structSchema(new(In))
|
||||||
|
if t.InputSchema == nil {
|
||||||
|
t.InputSchema = map[string]any{"type": "object"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wrap the typed handler into a generic ToolHandler.
|
||||||
|
wrapped := func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
var input In
|
||||||
|
if req != nil && len(req.Params.Arguments) > 0 {
|
||||||
|
if r := core.JSONUnmarshal(req.Params.Arguments, &input); !r.OK {
|
||||||
|
if err, ok := r.Value.(error); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, output, err := h(ctx, req, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
data := core.JSONMarshalString(output)
|
||||||
|
return &mcp.CallToolResult{
|
||||||
|
Content: []mcp.Content{&mcp.TextContent{Text: data}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
server.AddTool(t, wrapped)
|
||||||
|
|
||||||
restHandler := func(ctx context.Context, body []byte) (any, error) {
|
restHandler := func(ctx context.Context, body []byte) (any, error) {
|
||||||
var input In
|
var input In
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-process"
|
"dappco.re/go/core/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestToolRegistry_Good_RecordsTools(t *testing.T) {
|
func TestToolRegistry_Good_RecordsTools(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-ai/ai"
|
"dappco.re/go/core/ai/ai"
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/go-process"
|
"dappco.re/go/core/process"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
"dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-process"
|
"dappco.re/go/core/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestProcessService creates a real process.Service backed by a core.Core for CI tests.
|
// newTestProcessService creates a real process.Service backed by a core.Core for CI tests.
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/go-rag"
|
"dappco.re/go/core/rag"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,12 @@ import (
|
||||||
"image"
|
"image"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/go-webview"
|
"dappco.re/go/core/webview"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -554,7 +553,7 @@ func (s *Service) webviewScreenshot(ctx context.Context, req *mcp.CallToolReques
|
||||||
if format == "" {
|
if format == "" {
|
||||||
format = "png"
|
format = "png"
|
||||||
}
|
}
|
||||||
format = strings.ToLower(format)
|
format = core.Lower(format)
|
||||||
|
|
||||||
data, err := webviewInstance.Screenshot()
|
data, err := webviewInstance.Screenshot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -649,7 +648,7 @@ func waitForSelector(ctx context.Context, timeout time.Duration, selector string
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "element not found") {
|
if !core.Contains(err.Error(), "element not found") {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-webview"
|
"dappco.re/go/core/webview"
|
||||||
)
|
)
|
||||||
|
|
||||||
// skipIfShort skips webview tests in short mode (go test -short).
|
// skipIfShort skips webview tests in short mode (go test -short).
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-ws"
|
"dappco.re/go/core/ws"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestWSToolsRegistered_Good verifies that WebSocket tools are registered when hub is available.
|
// TestWSToolsRegistered_Good verifies that WebSocket tools are registered when hub is available.
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,10 @@ import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -37,7 +36,7 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error {
|
||||||
addr = DefaultHTTPAddr
|
addr = DefaultHTTPAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
authToken := os.Getenv("MCP_AUTH_TOKEN")
|
authToken := core.Env("MCP_AUTH_TOKEN")
|
||||||
|
|
||||||
handler := mcp.NewStreamableHTTPHandler(
|
handler := mcp.NewStreamableHTTPHandler(
|
||||||
func(r *http.Request) *mcp.Server {
|
func(r *http.Request) *mcp.Server {
|
||||||
|
|
@ -85,18 +84,18 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error {
|
||||||
// If token is empty, authentication is disabled for local development.
|
// If token is empty, authentication is disabled for local development.
|
||||||
func withAuth(token string, next http.Handler) http.Handler {
|
func withAuth(token string, next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.TrimSpace(token) == "" {
|
if core.Trim(token) == "" {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(auth, "Bearer ") {
|
if !core.HasPrefix(auth, "Bearer ") {
|
||||||
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provided := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
provided := core.Trim(core.TrimPrefix(auth, "Bearer "))
|
||||||
if len(provided) == 0 {
|
if len(provided) == 0 {
|
||||||
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
|
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
goio "io"
|
goio "io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -31,7 +31,7 @@ var diagWriter goio.Writer = os.Stderr
|
||||||
func diagPrintf(format string, args ...any) {
|
func diagPrintf(format string, args ...any) {
|
||||||
diagMu.Lock()
|
diagMu.Lock()
|
||||||
defer diagMu.Unlock()
|
defer diagMu.Unlock()
|
||||||
fmt.Fprintf(diagWriter, format, args...)
|
core.Print(diagWriter, format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDiagWriter swaps the diagnostic writer and returns the previous one.
|
// setDiagWriter swaps the diagnostic writer and returns the previous one.
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go-io"
|
"dappco.re/go/core/io"
|
||||||
"forge.lthn.ai/core/go-log"
|
"dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServeUnix starts a Unix domain socket server for the MCP service.
|
// ServeUnix starts a Unix domain socket server for the MCP service.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue