Compare commits
1 commit
dev
...
ax/review-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899a532d76 |
19 changed files with 717 additions and 560 deletions
|
|
@ -16,17 +16,15 @@ package main
|
|||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
goio "io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"path/filepath" // needed for WalkDir (no core equivalent)
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
|
@ -57,53 +55,47 @@ var httpClient = &http.Client{
|
|||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
fmt.Println("OpenBrain Seed — MCP API Client")
|
||||
fmt.Println(strings.Repeat("=", 55))
|
||||
os.Stdout.Write([]byte("OpenBrain Seed — MCP API Client\n"))
|
||||
os.Stdout.Write([]byte(core.Sprintf("%s\n", repeatChar('=', 55))))
|
||||
|
||||
if *apiKey == "" && !*dryRun {
|
||||
fmt.Println("ERROR: -api-key is required (or use -dry-run)")
|
||||
fmt.Println(" Generate one at: https://lthn.sh/admin/mcp/api-keys")
|
||||
os.Stdout.Write([]byte("ERROR: -api-key is required (or use -dry-run)\n"))
|
||||
os.Stdout.Write([]byte(" Generate one at: https://lthn.sh/admin/mcp/api-keys\n"))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
fmt.Println("[DRY RUN] — no data will be stored")
|
||||
os.Stdout.Write([]byte("[DRY RUN] — no data will be stored\n"))
|
||||
}
|
||||
|
||||
fmt.Printf("API: %s\n", *apiURL)
|
||||
fmt.Printf("Server: %s | Agent: %s\n", *server, *agent)
|
||||
os.Stdout.Write([]byte(core.Sprintf("API: %s\n", *apiURL)))
|
||||
os.Stdout.Write([]byte(core.Sprintf("Server: %s | Agent: %s\n", *server, *agent)))
|
||||
|
||||
// Discover memory files
|
||||
memPath := *memoryPath
|
||||
if memPath == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
memPath = filepath.Join(home, ".claude", "projects", "*", "memory")
|
||||
memPath = core.JoinPath(core.Env("HOME"), ".claude", "projects", "*", "memory")
|
||||
}
|
||||
memFiles, _ := filepath.Glob(filepath.Join(memPath, "*.md"))
|
||||
fmt.Printf("\nFound %d memory files\n", len(memFiles))
|
||||
memFiles := core.PathGlob(core.JoinPath(memPath, "*.md"))
|
||||
os.Stdout.Write([]byte(core.Sprintf("\nFound %d memory files\n", len(memFiles))))
|
||||
|
||||
// Discover plan files
|
||||
var planFiles []string
|
||||
if *plans {
|
||||
pPath := *planPath
|
||||
if pPath == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
pPath = filepath.Join(home, "Code", "*", "docs", "plans")
|
||||
pPath = core.JoinPath(core.Env("HOME"), "Code", "*", "docs", "plans")
|
||||
}
|
||||
planFiles, _ = filepath.Glob(filepath.Join(pPath, "*.md"))
|
||||
planFiles = append(planFiles, core.PathGlob(core.JoinPath(pPath, "*.md"))...)
|
||||
// Also check nested dirs (completed/, etc.)
|
||||
nested, _ := filepath.Glob(filepath.Join(pPath, "*", "*.md"))
|
||||
planFiles = append(planFiles, nested...)
|
||||
planFiles = append(planFiles, core.PathGlob(core.JoinPath(pPath, "*", "*.md"))...)
|
||||
|
||||
// Also check host-uk nested repos
|
||||
home, _ := os.UserHomeDir()
|
||||
hostUkPath := filepath.Join(home, "Code", "host-uk", "*", "docs", "plans")
|
||||
hostUkFiles, _ := filepath.Glob(filepath.Join(hostUkPath, "*.md"))
|
||||
planFiles = append(planFiles, hostUkFiles...)
|
||||
hostUkNested, _ := filepath.Glob(filepath.Join(hostUkPath, "*", "*.md"))
|
||||
planFiles = append(planFiles, hostUkNested...)
|
||||
hostUkPath := core.JoinPath(core.Env("HOME"), "Code", "host-uk", "*", "docs", "plans")
|
||||
planFiles = append(planFiles, core.PathGlob(core.JoinPath(hostUkPath, "*.md"))...)
|
||||
planFiles = append(planFiles, core.PathGlob(core.JoinPath(hostUkPath, "*", "*.md"))...)
|
||||
|
||||
fmt.Printf("Found %d plan files\n", len(planFiles))
|
||||
os.Stdout.Write([]byte(core.Sprintf("Found %d plan files\n", len(planFiles))))
|
||||
}
|
||||
|
||||
// Discover CLAUDE.md files
|
||||
|
|
@ -111,11 +103,10 @@ func main() {
|
|||
if *claudeMd {
|
||||
cPath := *codePath
|
||||
if cPath == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
cPath = filepath.Join(home, "Code")
|
||||
cPath = core.JoinPath(core.Env("HOME"), "Code")
|
||||
}
|
||||
claudeFiles = discoverClaudeMdFiles(cPath)
|
||||
fmt.Printf("Found %d CLAUDE.md files\n", len(claudeFiles))
|
||||
os.Stdout.Write([]byte(core.Sprintf("Found %d CLAUDE.md files\n", len(claudeFiles))))
|
||||
}
|
||||
|
||||
imported := 0
|
||||
|
|
@ -123,11 +114,11 @@ func main() {
|
|||
errors := 0
|
||||
|
||||
// Process memory files
|
||||
fmt.Println("\n--- Memory Files ---")
|
||||
os.Stdout.Write([]byte("\n--- Memory Files ---\n"))
|
||||
for _, f := range memFiles {
|
||||
project := extractProject(f)
|
||||
sections := parseMarkdownSections(f)
|
||||
filename := strings.TrimSuffix(filepath.Base(f), ".md")
|
||||
filename := core.TrimSuffix(core.PathBase(f), ".md")
|
||||
|
||||
if len(sections) == 0 {
|
||||
coreerr.Warn("brain-seed: skip file (no sections)", "project", project, "file", filename)
|
||||
|
|
@ -137,7 +128,7 @@ func main() {
|
|||
|
||||
for _, sec := range sections {
|
||||
content := sec.heading + "\n\n" + sec.content
|
||||
if strings.TrimSpace(sec.content) == "" {
|
||||
if core.Trim(sec.content) == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
|
@ -150,8 +141,8 @@ func main() {
|
|||
content = truncate(content, *maxChars)
|
||||
|
||||
if *dryRun {
|
||||
fmt.Printf(" [DRY] %s/%s :: %s (%s) — %d chars\n",
|
||||
project, filename, sec.heading, memType, len(content))
|
||||
os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s/%s :: %s (%s) — %d chars\n",
|
||||
project, filename, sec.heading, memType, len(content))))
|
||||
imported++
|
||||
continue
|
||||
}
|
||||
|
|
@ -161,18 +152,18 @@ func main() {
|
|||
errors++
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" ok %s/%s :: %s (%s)\n", project, filename, sec.heading, memType)
|
||||
os.Stdout.Write([]byte(core.Sprintf(" ok %s/%s :: %s (%s)\n", project, filename, sec.heading, memType)))
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
||||
// Process plan files
|
||||
if *plans && len(planFiles) > 0 {
|
||||
fmt.Println("\n--- Plan Documents ---")
|
||||
os.Stdout.Write([]byte("\n--- Plan Documents ---\n"))
|
||||
for _, f := range planFiles {
|
||||
project := extractProjectFromPlan(f)
|
||||
sections := parseMarkdownSections(f)
|
||||
filename := strings.TrimSuffix(filepath.Base(f), ".md")
|
||||
filename := core.TrimSuffix(core.PathBase(f), ".md")
|
||||
|
||||
if len(sections) == 0 {
|
||||
skipped++
|
||||
|
|
@ -181,7 +172,7 @@ func main() {
|
|||
|
||||
for _, sec := range sections {
|
||||
content := sec.heading + "\n\n" + sec.content
|
||||
if strings.TrimSpace(sec.content) == "" {
|
||||
if core.Trim(sec.content) == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
|
@ -190,8 +181,8 @@ func main() {
|
|||
content = truncate(content, *maxChars)
|
||||
|
||||
if *dryRun {
|
||||
fmt.Printf(" [DRY] %s :: %s / %s (plan) — %d chars\n",
|
||||
project, filename, sec.heading, len(content))
|
||||
os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s :: %s / %s (plan) — %d chars\n",
|
||||
project, filename, sec.heading, len(content))))
|
||||
imported++
|
||||
continue
|
||||
}
|
||||
|
|
@ -201,7 +192,7 @@ func main() {
|
|||
errors++
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" ok %s :: %s / %s (plan)\n", project, filename, sec.heading)
|
||||
os.Stdout.Write([]byte(core.Sprintf(" ok %s :: %s / %s (plan)\n", project, filename, sec.heading)))
|
||||
imported++
|
||||
}
|
||||
}
|
||||
|
|
@ -209,7 +200,7 @@ func main() {
|
|||
|
||||
// Process CLAUDE.md files
|
||||
if *claudeMd && len(claudeFiles) > 0 {
|
||||
fmt.Println("\n--- CLAUDE.md Files ---")
|
||||
os.Stdout.Write([]byte("\n--- CLAUDE.md Files ---\n"))
|
||||
for _, f := range claudeFiles {
|
||||
project := extractProjectFromClaudeMd(f)
|
||||
sections := parseMarkdownSections(f)
|
||||
|
|
@ -221,7 +212,7 @@ func main() {
|
|||
|
||||
for _, sec := range sections {
|
||||
content := sec.heading + "\n\n" + sec.content
|
||||
if strings.TrimSpace(sec.content) == "" {
|
||||
if core.Trim(sec.content) == "" {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
|
@ -230,8 +221,8 @@ func main() {
|
|||
content = truncate(content, *maxChars)
|
||||
|
||||
if *dryRun {
|
||||
fmt.Printf(" [DRY] %s :: CLAUDE.md / %s (convention) — %d chars\n",
|
||||
project, sec.heading, len(content))
|
||||
os.Stdout.Write([]byte(core.Sprintf(" [DRY] %s :: CLAUDE.md / %s (convention) — %d chars\n",
|
||||
project, sec.heading, len(content))))
|
||||
imported++
|
||||
continue
|
||||
}
|
||||
|
|
@ -241,18 +232,18 @@ func main() {
|
|||
errors++
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" ok %s :: CLAUDE.md / %s (convention)\n", project, sec.heading)
|
||||
os.Stdout.Write([]byte(core.Sprintf(" ok %s :: CLAUDE.md / %s (convention)\n", project, sec.heading)))
|
||||
imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n", strings.Repeat("=", 55))
|
||||
os.Stdout.Write([]byte(core.Sprintf("\n%s\n", repeatChar('=', 55))))
|
||||
prefix := ""
|
||||
if *dryRun {
|
||||
prefix = "[DRY RUN] "
|
||||
}
|
||||
fmt.Printf("%sImported: %d | Skipped: %d | Errors: %d\n", prefix, imported, skipped, errors)
|
||||
os.Stdout.Write([]byte(core.Sprintf("%sImported: %d | Skipped: %d | Errors: %d\n", prefix, imported, skipped, errors)))
|
||||
}
|
||||
|
||||
// callBrainRemember sends a memory to the MCP API via brain_remember tool.
|
||||
|
|
@ -273,10 +264,7 @@ func callBrainRemember(content, memType string, tags []string, project string, c
|
|||
"arguments": args,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return coreerr.E("callBrainRemember", "marshal", err)
|
||||
}
|
||||
body := []byte(core.JSONMarshalString(payload))
|
||||
|
||||
req, err := http.NewRequest("POST", *apiURL+"/tools/call", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
|
|
@ -301,8 +289,9 @@ func callBrainRemember(content, memType string, tags []string, project string, c
|
|||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return coreerr.E("callBrainRemember", "decode", err)
|
||||
r := core.JSONUnmarshalString(string(respBody), &result)
|
||||
if !r.OK {
|
||||
return coreerr.E("callBrainRemember", "decode", nil)
|
||||
}
|
||||
if !result.Success {
|
||||
return coreerr.E("callBrainRemember", "API: "+result.Error, nil)
|
||||
|
|
@ -312,19 +301,26 @@ func callBrainRemember(content, memType string, tags []string, project string, c
|
|||
}
|
||||
|
||||
// truncate caps content to maxLen chars, appending an ellipsis if truncated.
|
||||
//
|
||||
// truncate("hello world", 5) // "hello…"
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
// Find last space before limit to avoid splitting mid-word
|
||||
cut := maxLen
|
||||
if idx := strings.LastIndex(s[:maxLen], " "); idx > maxLen-200 {
|
||||
cut = idx
|
||||
for i := maxLen - 1; i > maxLen-200 && i >= 0; i-- {
|
||||
if s[i] == ' ' {
|
||||
cut = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return s[:cut] + "…"
|
||||
}
|
||||
|
||||
// discoverClaudeMdFiles finds CLAUDE.md files across a code directory.
|
||||
//
|
||||
// files := discoverClaudeMdFiles(core.JoinPath(core.Env("HOME"), "Code"))
|
||||
func discoverClaudeMdFiles(codePath string) []string {
|
||||
var files []string
|
||||
|
||||
|
|
@ -338,9 +334,15 @@ func discoverClaudeMdFiles(codePath string) []string {
|
|||
if name == "node_modules" || name == "vendor" || name == ".claude" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
// Limit depth
|
||||
// Limit depth by counting separators manually
|
||||
rel, _ := filepath.Rel(codePath, path)
|
||||
if strings.Count(rel, string(os.PathSeparator)) > 3 {
|
||||
depth := 0
|
||||
for _, ch := range rel {
|
||||
if ch == os.PathSeparator {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
if depth > 3 {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
|
|
@ -363,6 +365,8 @@ type section struct {
|
|||
var headingRe = regexp.MustCompile(`^#{1,3}\s+(.+)$`)
|
||||
|
||||
// parseMarkdownSections splits a markdown file by headings.
|
||||
//
|
||||
// sections := parseMarkdownSections("/path/to/MEMORY.md")
|
||||
func parseMarkdownSections(path string) []section {
|
||||
data, err := coreio.Local.Read(path)
|
||||
if err != nil || len(data) == 0 {
|
||||
|
|
@ -370,19 +374,19 @@ func parseMarkdownSections(path string) []section {
|
|||
}
|
||||
|
||||
var sections []section
|
||||
lines := strings.Split(data, "\n")
|
||||
lines := core.Split(data, "\n")
|
||||
var curHeading string
|
||||
var curContent []string
|
||||
|
||||
for _, line := range lines {
|
||||
if m := headingRe.FindStringSubmatch(line); m != nil {
|
||||
if curHeading != "" && len(curContent) > 0 {
|
||||
text := strings.TrimSpace(strings.Join(curContent, "\n"))
|
||||
text := core.Trim(joinLines(curContent))
|
||||
if text != "" {
|
||||
sections = append(sections, section{curHeading, text})
|
||||
}
|
||||
}
|
||||
curHeading = strings.TrimSpace(m[1])
|
||||
curHeading = core.Trim(m[1])
|
||||
curContent = nil
|
||||
} else {
|
||||
curContent = append(curContent, line)
|
||||
|
|
@ -391,17 +395,17 @@ func parseMarkdownSections(path string) []section {
|
|||
|
||||
// Flush last section
|
||||
if curHeading != "" && len(curContent) > 0 {
|
||||
text := strings.TrimSpace(strings.Join(curContent, "\n"))
|
||||
text := core.Trim(joinLines(curContent))
|
||||
if text != "" {
|
||||
sections = append(sections, section{curHeading, text})
|
||||
}
|
||||
}
|
||||
|
||||
// If no headings found, treat entire file as one section
|
||||
if len(sections) == 0 && strings.TrimSpace(data) != "" {
|
||||
if len(sections) == 0 && core.Trim(data) != "" {
|
||||
sections = append(sections, section{
|
||||
heading: strings.TrimSuffix(filepath.Base(path), ".md"),
|
||||
content: strings.TrimSpace(data),
|
||||
heading: core.TrimSuffix(core.PathBase(path), ".md"),
|
||||
content: core.Trim(data),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -459,7 +463,7 @@ func inferType(heading, content, source string) string {
|
|||
return "convention"
|
||||
}
|
||||
|
||||
lower := strings.ToLower(heading + " " + content)
|
||||
lower := toLower(heading + " " + content)
|
||||
patterns := map[string][]string{
|
||||
"architecture": {"architecture", "stack", "infrastructure", "layer", "service mesh"},
|
||||
"convention": {"convention", "standard", "naming", "pattern", "rule", "coding"},
|
||||
|
|
@ -468,10 +472,10 @@ func inferType(heading, content, source string) string {
|
|||
"plan": {"plan", "todo", "roadmap", "milestone", "phase", "task"},
|
||||
"research": {"research", "finding", "discovery", "analysis", "rfc"},
|
||||
}
|
||||
for t, keywords := range patterns {
|
||||
for memoryType, keywords := range patterns {
|
||||
for _, kw := range keywords {
|
||||
if strings.Contains(lower, kw) {
|
||||
return t
|
||||
if core.Contains(lower, kw) {
|
||||
return memoryType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -485,7 +489,7 @@ func buildTags(filename, source, project string) []string {
|
|||
tags = append(tags, "project:"+project)
|
||||
}
|
||||
if filename != "MEMORY" && filename != "CLAUDE" {
|
||||
tags = append(tags, strings.ReplaceAll(strings.ReplaceAll(filename, "-", " "), "_", " "))
|
||||
tags = append(tags, core.Replace(core.Replace(filename, "-", " "), "_", " "))
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
|
@ -503,3 +507,43 @@ func confidenceForSource(source string) float64 {
|
|||
return 0.5
|
||||
}
|
||||
}
|
||||
|
||||
// repeatChar returns a string of n repetitions of ch.
|
||||
//
|
||||
// repeatChar('=', 3) // "==="
|
||||
func repeatChar(ch byte, n int) string {
|
||||
b := core.NewBuilder()
|
||||
for i := 0; i < n; i++ {
|
||||
b.WriteByte(ch)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// joinLines joins a slice of lines with newline separators.
|
||||
//
|
||||
// joinLines([]string{"a", "b"}) // "a\nb"
|
||||
func joinLines(lines []string) string {
|
||||
b := core.NewBuilder()
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(line)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// toLower converts a string to lowercase ASCII.
|
||||
//
|
||||
// toLower("Hello World") // "hello world"
|
||||
func toLower(s string) string {
|
||||
b := core.NewBuilder()
|
||||
for _, ch := range s {
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
b.WriteRune(ch + 32)
|
||||
} else {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,12 @@ package agentic
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -52,31 +50,31 @@ func (s *PrepSubsystem) registerDispatchTool(server *mcp.Server) {
|
|||
// agentCommand returns the command and args for a given agent type.
|
||||
// Supports model variants: "gemini", "gemini:flash", "gemini:pro", "claude", "claude:haiku".
|
||||
func agentCommand(agent, prompt string) (string, []string, error) {
|
||||
parts := strings.SplitN(agent, ":", 2)
|
||||
base := parts[0]
|
||||
model := ""
|
||||
parts := core.SplitN(agent, ":", 2)
|
||||
agentBase := parts[0]
|
||||
agentModel := ""
|
||||
if len(parts) > 1 {
|
||||
model = parts[1]
|
||||
agentModel = parts[1]
|
||||
}
|
||||
|
||||
switch base {
|
||||
switch agentBase {
|
||||
case "gemini":
|
||||
args := []string{"-p", prompt, "--yolo", "--sandbox"}
|
||||
if model != "" {
|
||||
args = append(args, "-m", "gemini-2.5-"+model)
|
||||
if agentModel != "" {
|
||||
args = append(args, "-m", "gemini-2.5-"+agentModel)
|
||||
}
|
||||
return "gemini", args, nil
|
||||
case "codex":
|
||||
return "codex", []string{"--approval-mode", "full-auto", "-q", prompt}, nil
|
||||
case "claude":
|
||||
args := []string{"-p", prompt, "--dangerously-skip-permissions"}
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
if agentModel != "" {
|
||||
args = append(args, "--model", agentModel)
|
||||
}
|
||||
return "claude", args, nil
|
||||
case "local":
|
||||
home, _ := os.UserHomeDir()
|
||||
script := filepath.Join(home, "Code", "core", "agent", "scripts", "local-agent.sh")
|
||||
home := core.Env("HOME")
|
||||
script := core.JoinPath(home, "Code", "core", "agent", "scripts", "local-agent.sh")
|
||||
return "bash", []string{script, prompt}, nil
|
||||
default:
|
||||
return "", nil, coreerr.E("agentCommand", "unknown agent: "+agent, nil)
|
||||
|
|
@ -117,14 +115,14 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
|||
}
|
||||
|
||||
wsDir := prepOut.WorkspaceDir
|
||||
srcDir := filepath.Join(wsDir, "src")
|
||||
srcDir := core.JoinPath(wsDir, "src")
|
||||
|
||||
// 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."
|
||||
|
||||
if input.DryRun {
|
||||
// Read PROMPT.md for the dry run output
|
||||
promptRaw, _ := coreio.Local.Read(filepath.Join(wsDir, "PROMPT.md"))
|
||||
promptRaw, _ := coreio.Local.Read(core.JoinPath(wsDir, "PROMPT.md"))
|
||||
return nil, DispatchOutput{
|
||||
Success: true,
|
||||
Agent: input.Agent,
|
||||
|
|
@ -175,7 +173,7 @@ func (s *PrepSubsystem) dispatch(ctx context.Context, req *mcp.CallToolRequest,
|
|||
return nil, DispatchOutput{}, err
|
||||
}
|
||||
|
||||
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", input.Agent))
|
||||
outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", input.Agent))
|
||||
outFile, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
return nil, DispatchOutput{}, coreerr.E("dispatch", "failed to create log file", err)
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@ package agentic
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -99,19 +97,19 @@ func (s *PrepSubsystem) createEpic(ctx context.Context, req *mcp.CallToolRequest
|
|||
}
|
||||
|
||||
// Step 2: Build epic body with checklist
|
||||
var body strings.Builder
|
||||
epicBody := core.NewBuilder()
|
||||
if input.Body != "" {
|
||||
body.WriteString(input.Body)
|
||||
body.WriteString("\n\n")
|
||||
epicBody.WriteString(input.Body)
|
||||
epicBody.WriteString("\n\n")
|
||||
}
|
||||
body.WriteString("## Tasks\n\n")
|
||||
epicBody.WriteString("## Tasks\n\n")
|
||||
for _, child := range children {
|
||||
body.WriteString(fmt.Sprintf("- [ ] #%d %s\n", child.Number, child.Title))
|
||||
epicBody.WriteString(core.Sprintf("- [ ] #%d %s\n", child.Number, child.Title))
|
||||
}
|
||||
|
||||
// Step 3: Create epic issue
|
||||
epicLabels := append(labelIDs, s.resolveLabelIDs(ctx, input.Org, input.Repo, []string{"epic"})...)
|
||||
epic, err := s.createIssue(ctx, input.Org, input.Repo, input.Title, body.String(), epicLabels)
|
||||
epic, err := s.createIssue(ctx, input.Org, input.Repo, input.Title, epicBody.String(), epicLabels)
|
||||
if err != nil {
|
||||
return nil, EpicOutput{}, coreerr.E("createEpic", "failed to create epic", err)
|
||||
}
|
||||
|
|
@ -155,9 +153,9 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
|
|||
payload["labels"] = labelIDs
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(payload)
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data))
|
||||
data := []byte(core.JSONMarshalString(payload))
|
||||
issueURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", issueURL, bytes.NewReader(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
|
|
@ -165,17 +163,17 @@ func (s *PrepSubsystem) createIssue(ctx context.Context, org, repo, title, body
|
|||
if err != nil {
|
||||
return ChildRef{}, coreerr.E("createIssue", "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 201 {
|
||||
return ChildRef{}, coreerr.E("createIssue", fmt.Sprintf("returned %d", resp.StatusCode), nil)
|
||||
resp.Body.Close()
|
||||
return ChildRef{}, coreerr.E("createIssue", core.Sprintf("returned %d", resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Number int `json:"number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &result)
|
||||
|
||||
return ChildRef{
|
||||
Number: result.Number,
|
||||
|
|
@ -191,16 +189,16 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n
|
|||
}
|
||||
|
||||
// Fetch existing labels
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
labelsURL := core.Sprintf("%s/api/v1/repos/%s/%s/labels?limit=50", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", labelsURL, nil)
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +206,7 @@ func (s *PrepSubsystem) resolveLabelIDs(ctx context.Context, org, repo string, n
|
|||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&existing)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &existing)
|
||||
|
||||
nameToID := make(map[string]int64)
|
||||
for _, l := range existing {
|
||||
|
|
@ -244,13 +242,13 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string)
|
|||
colour = "#6b7280"
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
payload := []byte(core.JSONMarshalString(map[string]string{
|
||||
"name": name,
|
||||
"color": colour,
|
||||
})
|
||||
}))
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
||||
createLabelURL := core.Sprintf("%s/api/v1/repos/%s/%s/labels", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", createLabelURL, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
|
|
@ -258,15 +256,15 @@ func (s *PrepSubsystem) createLabel(ctx context.Context, org, repo, name string)
|
|||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 201 {
|
||||
resp.Body.Close()
|
||||
return 0
|
||||
}
|
||||
|
||||
var result struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &result)
|
||||
return result.ID
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,9 @@ package agentic
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
|
|
@ -23,10 +19,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
|
|||
}
|
||||
|
||||
// Read the log file
|
||||
logFiles, err := filepath.Glob(filepath.Join(wsDir, "agent-*.log"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
logFiles := core.PathGlob(core.JoinPath(wsDir, "agent-*.log"))
|
||||
if len(logFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -39,7 +32,7 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
|
|||
body := contentStr
|
||||
|
||||
// Skip quota errors
|
||||
if strings.Contains(body, "QUOTA_EXHAUSTED") || strings.Contains(body, "QuotaError") {
|
||||
if core.Contains(body, "QUOTA_EXHAUSTED") || core.Contains(body, "QuotaError") {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -52,13 +45,13 @@ func (s *PrepSubsystem) ingestFindings(wsDir string) {
|
|||
// Determine issue type from the template used
|
||||
issueType := "task"
|
||||
priority := "normal"
|
||||
if strings.Contains(body, "security") || strings.Contains(body, "Security") {
|
||||
if core.Contains(body, "security") || core.Contains(body, "Security") {
|
||||
issueType = "bug"
|
||||
priority = "high"
|
||||
}
|
||||
|
||||
// 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
|
||||
description := body
|
||||
|
|
@ -81,7 +74,7 @@ func countFileRefs(body string) int {
|
|||
}
|
||||
if j < len(body) && body[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++
|
||||
}
|
||||
}
|
||||
|
|
@ -97,20 +90,20 @@ func (s *PrepSubsystem) createIssueViaAPI(repo, title, description, issueType, p
|
|||
}
|
||||
|
||||
// Read the agent API key from file
|
||||
home, _ := os.UserHomeDir()
|
||||
apiKeyData, err := coreio.Local.Read(filepath.Join(home, ".claude", "agent-api.key"))
|
||||
home := core.Env("HOME")
|
||||
apiKeyData, err := coreio.Local.Read(core.JoinPath(home, ".claude", "agent-api.key"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
apiKey := strings.TrimSpace(apiKeyData)
|
||||
apiKey := core.Trim(apiKeyData)
|
||||
|
||||
payload, _ := json.Marshal(map[string]string{
|
||||
payload := []byte(core.JSONMarshalString(map[string]string{
|
||||
"title": title,
|
||||
"description": description,
|
||||
"type": issueType,
|
||||
"priority": priority,
|
||||
"reporter": "cladius",
|
||||
})
|
||||
}))
|
||||
|
||||
req, _ := http.NewRequest("POST", s.brainURL+"/v1/issues", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
|
|
|||
|
|
@ -6,11 +6,9 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -281,11 +279,11 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
|
||||
var plans []Plan
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
if entry.IsDir() || !core.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
|
||||
id := strings.TrimSuffix(entry.Name(), ".json")
|
||||
id := core.TrimSuffix(entry.Name(), ".json")
|
||||
plan, err := readPlan(dir, id)
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
@ -312,36 +310,43 @@ func (s *PrepSubsystem) planList(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
// --- Helpers ---
|
||||
|
||||
func (s *PrepSubsystem) plansDir() string {
|
||||
return filepath.Join(s.codePath, ".core", "plans")
|
||||
return core.JoinPath(s.codePath, ".core", "plans")
|
||||
}
|
||||
|
||||
func planPath(dir, id string) string {
|
||||
return filepath.Join(dir, id+".json")
|
||||
return core.JoinPath(dir, id+".json")
|
||||
}
|
||||
|
||||
func generatePlanID(title string) string {
|
||||
slug := strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' {
|
||||
return r
|
||||
// Build slug: lowercase, letters/digits/-
|
||||
builder := core.NewBuilder()
|
||||
for _, r := range title {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-':
|
||||
builder.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
builder.WriteRune(r + 32)
|
||||
case r == ' ':
|
||||
builder.WriteRune('-')
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return r + 32
|
||||
}
|
||||
if r == ' ' {
|
||||
return '-'
|
||||
}
|
||||
return -1
|
||||
}, title)
|
||||
}
|
||||
slug := builder.String()
|
||||
|
||||
// Trim consecutive dashes and cap length
|
||||
for strings.Contains(slug, "--") {
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
for core.Contains(slug, "--") {
|
||||
slug = core.Replace(slug, "--", "-")
|
||||
}
|
||||
slug = core.Trim(slug)
|
||||
// Trim leading/trailing dashes manually (core.Trim does whitespace only)
|
||||
for len(slug) > 0 && slug[0] == '-' {
|
||||
slug = slug[1:]
|
||||
}
|
||||
slug = strings.Trim(slug, "-")
|
||||
if len(slug) > 30 {
|
||||
slug = slug[:30]
|
||||
}
|
||||
slug = strings.TrimRight(slug, "-")
|
||||
for len(slug) > 0 && slug[len(slug)-1] == '-' {
|
||||
slug = slug[:len(slug)-1]
|
||||
}
|
||||
|
||||
// Append short random suffix for uniqueness
|
||||
b := make([]byte, 3)
|
||||
|
|
@ -356,8 +361,9 @@ func readPlan(dir, id string) (*Plan, error) {
|
|||
}
|
||||
|
||||
var plan Plan
|
||||
if err := json.Unmarshal([]byte(data), &plan); err != nil {
|
||||
return nil, coreerr.E("readPlan", "failed to parse plan "+id, err)
|
||||
result := core.JSONUnmarshalString(data, &plan)
|
||||
if !result.OK {
|
||||
return nil, coreerr.E("readPlan", "failed to parse plan "+id, nil)
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
|
@ -368,12 +374,9 @@ func writePlan(dir string, plan *Plan) (string, error) {
|
|||
}
|
||||
|
||||
path := planPath(dir, plan.ID)
|
||||
data, err := json.MarshalIndent(plan, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := core.JSONMarshalString(plan)
|
||||
|
||||
return path, coreio.Local.Write(path, string(data))
|
||||
return path, coreio.Local.Write(path, encoded)
|
||||
}
|
||||
|
||||
func validPlanStatus(status string) bool {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,10 @@ package agentic
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -54,8 +51,8 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
return nil, CreatePROutput{}, coreerr.E("createPR", "no Forge token configured", nil)
|
||||
}
|
||||
|
||||
wsDir := filepath.Join(s.workspaceRoot(), input.Workspace)
|
||||
srcDir := filepath.Join(wsDir, "src")
|
||||
wsDir := core.JoinPath(s.workspaceRoot(), input.Workspace)
|
||||
srcDir := core.JoinPath(wsDir, "src")
|
||||
|
||||
if _, err := coreio.Local.List(srcDir); err != nil {
|
||||
return nil, CreatePROutput{}, coreerr.E("createPR", "workspace not found: "+input.Workspace, nil)
|
||||
|
|
@ -75,7 +72,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
if err != nil {
|
||||
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
|
||||
|
|
@ -93,7 +90,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
title = st.Task
|
||||
}
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Agent work on %s", st.Branch)
|
||||
title = core.Sprintf("Agent work on %s", st.Branch)
|
||||
}
|
||||
|
||||
// Build PR body
|
||||
|
|
@ -131,7 +128,7 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
|
||||
// Comment on issue if tracked
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -147,31 +144,31 @@ func (s *PrepSubsystem) createPR(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
|
||||
func (s *PrepSubsystem) buildPRBody(st *WorkspaceStatus) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## Summary\n\n")
|
||||
builder := core.NewBuilder()
|
||||
builder.WriteString("## Summary\n\n")
|
||||
if st.Task != "" {
|
||||
b.WriteString(st.Task)
|
||||
b.WriteString("\n\n")
|
||||
builder.WriteString(st.Task)
|
||||
builder.WriteString("\n\n")
|
||||
}
|
||||
if st.Issue > 0 {
|
||||
b.WriteString(fmt.Sprintf("Closes #%d\n\n", st.Issue))
|
||||
builder.WriteString(core.Sprintf("Closes #%d\n\n", st.Issue))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("**Agent:** %s\n", st.Agent))
|
||||
b.WriteString(fmt.Sprintf("**Runs:** %d\n", st.Runs))
|
||||
b.WriteString("\n---\n*Created by agentic dispatch*\n")
|
||||
return b.String()
|
||||
builder.WriteString(core.Sprintf("**Agent:** %s\n", st.Agent))
|
||||
builder.WriteString(core.Sprintf("**Runs:** %d\n", st.Runs))
|
||||
builder.WriteString("\n---\n*Created by agentic dispatch*\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base, title, body string) (string, int, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
payload := []byte(core.JSONMarshalString(map[string]any{
|
||||
"title": title,
|
||||
"body": body,
|
||||
"head": head,
|
||||
"base": base,
|
||||
})
|
||||
}))
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
||||
pullsURL := core.Sprintf("%s/api/v1/repos/%s/%s/pulls", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", pullsURL, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
|
|
@ -183,25 +180,25 @@ func (s *PrepSubsystem) forgeCreatePR(ctx context.Context, org, repo, head, base
|
|||
|
||||
if resp.StatusCode != 201 {
|
||||
var errBody map[string]any
|
||||
json.NewDecoder(resp.Body).Decode(&errBody)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &errBody)
|
||||
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 {
|
||||
Number int `json:"number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&pr)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &pr)
|
||||
|
||||
return pr.HTMLURL, pr.Number, nil
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) commentOnIssue(ctx context.Context, org, repo string, issue int, comment string) {
|
||||
payload, _ := json.Marshal(map[string]string{"body": comment})
|
||||
payload := []byte(core.JSONMarshalString(map[string]string{"body": comment}))
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
|
||||
commentURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", s.forgeURL, org, repo, issue)
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", commentURL, bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
|
|
@ -302,18 +299,18 @@ func (s *PrepSubsystem) listPRs(ctx context.Context, _ *mcp.CallToolRequest, inp
|
|||
}
|
||||
|
||||
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",
|
||||
repoPullsURL := core.Sprintf("%s/api/v1/repos/%s/%s/pulls?state=%s&limit=10",
|
||||
s.forgeURL, org, repo, state)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", repoPullsURL, nil)
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, coreerr.E("listRepoPRs", "failed to list PRs for "+repo, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, coreerr.E("listRepoPRs", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
|
||||
resp.Body.Close()
|
||||
return nil, coreerr.E("listRepoPRs", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
var prs []struct {
|
||||
|
|
@ -335,7 +332,7 @@ func (s *PrepSubsystem) listRepoPRs(ctx context.Context, org, repo, state string
|
|||
Name string `json:"name"`
|
||||
} `json:"labels"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&prs)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &prs)
|
||||
|
||||
var result []PRInfo
|
||||
for _, pr := range prs {
|
||||
|
|
|
|||
|
|
@ -7,16 +7,12 @@ package agentic
|
|||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
goio "io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -35,18 +31,21 @@ type PrepSubsystem struct {
|
|||
}
|
||||
|
||||
// NewPrep creates an agentic subsystem.
|
||||
//
|
||||
// sub := agentic.NewPrep()
|
||||
// svc, _ := mcp.New(mcp.Options{Subsystems: []mcp.Subsystem{sub}})
|
||||
func NewPrep() *PrepSubsystem {
|
||||
home, _ := os.UserHomeDir()
|
||||
home := core.Env("HOME")
|
||||
|
||||
forgeToken := os.Getenv("FORGE_TOKEN")
|
||||
forgeToken := core.Env("FORGE_TOKEN")
|
||||
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 data, err := coreio.Local.Read(filepath.Join(home, ".claude", "brain.key")); err == nil {
|
||||
brainKey = strings.TrimSpace(data)
|
||||
if data, err := coreio.Local.Read(core.JoinPath(home, ".claude", "brain.key")); err == nil {
|
||||
brainKey = core.Trim(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,31 +54,31 @@ func NewPrep() *PrepSubsystem {
|
|||
forgeToken: forgeToken,
|
||||
brainURL: envOr("CORE_BRAIN_URL", "https://api.lthn.sh"),
|
||||
brainKey: brainKey,
|
||||
specsPath: envOr("SPECS_PATH", filepath.Join(home, "Code", "host-uk", "specs")),
|
||||
codePath: envOr("CODE_PATH", filepath.Join(home, "Code")),
|
||||
specsPath: envOr("SPECS_PATH", core.JoinPath(home, "Code", "host-uk", "specs")),
|
||||
codePath: envOr("CODE_PATH", core.JoinPath(home, "Code")),
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if v := core.Env(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(value, "\\") {
|
||||
if core.Contains(value, "\\") {
|
||||
return "", coreerr.E("prepWorkspace", field+" contains invalid path separator", nil)
|
||||
}
|
||||
|
||||
parts := strings.Split(value, "/")
|
||||
parts := core.Split(value, "/")
|
||||
if !allowSubdirs && len(parts) != 1 {
|
||||
return "", coreerr.E("prepWorkspace", field+" may not contain subdirectories", nil)
|
||||
}
|
||||
|
|
@ -134,7 +133,7 @@ func (s *PrepSubsystem) Shutdown(_ context.Context) error { return nil }
|
|||
|
||||
// workspaceRoot returns the base directory for agent workspaces.
|
||||
func (s *PrepSubsystem) workspaceRoot() string {
|
||||
return filepath.Join(s.codePath, ".core", "workspace")
|
||||
return core.JoinPath(s.codePath, ".core", "workspace")
|
||||
}
|
||||
|
||||
// --- Input/Output types ---
|
||||
|
|
@ -197,8 +196,8 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
|||
|
||||
// Workspace root: .core/workspace/{repo}-{timestamp}/
|
||||
wsRoot := s.workspaceRoot()
|
||||
wsName := fmt.Sprintf("%s-%d", input.Repo, time.Now().Unix())
|
||||
wsDir := filepath.Join(wsRoot, wsName)
|
||||
wsName := core.Sprintf("%s-%d", input.Repo, time.Now().Unix())
|
||||
wsDir := core.JoinPath(wsRoot, wsName)
|
||||
|
||||
// Create workspace structure
|
||||
// kb/ and specs/ will be created inside src/ after clone
|
||||
|
|
@ -206,31 +205,41 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
|||
out := PrepOutput{WorkspaceDir: wsDir}
|
||||
|
||||
// Source repo path
|
||||
repoPath := filepath.Join(s.codePath, "core", input.Repo)
|
||||
repoPath := core.JoinPath(s.codePath, "core", input.Repo)
|
||||
|
||||
// 1. Clone repo into src/ and create feature branch
|
||||
srcDir := filepath.Join(wsDir, "src")
|
||||
srcDir := core.JoinPath(wsDir, "src")
|
||||
cloneCmd := exec.CommandContext(ctx, "git", "clone", repoPath, srcDir)
|
||||
if err := cloneCmd.Run(); err != nil {
|
||||
return nil, PrepOutput{}, coreerr.E("prepWorkspace", "failed to clone repository", err)
|
||||
}
|
||||
|
||||
// Create feature branch
|
||||
taskSlug := strings.Map(func(r rune) rune {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' {
|
||||
return r
|
||||
taskBuilder := core.NewBuilder()
|
||||
for _, r := range input.Task {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-':
|
||||
taskBuilder.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
taskBuilder.WriteRune(r + 32)
|
||||
default:
|
||||
taskBuilder.WriteRune('-')
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return r + 32 // lowercase
|
||||
}
|
||||
return '-'
|
||||
}, input.Task)
|
||||
}
|
||||
taskSlug := taskBuilder.String()
|
||||
if len(taskSlug) > 40 {
|
||||
taskSlug = taskSlug[:40]
|
||||
}
|
||||
taskSlug = strings.Trim(taskSlug, "-")
|
||||
taskSlug = core.Trim(taskSlug)
|
||||
// Trim leading/trailing dashes
|
||||
for len(taskSlug) > 0 && taskSlug[0] == '-' {
|
||||
taskSlug = taskSlug[1:]
|
||||
}
|
||||
for len(taskSlug) > 0 && taskSlug[len(taskSlug)-1] == '-' {
|
||||
taskSlug = taskSlug[:len(taskSlug)-1]
|
||||
}
|
||||
if taskSlug != "" {
|
||||
branchName := fmt.Sprintf("agent/%s", taskSlug)
|
||||
branchName := core.Sprintf("agent/%s", taskSlug)
|
||||
branchCmd := exec.CommandContext(ctx, "git", "checkout", "-b", branchName)
|
||||
branchCmd.Dir = srcDir
|
||||
if err := branchCmd.Run(); err != nil {
|
||||
|
|
@ -239,29 +248,29 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
|||
}
|
||||
|
||||
// Create context dirs inside src/
|
||||
coreio.Local.EnsureDir(filepath.Join(srcDir, "kb"))
|
||||
coreio.Local.EnsureDir(filepath.Join(srcDir, "specs"))
|
||||
coreio.Local.EnsureDir(core.JoinPath(srcDir, "kb"))
|
||||
coreio.Local.EnsureDir(core.JoinPath(srcDir, "specs"))
|
||||
|
||||
// Remote stays as local clone origin — agent cannot push to forge.
|
||||
// Reviewer pulls changes from workspace and pushes after verification.
|
||||
|
||||
// 2. Copy CLAUDE.md and GEMINI.md to workspace
|
||||
claudeMdPath := filepath.Join(repoPath, "CLAUDE.md")
|
||||
claudeMdPath := core.JoinPath(repoPath, "CLAUDE.md")
|
||||
if data, err := coreio.Local.Read(claudeMdPath); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "CLAUDE.md"), data)
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "CLAUDE.md"), data)
|
||||
out.ClaudeMd = true
|
||||
}
|
||||
// Copy GEMINI.md from core/agent (ethics framework for all agents)
|
||||
agentGeminiMd := filepath.Join(s.codePath, "core", "agent", "GEMINI.md")
|
||||
agentGeminiMd := core.JoinPath(s.codePath, "core", "agent", "GEMINI.md")
|
||||
if data, err := coreio.Local.Read(agentGeminiMd); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "GEMINI.md"), data)
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "GEMINI.md"), data)
|
||||
}
|
||||
|
||||
// Copy persona if specified
|
||||
if persona != "" {
|
||||
personaPath := filepath.Join(s.codePath, "core", "agent", "prompts", "personas", persona+".md")
|
||||
personaPath := core.JoinPath(s.codePath, "core", "agent", "prompts", "personas", persona+".md")
|
||||
if data, err := coreio.Local.Read(personaPath); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "PERSONA.md"), data)
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "PERSONA.md"), data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,9 +278,9 @@ func (s *PrepSubsystem) prepWorkspace(ctx context.Context, _ *mcp.CallToolReques
|
|||
if input.Issue > 0 {
|
||||
s.generateTodo(ctx, input.Org, input.Repo, input.Issue, wsDir)
|
||||
} 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)
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), todo)
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "TODO.md"), todo)
|
||||
}
|
||||
|
||||
// 4. Generate CONTEXT.md from OpenBrain
|
||||
|
|
@ -368,7 +377,7 @@ Do NOT push. Commit only — a reviewer will verify and push.
|
|||
prompt = "Read TODO.md and complete the task. Work in src/.\n"
|
||||
}
|
||||
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "PROMPT.md"), prompt)
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "PROMPT.md"), prompt)
|
||||
}
|
||||
|
||||
// --- Plan template rendering ---
|
||||
|
|
@ -377,11 +386,11 @@ Do NOT push. Commit only — a reviewer will verify and push.
|
|||
// and writes PLAN.md into the workspace src/ directory.
|
||||
func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map[string]string, task string, wsDir string) {
|
||||
// Look for template in core/agent/prompts/templates/
|
||||
templatePath := filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
|
||||
templatePath := core.JoinPath(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yaml")
|
||||
content, err := coreio.Local.Read(templatePath)
|
||||
if err != nil {
|
||||
// Try .yml extension
|
||||
templatePath = filepath.Join(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
|
||||
templatePath = core.JoinPath(s.codePath, "core", "agent", "prompts", "templates", templateSlug+".yml")
|
||||
content, err = coreio.Local.Read(templatePath)
|
||||
if err != nil {
|
||||
return // Template not found, skip silently
|
||||
|
|
@ -390,8 +399,8 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
|
|||
|
||||
// Substitute variables ({{variable_name}} → value)
|
||||
for key, value := range variables {
|
||||
content = strings.ReplaceAll(content, "{{"+key+"}}", value)
|
||||
content = strings.ReplaceAll(content, "{{ "+key+" }}", value)
|
||||
content = core.Replace(content, "{{"+key+"}}", value)
|
||||
content = core.Replace(content, "{{ "+key+" }}", value)
|
||||
}
|
||||
|
||||
// Parse the YAML to render as markdown
|
||||
|
|
@ -411,42 +420,42 @@ func (s *PrepSubsystem) writePlanFromTemplate(templateSlug string, variables map
|
|||
}
|
||||
|
||||
// Render as PLAN.md
|
||||
var plan strings.Builder
|
||||
plan.WriteString("# Plan: " + tmpl.Name + "\n\n")
|
||||
planBuilder := core.NewBuilder()
|
||||
planBuilder.WriteString("# Plan: " + tmpl.Name + "\n\n")
|
||||
if task != "" {
|
||||
plan.WriteString("**Task:** " + task + "\n\n")
|
||||
planBuilder.WriteString("**Task:** " + task + "\n\n")
|
||||
}
|
||||
if tmpl.Description != "" {
|
||||
plan.WriteString(tmpl.Description + "\n\n")
|
||||
planBuilder.WriteString(tmpl.Description + "\n\n")
|
||||
}
|
||||
|
||||
if len(tmpl.Guidelines) > 0 {
|
||||
plan.WriteString("## Guidelines\n\n")
|
||||
for _, g := range tmpl.Guidelines {
|
||||
plan.WriteString("- " + g + "\n")
|
||||
planBuilder.WriteString("## Guidelines\n\n")
|
||||
for _, guideline := range tmpl.Guidelines {
|
||||
planBuilder.WriteString("- " + guideline + "\n")
|
||||
}
|
||||
plan.WriteString("\n")
|
||||
planBuilder.WriteString("\n")
|
||||
}
|
||||
|
||||
for i, phase := range tmpl.Phases {
|
||||
plan.WriteString(fmt.Sprintf("## Phase %d: %s\n\n", i+1, phase.Name))
|
||||
for phaseIndex, phase := range tmpl.Phases {
|
||||
planBuilder.WriteString(core.Sprintf("## Phase %d: %s\n\n", phaseIndex+1, phase.Name))
|
||||
if phase.Description != "" {
|
||||
plan.WriteString(phase.Description + "\n\n")
|
||||
planBuilder.WriteString(phase.Description + "\n\n")
|
||||
}
|
||||
for _, task := range phase.Tasks {
|
||||
switch t := task.(type) {
|
||||
for _, phaseTask := range phase.Tasks {
|
||||
switch taskValue := phaseTask.(type) {
|
||||
case string:
|
||||
plan.WriteString("- [ ] " + t + "\n")
|
||||
planBuilder.WriteString("- [ ] " + taskValue + "\n")
|
||||
case map[string]any:
|
||||
if name, ok := t["name"].(string); ok {
|
||||
plan.WriteString("- [ ] " + name + "\n")
|
||||
if name, ok := taskValue["name"].(string); ok {
|
||||
planBuilder.WriteString("- [ ] " + name + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
plan.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
|
||||
planBuilder.WriteString("\n**Commit after completing this phase.**\n\n---\n\n")
|
||||
}
|
||||
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "PLAN.md"), plan.String())
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "PLAN.md"), planBuilder.String())
|
||||
}
|
||||
|
||||
// --- Helpers (unchanged) ---
|
||||
|
|
@ -456,8 +465,8 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
|||
return 0
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
wikiURL := core.Sprintf("%s/api/v1/repos/%s/%s/wiki/pages", s.forgeURL, org, repo)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", wikiURL, nil)
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
|
|
@ -473,7 +482,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
|||
Title string `json:"title"`
|
||||
SubURL string `json:"sub_url"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&pages)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &pages)
|
||||
|
||||
count := 0
|
||||
for _, page := range pages {
|
||||
|
|
@ -482,7 +491,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
|||
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, _ := http.NewRequestWithContext(ctx, "GET", pageURL, nil)
|
||||
pageReq.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
|
|
@ -498,7 +507,7 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
|||
var pageData struct {
|
||||
ContentBase64 string `json:"content_base64"`
|
||||
}
|
||||
json.NewDecoder(pageResp.Body).Decode(&pageData)
|
||||
core.JSONUnmarshalString(readBody(pageResp.Body), &pageData)
|
||||
pageResp.Body.Close()
|
||||
|
||||
if pageData.ContentBase64 == "" {
|
||||
|
|
@ -506,14 +515,17 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
|||
}
|
||||
|
||||
content, _ := base64.StdEncoding.DecodeString(pageData.ContentBase64)
|
||||
filename := strings.Map(func(r rune) rune {
|
||||
fileBuilder := core.NewBuilder()
|
||||
for _, r := range page.Title {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
|
||||
return r
|
||||
fileBuilder.WriteRune(r)
|
||||
} else {
|
||||
fileBuilder.WriteRune('-')
|
||||
}
|
||||
return '-'
|
||||
}, page.Title) + ".md"
|
||||
}
|
||||
filename := fileBuilder.String() + ".md"
|
||||
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "kb", filename), string(content))
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "kb", filename), string(content))
|
||||
count++
|
||||
}
|
||||
|
||||
|
|
@ -522,17 +534,17 @@ func (s *PrepSubsystem) pullWiki(ctx context.Context, org, repo, wsDir string) i
|
|||
|
||||
func (s *PrepSubsystem) copySpecs(wsDir string) int {
|
||||
specFiles := []string{"AGENT_CONTEXT.md", "TASK_PROTOCOL.md"}
|
||||
count := 0
|
||||
specCount := 0
|
||||
|
||||
for _, file := range specFiles {
|
||||
src := filepath.Join(s.specsPath, file)
|
||||
if data, err := coreio.Local.Read(src); err == nil {
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "specs", file), data)
|
||||
count++
|
||||
sourcePath := core.JoinPath(s.specsPath, file)
|
||||
if data, err := coreio.Local.Read(sourcePath); err == nil {
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "specs", file), data)
|
||||
specCount++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
return specCount
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string) int {
|
||||
|
|
@ -540,14 +552,14 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
|
|||
return 0
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
body := core.JSONMarshalString(map[string]any{
|
||||
"query": "architecture conventions key interfaces for " + repo,
|
||||
"top_k": 10,
|
||||
"project": repo,
|
||||
"agent_id": "cladius",
|
||||
})
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", strings.NewReader(string(body)))
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", s.brainURL+"/v1/brain/recall", core.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.brainKey)
|
||||
|
|
@ -565,26 +577,26 @@ func (s *PrepSubsystem) generateContext(ctx context.Context, repo, wsDir string)
|
|||
var result struct {
|
||||
Memories []map[string]any `json:"memories"`
|
||||
}
|
||||
json.Unmarshal(respData, &result)
|
||||
core.JSONUnmarshalString(string(respData), &result)
|
||||
|
||||
var content strings.Builder
|
||||
content.WriteString("# Context — " + repo + "\n\n")
|
||||
content.WriteString("> Relevant knowledge from OpenBrain.\n\n")
|
||||
contextBuilder := core.NewBuilder()
|
||||
contextBuilder.WriteString("# Context — " + repo + "\n\n")
|
||||
contextBuilder.WriteString("> Relevant knowledge from OpenBrain.\n\n")
|
||||
|
||||
for i, mem := range result.Memories {
|
||||
for memIndex, mem := range result.Memories {
|
||||
memType, _ := mem["type"].(string)
|
||||
memContent, _ := mem["content"].(string)
|
||||
memProject, _ := mem["project"].(string)
|
||||
score, _ := mem["score"].(float64)
|
||||
content.WriteString(fmt.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", i+1, memProject, memType, score, memContent))
|
||||
memScore, _ := mem["score"].(float64)
|
||||
contextBuilder.WriteString(core.Sprintf("### %d. %s [%s] (score: %.3f)\n\n%s\n\n", memIndex+1, memProject, memType, memScore, memContent))
|
||||
}
|
||||
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "CONTEXT.md"), content.String())
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "CONTEXT.md"), contextBuilder.String())
|
||||
return len(result.Memories)
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
|
||||
goWorkPath := filepath.Join(s.codePath, "go.work")
|
||||
goWorkPath := core.JoinPath(s.codePath, "go.work")
|
||||
modulePath := "forge.lthn.ai/core/" + repo
|
||||
|
||||
workData, err := coreio.Local.Read(goWorkPath)
|
||||
|
|
@ -593,47 +605,47 @@ func (s *PrepSubsystem) findConsumers(repo, wsDir string) int {
|
|||
}
|
||||
|
||||
var consumers []string
|
||||
for _, line := range strings.Split(workData, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "./") {
|
||||
for _, line := range core.Split(workData, "\n") {
|
||||
line = core.Trim(line)
|
||||
if !core.HasPrefix(line, "./") {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(s.codePath, strings.TrimPrefix(line, "./"))
|
||||
goMod := filepath.Join(dir, "go.mod")
|
||||
dir := core.JoinPath(s.codePath, core.TrimPrefix(line, "./"))
|
||||
goMod := core.JoinPath(dir, "go.mod")
|
||||
modData, err := coreio.Local.Read(goMod)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(modData, modulePath) && !strings.HasPrefix(modData, "module "+modulePath) {
|
||||
consumers = append(consumers, filepath.Base(dir))
|
||||
if core.Contains(modData, modulePath) && !core.HasPrefix(modData, "module "+modulePath) {
|
||||
consumers = append(consumers, core.PathBase(dir))
|
||||
}
|
||||
}
|
||||
|
||||
if len(consumers) > 0 {
|
||||
content := "# Consumers of " + repo + "\n\n"
|
||||
content += "These modules import `" + modulePath + "`:\n\n"
|
||||
for _, c := range consumers {
|
||||
content += "- " + c + "\n"
|
||||
consumersContent := "# Consumers of " + repo + "\n\n"
|
||||
consumersContent += "These modules import `" + modulePath + "`:\n\n"
|
||||
for _, consumer := range consumers {
|
||||
consumersContent += "- " + consumer + "\n"
|
||||
}
|
||||
content += fmt.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "CONSUMERS.md"), content)
|
||||
consumersContent += core.Sprintf("\n**Breaking change risk: %d consumers.**\n", len(consumers))
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "CONSUMERS.md"), consumersContent)
|
||||
}
|
||||
|
||||
return len(consumers)
|
||||
}
|
||||
|
||||
func (s *PrepSubsystem) gitLog(repoPath, wsDir string) int {
|
||||
cmd := exec.Command("git", "log", "--oneline", "-20")
|
||||
cmd.Dir = repoPath
|
||||
output, err := cmd.Output()
|
||||
gitCmd := exec.Command("git", "log", "--oneline", "-20")
|
||||
gitCmd.Dir = repoPath
|
||||
output, err := gitCmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
lines := core.Split(core.Trim(string(output)), "\n")
|
||||
if len(lines) > 0 && lines[0] != "" {
|
||||
content := "# Recent Changes\n\n```\n" + string(output) + "```\n"
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "RECENT.md"), content)
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "RECENT.md"), content)
|
||||
}
|
||||
|
||||
return len(lines)
|
||||
|
|
@ -644,8 +656,8 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
|
|||
return
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
issueURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", s.forgeURL, org, repo, issue)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", issueURL, nil)
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
|
|
@ -661,13 +673,23 @@ func (s *PrepSubsystem) generateTodo(ctx context.Context, org, repo string, issu
|
|||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&issueData)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &issueData)
|
||||
|
||||
content := fmt.Sprintf("# TASK: %s\n\n", issueData.Title)
|
||||
content += fmt.Sprintf("**Status:** ready\n")
|
||||
content += fmt.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 += "## Objective\n\n" + issueData.Body + "\n"
|
||||
todoContent := core.Sprintf("# TASK: %s\n\n", issueData.Title)
|
||||
todoContent += "**Status:** ready\n"
|
||||
todoContent += core.Sprintf("**Source:** %s/%s/%s/issues/%d\n", s.forgeURL, org, repo, issue)
|
||||
todoContent += core.Sprintf("**Repo:** %s/%s\n\n---\n\n", org, repo)
|
||||
todoContent += "## Objective\n\n" + issueData.Body + "\n"
|
||||
|
||||
coreio.Local.Write(filepath.Join(wsDir, "src", "TODO.md"), content)
|
||||
coreio.Local.Write(core.JoinPath(wsDir, "src", "TODO.md"), todoContent)
|
||||
}
|
||||
|
||||
// readBody reads an HTTP response body to a string and closes it.
|
||||
//
|
||||
// body := readBody(resp.Body)
|
||||
// core.JSONUnmarshalString(body, &result)
|
||||
func readBody(body goio.ReadCloser) string {
|
||||
data, _ := goio.ReadAll(body)
|
||||
body.Close()
|
||||
return string(data)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,3 +94,36 @@ func TestPrepWorkspace_Bad_BadPlanTemplateTraversal(t *testing.T) {
|
|||
t.Fatalf("expected plan template error, got %q", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeRepoPathSegment_Ugly(t *testing.T) {
|
||||
// Empty value is allowed (returns "", nil) — callers validate presence separately
|
||||
value, err := sanitizeRepoPathSegment("", "repo", false)
|
||||
if err != nil {
|
||||
t.Errorf("expected nil error for empty value, got %v", err)
|
||||
}
|
||||
if value != "" {
|
||||
t.Errorf("expected empty string, got %q", value)
|
||||
}
|
||||
|
||||
// Null bytes are rejected as invalid characters
|
||||
_, err = sanitizeRepoPathSegment("repo\x00name", "repo", false)
|
||||
if err == nil {
|
||||
t.Error("expected error for null byte in value, got nil")
|
||||
}
|
||||
|
||||
// Leading whitespace is rejected
|
||||
_, err = sanitizeRepoPathSegment(" repo", "repo", false)
|
||||
if err == nil {
|
||||
t.Error("expected error for leading whitespace, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepWorkspace_Ugly(t *testing.T) {
|
||||
// Empty codePath still validates inputs before hitting the filesystem
|
||||
s := &PrepSubsystem{codePath: ""}
|
||||
|
||||
_, _, err := s.prepWorkspace(context.Background(), nil, PrepInput{Repo: ""})
|
||||
if err == nil {
|
||||
t.Error("expected error for empty repo with empty codePath, got nil")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -41,21 +39,24 @@ type AgentsConfig struct {
|
|||
}
|
||||
|
||||
// loadAgentsConfig reads config/agents.yaml from the code path.
|
||||
//
|
||||
// agentsConfig := s.loadAgentsConfig()
|
||||
// limit := agentsConfig.Concurrency["claude"] // 1
|
||||
func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
|
||||
paths := []string{
|
||||
filepath.Join(s.codePath, ".core", "agents.yaml"),
|
||||
core.JoinPath(s.codePath, ".core", "agents.yaml"),
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
data, err := coreio.Local.Read(path)
|
||||
for _, configPath := range paths {
|
||||
data, err := coreio.Local.Read(configPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cfg AgentsConfig
|
||||
if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
|
||||
var configuration AgentsConfig
|
||||
if err := yaml.Unmarshal([]byte(data), &configuration); err != nil {
|
||||
continue
|
||||
}
|
||||
return &cfg
|
||||
return &configuration
|
||||
}
|
||||
|
||||
return &AgentsConfig{
|
||||
|
|
@ -73,15 +74,15 @@ func (s *PrepSubsystem) loadAgentsConfig() *AgentsConfig {
|
|||
// delayForAgent calculates how long to wait before spawning the next task
|
||||
// for a given agent type, based on rate config and time of day.
|
||||
func (s *PrepSubsystem) delayForAgent(agent string) time.Duration {
|
||||
cfg := s.loadAgentsConfig()
|
||||
rate, ok := cfg.Rates[agent]
|
||||
agentsConfig := s.loadAgentsConfig()
|
||||
rate, ok := agentsConfig.Rates[agent]
|
||||
if !ok || rate.SustainedDelay == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Parse reset time
|
||||
// Parse reset time (e.g. "06:00")
|
||||
resetHour, resetMin := 6, 0
|
||||
fmt.Sscanf(rate.ResetUTC, "%d:%d", &resetHour, &resetMin)
|
||||
parseResetTime(rate.ResetUTC, &resetHour, &resetMin)
|
||||
|
||||
now := time.Now().UTC()
|
||||
resetToday := time.Date(now.Year(), now.Month(), now.Day(), resetHour, resetMin, 0, 0, time.UTC)
|
||||
|
|
@ -103,6 +104,9 @@ func (s *PrepSubsystem) delayForAgent(agent string) time.Duration {
|
|||
|
||||
// listWorkspaceDirs returns all workspace directories, including those
|
||||
// nested one level deep (e.g. workspace/core/go-io-123/).
|
||||
//
|
||||
// dirs := s.listWorkspaceDirs()
|
||||
// // dirs == ["/home/user/.core/workspace/go-io-123", ...]
|
||||
func (s *PrepSubsystem) listWorkspaceDirs() []string {
|
||||
wsRoot := s.workspaceRoot()
|
||||
entries, err := coreio.Local.List(wsRoot)
|
||||
|
|
@ -115,21 +119,21 @@ func (s *PrepSubsystem) listWorkspaceDirs() []string {
|
|||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(wsRoot, entry.Name())
|
||||
entryPath := core.JoinPath(wsRoot, entry.Name())
|
||||
// Check if this dir has a status.json (it's a workspace)
|
||||
if coreio.Local.IsFile(filepath.Join(path, "status.json")) {
|
||||
dirs = append(dirs, path)
|
||||
if coreio.Local.IsFile(core.JoinPath(entryPath, "status.json")) {
|
||||
dirs = append(dirs, entryPath)
|
||||
continue
|
||||
}
|
||||
// Otherwise check one level deeper (org subdirectory)
|
||||
subEntries, err := coreio.Local.List(path)
|
||||
subEntries, err := coreio.Local.List(entryPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, sub := range subEntries {
|
||||
if sub.IsDir() {
|
||||
subPath := filepath.Join(path, sub.Name())
|
||||
if coreio.Local.IsFile(filepath.Join(subPath, "status.json")) {
|
||||
subPath := core.JoinPath(entryPath, sub.Name())
|
||||
if coreio.Local.IsFile(core.JoinPath(subPath, "status.json")) {
|
||||
dirs = append(dirs, subPath)
|
||||
}
|
||||
}
|
||||
|
|
@ -146,7 +150,7 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
|
|||
if err != nil || st.Status != "running" {
|
||||
continue
|
||||
}
|
||||
stBase := strings.SplitN(st.Agent, ":", 2)[0]
|
||||
stBase := core.SplitN(st.Agent, ":", 2)[0]
|
||||
if stBase != agent {
|
||||
continue
|
||||
}
|
||||
|
|
@ -161,15 +165,18 @@ func (s *PrepSubsystem) countRunningByAgent(agent string) int {
|
|||
}
|
||||
|
||||
// baseAgent strips the model variant (gemini:flash → gemini).
|
||||
//
|
||||
// baseAgent("gemini:flash") == "gemini"
|
||||
// baseAgent("claude") == "claude"
|
||||
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.
|
||||
func (s *PrepSubsystem) canDispatchAgent(agent string) bool {
|
||||
cfg := s.loadAgentsConfig()
|
||||
agentsConfig := s.loadAgentsConfig()
|
||||
base := baseAgent(agent)
|
||||
limit, ok := cfg.Concurrency[base]
|
||||
limit, ok := agentsConfig.Concurrency[base]
|
||||
if !ok || limit <= 0 {
|
||||
return true
|
||||
}
|
||||
|
|
@ -205,7 +212,7 @@ func (s *PrepSubsystem) drainQueue() {
|
|||
continue
|
||||
}
|
||||
|
||||
srcDir := filepath.Join(wsDir, "src")
|
||||
srcDir := core.JoinPath(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."
|
||||
|
||||
command, args, err := agentCommand(st.Agent, prompt)
|
||||
|
|
@ -213,7 +220,7 @@ func (s *PrepSubsystem) drainQueue() {
|
|||
continue
|
||||
}
|
||||
|
||||
outputFile := filepath.Join(wsDir, fmt.Sprintf("agent-%s.log", st.Agent))
|
||||
outputFile := core.JoinPath(wsDir, core.Sprintf("agent-%s.log", st.Agent))
|
||||
outFile, err := os.Create(outputFile)
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
@ -264,3 +271,28 @@ func (s *PrepSubsystem) drainQueue() {
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
// parseResetTime parses "HH:MM" into hour and minute integers.
|
||||
// On invalid input the defaults are unchanged.
|
||||
//
|
||||
// parseResetTime("06:30", &h, &m) // h=6, m=30
|
||||
func parseResetTime(value string, hour, minute *int) {
|
||||
parts := core.SplitN(value, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
type hm struct {
|
||||
H int `json:"h"`
|
||||
M int `json:"m"`
|
||||
}
|
||||
var target hm
|
||||
result := core.JSONUnmarshalString(
|
||||
core.Sprintf(`{"h":%s,"m":%s}`, core.Trim(parts[0]), core.Trim(parts[1])),
|
||||
&target,
|
||||
)
|
||||
if result.OK {
|
||||
*hour = target.H
|
||||
*minute = target.M
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ package agentic
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -45,8 +44,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
return nil, ResumeOutput{}, coreerr.E("resume", "workspace is required", nil)
|
||||
}
|
||||
|
||||
wsDir := filepath.Join(s.workspaceRoot(), input.Workspace)
|
||||
srcDir := filepath.Join(wsDir, "src")
|
||||
wsDir := core.JoinPath(s.workspaceRoot(), input.Workspace)
|
||||
srcDir := core.JoinPath(wsDir, "src")
|
||||
|
||||
// Verify workspace exists
|
||||
if _, err := coreio.Local.List(srcDir); err != nil {
|
||||
|
|
@ -71,8 +70,8 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
|
||||
// Write ANSWER.md if answer provided
|
||||
if input.Answer != "" {
|
||||
answerPath := filepath.Join(srcDir, "ANSWER.md")
|
||||
content := fmt.Sprintf("# Answer\n\n%s\n", input.Answer)
|
||||
answerPath := core.JoinPath(srcDir, "ANSWER.md")
|
||||
content := core.Sprintf("# Answer\n\n%s\n", input.Answer)
|
||||
if err := coreio.Local.Write(answerPath, content); err != nil {
|
||||
return nil, ResumeOutput{}, coreerr.E("resume", "failed to write ANSWER.md", err)
|
||||
}
|
||||
|
|
@ -95,7 +94,7 @@ func (s *PrepSubsystem) resume(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
}
|
||||
|
||||
// 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.JoinPath(wsDir, core.Sprintf("agent-%s-run%d.log", agent, st.Runs+1))
|
||||
|
||||
command, args, err := agentCommand(agent, prompt)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,9 @@ package agentic
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -81,7 +79,7 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input
|
|||
seen := make(map[string]bool)
|
||||
var unique []ScanIssue
|
||||
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] {
|
||||
seen[key] = true
|
||||
unique = append(unique, issue)
|
||||
|
|
@ -100,8 +98,8 @@ func (s *PrepSubsystem) scan(ctx context.Context, _ *mcp.CallToolRequest, input
|
|||
}
|
||||
|
||||
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)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
orgReposURL := core.Sprintf("%s/api/v1/orgs/%s/repos?limit=50", s.forgeURL, org)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", orgReposURL, nil)
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
|
|
@ -110,13 +108,14 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, coreerr.E("listOrgRepos", fmt.Sprintf("HTTP %d listing repos", resp.StatusCode), nil)
|
||||
resp.Body.Close()
|
||||
return nil, coreerr.E("listOrgRepos", core.Sprintf("HTTP %d listing repos", resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
var repos []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&repos)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &repos)
|
||||
|
||||
var names []string
|
||||
for _, r := range repos {
|
||||
|
|
@ -126,9 +125,9 @@ func (s *PrepSubsystem) listOrgRepos(ctx context.Context, org string) ([]string,
|
|||
}
|
||||
|
||||
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",
|
||||
repoIssuesURL := core.Sprintf("%s/api/v1/repos/%s/%s/issues?state=open&labels=%s&limit=10&type=issues",
|
||||
s.forgeURL, org, repo, label)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", repoIssuesURL, nil)
|
||||
req.Header.Set("Authorization", "token "+s.forgeToken)
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
|
|
@ -137,7 +136,8 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
|
|||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, coreerr.E("listRepoIssues", fmt.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
|
||||
resp.Body.Close()
|
||||
return nil, coreerr.E("listRepoIssues", core.Sprintf("HTTP %d for "+repo, resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
var issues []struct {
|
||||
|
|
@ -151,7 +151,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
|
|||
} `json:"assignee"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&issues)
|
||||
core.JSONUnmarshalString(readBody(resp.Body), &issues)
|
||||
|
||||
var result []ScanIssue
|
||||
for _, issue := range issues {
|
||||
|
|
@ -170,7 +170,7 @@ func (s *PrepSubsystem) listRepoIssues(ctx context.Context, org, repo, label str
|
|||
Title: issue.Title,
|
||||
Labels: labels,
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ package agentic
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -44,25 +42,29 @@ type WorkspaceStatus struct {
|
|||
PRURL string `json:"pr_url,omitempty"` // pull request URL (after PR created)
|
||||
}
|
||||
|
||||
// writeStatus serialises workspace status to status.json.
|
||||
//
|
||||
// writeStatus(wsDir, &WorkspaceStatus{Status: "running", Agent: "claude"})
|
||||
func writeStatus(wsDir string, status *WorkspaceStatus) error {
|
||||
status.UpdatedAt = time.Now()
|
||||
data, err := json.MarshalIndent(status, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return coreio.Local.Write(filepath.Join(wsDir, "status.json"), string(data))
|
||||
return coreio.Local.Write(core.JoinPath(wsDir, "status.json"), core.JSONMarshalString(status))
|
||||
}
|
||||
|
||||
// readStatus deserialises workspace status from status.json.
|
||||
//
|
||||
// st, err := readStatus(wsDir)
|
||||
// // st.Status == "running", st.Agent == "claude"
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
var s WorkspaceStatus
|
||||
if err := json.Unmarshal([]byte(data), &s); err != nil {
|
||||
return nil, err
|
||||
var workspaceStatus WorkspaceStatus
|
||||
result := core.JSONUnmarshalString(data, &workspaceStatus)
|
||||
if !result.OK {
|
||||
return nil, coreerr.E("readStatus", "failed to parse status.json", nil)
|
||||
}
|
||||
return &s, nil
|
||||
return &workspaceStatus, nil
|
||||
}
|
||||
|
||||
// --- agentic_status tool ---
|
||||
|
|
@ -103,60 +105,60 @@ func (s *PrepSubsystem) status(ctx context.Context, _ *mcp.CallToolRequest, inpu
|
|||
var workspaces []WorkspaceInfo
|
||||
|
||||
for _, wsDir := range wsDirs {
|
||||
name := filepath.Base(wsDir)
|
||||
workspaceName := core.PathBase(wsDir)
|
||||
|
||||
// Filter by specific workspace if requested
|
||||
if input.Workspace != "" && name != input.Workspace {
|
||||
if input.Workspace != "" && workspaceName != input.Workspace {
|
||||
continue
|
||||
}
|
||||
|
||||
info := WorkspaceInfo{Name: name}
|
||||
info := WorkspaceInfo{Name: workspaceName}
|
||||
|
||||
// Try reading status.json
|
||||
st, err := readStatus(wsDir)
|
||||
statusRecord, err := readStatus(wsDir)
|
||||
if err != nil {
|
||||
// Legacy workspace (no status.json) — check for log file
|
||||
logFiles, _ := filepath.Glob(filepath.Join(wsDir, "agent-*.log"))
|
||||
logFiles := core.PathGlob(core.JoinPath(wsDir, "agent-*.log"))
|
||||
if len(logFiles) > 0 {
|
||||
info.Status = "completed"
|
||||
} else {
|
||||
info.Status = "unknown"
|
||||
}
|
||||
if fi, err := os.Stat(wsDir); err == nil {
|
||||
info.Age = time.Since(fi.ModTime()).Truncate(time.Minute).String()
|
||||
if fileInfo, statErr := os.Stat(wsDir); statErr == nil {
|
||||
info.Age = time.Since(fileInfo.ModTime()).Truncate(time.Minute).String()
|
||||
}
|
||||
workspaces = append(workspaces, info)
|
||||
continue
|
||||
}
|
||||
|
||||
info.Status = st.Status
|
||||
info.Agent = st.Agent
|
||||
info.Repo = st.Repo
|
||||
info.Task = st.Task
|
||||
info.Runs = st.Runs
|
||||
info.Age = time.Since(st.StartedAt).Truncate(time.Minute).String()
|
||||
info.Status = statusRecord.Status
|
||||
info.Agent = statusRecord.Agent
|
||||
info.Repo = statusRecord.Repo
|
||||
info.Task = statusRecord.Task
|
||||
info.Runs = statusRecord.Runs
|
||||
info.Age = time.Since(statusRecord.StartedAt).Truncate(time.Minute).String()
|
||||
|
||||
// If status is "running", check if PID is still alive
|
||||
if st.Status == "running" && st.PID > 0 {
|
||||
proc, err := os.FindProcess(st.PID)
|
||||
if statusRecord.Status == "running" && statusRecord.PID > 0 {
|
||||
proc, err := os.FindProcess(statusRecord.PID)
|
||||
if err != nil || proc.Signal(nil) != nil {
|
||||
// Process died — check for BLOCKED.md
|
||||
blockedPath := filepath.Join(wsDir, "src", "BLOCKED.md")
|
||||
blockedPath := core.JoinPath(wsDir, "src", "BLOCKED.md")
|
||||
if data, err := coreio.Local.Read(blockedPath); err == nil {
|
||||
info.Status = "blocked"
|
||||
info.Question = strings.TrimSpace(data)
|
||||
st.Status = "blocked"
|
||||
st.Question = info.Question
|
||||
info.Question = core.Trim(data)
|
||||
statusRecord.Status = "blocked"
|
||||
statusRecord.Question = info.Question
|
||||
} else {
|
||||
info.Status = "completed"
|
||||
st.Status = "completed"
|
||||
statusRecord.Status = "completed"
|
||||
}
|
||||
writeStatus(wsDir, st)
|
||||
writeStatus(wsDir, statusRecord)
|
||||
}
|
||||
}
|
||||
|
||||
if st.Status == "blocked" {
|
||||
info.Question = st.Question
|
||||
if statusRecord.Status == "blocked" {
|
||||
info.Question = statusRecord.Question
|
||||
}
|
||||
|
||||
workspaces = append(workspaces, info)
|
||||
|
|
|
|||
|
|
@ -5,14 +5,11 @@ package brain
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
goio "io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
|
@ -44,16 +41,20 @@ func (s *DirectSubsystem) OnChannel(fn func(ctx context.Context, channel string,
|
|||
// NewDirect creates a brain subsystem that calls the OpenBrain API directly.
|
||||
// Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back
|
||||
// to ~/.claude/brain.key for the API key.
|
||||
//
|
||||
// sub := brain.NewDirect()
|
||||
// svc, _ := mcp.New(mcp.Options{Subsystems: []mcp.Subsystem{sub}})
|
||||
func NewDirect() *DirectSubsystem {
|
||||
apiURL := os.Getenv("CORE_BRAIN_URL")
|
||||
apiURL := core.Env("CORE_BRAIN_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = "https://api.lthn.sh"
|
||||
}
|
||||
|
||||
apiKey := os.Getenv("CORE_BRAIN_KEY")
|
||||
apiKey := core.Env("CORE_BRAIN_KEY")
|
||||
if apiKey == "" {
|
||||
if data, err := coreio.Local.Read(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil {
|
||||
apiKey = strings.TrimSpace(data)
|
||||
keyPath := core.JoinPath(core.Env("HOME"), ".claude", "brain.key")
|
||||
if data, err := coreio.Local.Read(keyPath); err == nil {
|
||||
apiKey = core.Trim(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,11 +96,7 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
|
|||
|
||||
var reqBody goio.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, coreerr.E("brain.apiCall", "marshal request", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(data)
|
||||
reqBody = bytes.NewReader([]byte(core.JSONMarshalString(body)))
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody)
|
||||
|
|
@ -126,8 +123,9 @@ func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body
|
|||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(respData, &result); err != nil {
|
||||
return nil, coreerr.E("brain.apiCall", "parse response", err)
|
||||
r := core.JSONUnmarshalString(string(respData), &result)
|
||||
if !r.OK {
|
||||
return nil, coreerr.E("brain.apiCall", "parse response", nil)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
|
@ -186,11 +184,11 @@ func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, in
|
|||
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"]),
|
||||
Content: core.Sprintf("%v", mm["content"]),
|
||||
Type: core.Sprintf("%v", mm["type"]),
|
||||
Project: core.Sprintf("%v", mm["project"]),
|
||||
AgentID: core.Sprintf("%v", mm["agent_id"]),
|
||||
CreatedAt: core.Sprintf("%v", mm["created_at"]),
|
||||
}
|
||||
if id, ok := mm["id"].(string); ok {
|
||||
mem.ID = id
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ type BridgeMessage struct {
|
|||
// Bridge maintains a WebSocket connection to the Laravel core-agentic
|
||||
// backend and forwards responses to a local ws.Hub.
|
||||
type Bridge struct {
|
||||
cfg Config
|
||||
hub *ws.Hub
|
||||
config Config
|
||||
hub *ws.Hub
|
||||
conn *websocket.Conn
|
||||
|
||||
mu sync.Mutex
|
||||
|
|
@ -35,8 +35,10 @@ type Bridge struct {
|
|||
|
||||
// NewBridge creates a bridge that will connect to the Laravel backend and
|
||||
// forward incoming messages to the provided ws.Hub channels.
|
||||
func NewBridge(hub *ws.Hub, cfg Config) *Bridge {
|
||||
return &Bridge{cfg: cfg, hub: hub}
|
||||
//
|
||||
// bridge := ide.NewBridge(hub, ide.DefaultConfig())
|
||||
func NewBridge(hub *ws.Hub, configuration Config) *Bridge {
|
||||
return &Bridge{config: configuration, hub: hub}
|
||||
}
|
||||
|
||||
// Start begins the connection loop in a background goroutine.
|
||||
|
|
@ -81,7 +83,7 @@ func (b *Bridge) Send(msg BridgeMessage) error {
|
|||
|
||||
// connectLoop reconnects to Laravel with exponential backoff.
|
||||
func (b *Bridge) connectLoop(ctx context.Context) {
|
||||
delay := b.cfg.ReconnectInterval
|
||||
delay := b.config.ReconnectInterval
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
|
@ -96,12 +98,12 @@ func (b *Bridge) connectLoop(ctx context.Context) {
|
|||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
delay = min(delay*2, b.cfg.MaxReconnectInterval)
|
||||
delay = min(delay*2, b.config.MaxReconnectInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset backoff on successful connection
|
||||
delay = b.cfg.ReconnectInterval
|
||||
delay = b.config.ReconnectInterval
|
||||
b.readLoop(ctx)
|
||||
}
|
||||
}
|
||||
|
|
@ -112,12 +114,12 @@ func (b *Bridge) dial(ctx context.Context) error {
|
|||
}
|
||||
|
||||
var header http.Header
|
||||
if b.cfg.Token != "" {
|
||||
if b.config.Token != "" {
|
||||
header = http.Header{}
|
||||
header.Set("Authorization", "Bearer "+b.cfg.Token)
|
||||
header.Set("Authorization", "Bearer "+b.config.Token)
|
||||
}
|
||||
|
||||
conn, _, err := dialer.DialContext(ctx, b.cfg.LaravelWSURL, header)
|
||||
conn, _, err := dialer.DialContext(ctx, b.config.LaravelWSURL, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -127,7 +129,7 @@ func (b *Bridge) dial(ctx context.Context) error {
|
|||
b.connected = true
|
||||
b.mu.Unlock()
|
||||
|
||||
coreerr.Info("ide bridge: connected", "url", b.cfg.LaravelWSURL)
|
||||
coreerr.Info("ide bridge: connected", "url", b.config.LaravelWSURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,23 +14,25 @@ var errBridgeNotAvailable = coreerr.E("ide", "bridge not available", nil)
|
|||
|
||||
// Subsystem implements mcp.Subsystem and mcp.SubsystemWithShutdown for the IDE.
|
||||
type Subsystem struct {
|
||||
cfg Config
|
||||
config Config
|
||||
bridge *Bridge
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// New creates an IDE subsystem. The ws.Hub is used for real-time forwarding;
|
||||
// pass nil if headless (tools still work but real-time streaming is disabled).
|
||||
//
|
||||
// sub := ide.New(hub, ide.WithToken("sk-abc"))
|
||||
func New(hub *ws.Hub, opts ...Option) *Subsystem {
|
||||
cfg := DefaultConfig()
|
||||
configuration := DefaultConfig()
|
||||
for _, opt := range opts {
|
||||
opt(&cfg)
|
||||
opt(&configuration)
|
||||
}
|
||||
var bridge *Bridge
|
||||
if hub != nil {
|
||||
bridge = NewBridge(hub, cfg)
|
||||
bridge = NewBridge(hub, configuration)
|
||||
}
|
||||
return &Subsystem{cfg: cfg, bridge: bridge, hub: hub}
|
||||
return &Subsystem{config: configuration, bridge: bridge, hub: hub}
|
||||
}
|
||||
|
||||
// Name implements mcp.Subsystem.
|
||||
|
|
|
|||
|
|
@ -716,17 +716,17 @@ func TestSubsystem_Good_WithOptions(t *testing.T) {
|
|||
WithToken("secret-123"),
|
||||
)
|
||||
|
||||
if sub.cfg.LaravelWSURL != "ws://custom:1234/ws" {
|
||||
t.Errorf("expected custom URL, got %q", sub.cfg.LaravelWSURL)
|
||||
if sub.config.LaravelWSURL != "ws://custom:1234/ws" {
|
||||
t.Errorf("expected custom URL, got %q", sub.config.LaravelWSURL)
|
||||
}
|
||||
if sub.cfg.WorkspaceRoot != "/tmp/test" {
|
||||
t.Errorf("expected workspace '/tmp/test', got %q", sub.cfg.WorkspaceRoot)
|
||||
if sub.config.WorkspaceRoot != "/tmp/test" {
|
||||
t.Errorf("expected workspace '/tmp/test', got %q", sub.config.WorkspaceRoot)
|
||||
}
|
||||
if sub.cfg.ReconnectInterval != 5*time.Second {
|
||||
t.Errorf("expected 5s reconnect interval, got %v", sub.cfg.ReconnectInterval)
|
||||
if sub.config.ReconnectInterval != 5*time.Second {
|
||||
t.Errorf("expected 5s reconnect interval, got %v", sub.config.ReconnectInterval)
|
||||
}
|
||||
if sub.cfg.Token != "secret-123" {
|
||||
t.Errorf("expected token 'secret-123', got %q", sub.cfg.Token)
|
||||
if sub.config.Token != "secret-123" {
|
||||
t.Errorf("expected token 'secret-123', got %q", sub.config.Token)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@ import (
|
|||
"crypto/subtle"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -37,7 +36,7 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error {
|
|||
addr = DefaultHTTPAddr
|
||||
}
|
||||
|
||||
authToken := os.Getenv("MCP_AUTH_TOKEN")
|
||||
authToken := core.Env("MCP_AUTH_TOKEN")
|
||||
|
||||
handler := mcp.NewStreamableHTTPHandler(
|
||||
func(r *http.Request) *mcp.Server {
|
||||
|
|
@ -85,19 +84,19 @@ func (s *Service) ServeHTTP(ctx context.Context, addr string) error {
|
|||
// If token is empty, requests are rejected.
|
||||
func withAuth(token string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
if core.Trim(token) == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer`)
|
||||
http.Error(w, `{"error":"authentication not configured"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") {
|
||||
if !core.HasPrefix(auth, "Bearer ") {
|
||||
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
provided := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||
provided := core.Trim(core.TrimPrefix(auth, "Bearer "))
|
||||
if len(provided) == 0 {
|
||||
http.Error(w, `{"error":"missing Bearer token"}`, http.StatusUnauthorized)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ package mcp
|
|||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
goio "io"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -29,7 +29,7 @@ var diagWriter goio.Writer = os.Stderr
|
|||
func diagPrintf(format string, args ...any) {
|
||||
diagMu.Lock()
|
||||
defer diagMu.Unlock()
|
||||
fmt.Fprintf(diagWriter, format, args...)
|
||||
diagWriter.Write([]byte(core.Sprintf(format, args...))) //nolint:errcheck
|
||||
}
|
||||
|
||||
// setDiagWriter swaps the diagnostic writer and returns the previous one.
|
||||
|
|
|
|||
|
|
@ -10,147 +10,137 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
func TestNewTCPTransport_Defaults(t *testing.T) {
|
||||
// Test that empty string gets replaced with default address constant
|
||||
// Note: We can't actually bind to 9100 as it may be in use,
|
||||
// so we verify the address is set correctly before Listen is called
|
||||
func TestTransportTcp_NewTCPTransport_Good(t *testing.T) {
|
||||
// Default constant is correctly set
|
||||
if DefaultTCPAddr != "127.0.0.1:9100" {
|
||||
t.Errorf("Expected default constant 127.0.0.1:9100, got %s", DefaultTCPAddr)
|
||||
t.Errorf("expected default constant 127.0.0.1:9100, got %s", DefaultTCPAddr)
|
||||
}
|
||||
|
||||
// Test with a dynamic port to verify transport creation works
|
||||
// Create transport with dynamic port
|
||||
tr, err := NewTCPTransport("127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create transport with dynamic port: %v", err)
|
||||
t.Fatalf("failed to create transport with dynamic port: %v", err)
|
||||
}
|
||||
defer tr.listener.Close()
|
||||
|
||||
// Verify we got a valid address
|
||||
if tr.addr != "127.0.0.1:0" {
|
||||
t.Errorf("Expected address to be set, got %s", tr.addr)
|
||||
t.Errorf("expected address to be set, got %s", tr.addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTCPTransport_Warning(t *testing.T) {
|
||||
// Capture warning output via setDiagWriter (mutex-protected, no race).
|
||||
func TestTransportTcp_NewTCPTransport_Bad(t *testing.T) {
|
||||
// Binding to an already-in-use port returns an error
|
||||
tr, err := NewTCPTransport("127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("first bind failed unexpectedly: %v", err)
|
||||
}
|
||||
defer tr.listener.Close()
|
||||
|
||||
addr := tr.listener.Addr().String()
|
||||
_, err = NewTCPTransport(addr)
|
||||
if err == nil {
|
||||
t.Error("expected error when binding to already-in-use port, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportTcp_NewTCPTransport_Ugly(t *testing.T) {
|
||||
// Empty host defaults to 127.0.0.1 — never binds to 0.0.0.0
|
||||
var buf bytes.Buffer
|
||||
old := setDiagWriter(&buf)
|
||||
defer setDiagWriter(old)
|
||||
|
||||
// Trigger warning — use port 0 (OS assigns free port)
|
||||
tr, err := NewTCPTransport("0.0.0.0:0")
|
||||
tr, err := NewTCPTransport(":0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create transport: %v", err)
|
||||
t.Fatalf("failed to create transport with empty host: %v", err)
|
||||
}
|
||||
defer tr.listener.Close()
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "WARNING") {
|
||||
t.Error("Expected warning for binding to 0.0.0.0, but didn't find it in stderr")
|
||||
// Should NOT have emitted a warning for 0.0.0.0
|
||||
if strings.Contains(buf.String(), "WARNING") {
|
||||
t.Error("unexpected warning for :0 (should default to 127.0.0.1, not 0.0.0.0)")
|
||||
}
|
||||
|
||||
// The bound address must be on 127.0.0.1, not 0.0.0.0
|
||||
host, _, _ := net.SplitHostPort(tr.listener.Addr().String())
|
||||
if host != "127.0.0.1" {
|
||||
t.Errorf("expected 127.0.0.1, got %s", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeTCP_Connection(t *testing.T) {
|
||||
func TestTransportTcp_ServeTCP_Good(t *testing.T) {
|
||||
s, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create service: %v", err)
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Use a random port for testing to avoid collisions
|
||||
addr := "127.0.0.1:0"
|
||||
|
||||
// Create transport first to get the actual address if we use :0
|
||||
tr, err := NewTCPTransport(addr)
|
||||
tr, err := NewTCPTransport("127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create transport: %v", err)
|
||||
}
|
||||
actualAddr := tr.listener.Addr().String()
|
||||
tr.listener.Close() // Close it so ServeTCP can re-open it or use the same address
|
||||
|
||||
// Start server in background
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- s.ServeTCP(ctx, actualAddr)
|
||||
}()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Connect to the server
|
||||
conn, err := net.Dial("tcp", actualAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to server: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Verify we can write to it
|
||||
_, err = conn.Write([]byte("{}\n"))
|
||||
if err != nil {
|
||||
t.Errorf("Failed to write to connection: %v", err)
|
||||
}
|
||||
|
||||
// Shutdown server
|
||||
cancel()
|
||||
err = <-errCh
|
||||
if err != nil {
|
||||
t.Errorf("ServeTCP returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_TCPTrigger(t *testing.T) {
|
||||
s, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create service: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Set MCP_ADDR to empty to trigger default TCP
|
||||
os.Setenv("MCP_ADDR", "")
|
||||
defer os.Unsetenv("MCP_ADDR")
|
||||
|
||||
// We use a random port for testing, but Run will try to use 127.0.0.1:9100 by default if we don't override.
|
||||
// Since 9100 might be in use, we'll set MCP_ADDR to use :0 (random port)
|
||||
os.Setenv("MCP_ADDR", "127.0.0.1:0")
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- s.Run(ctx)
|
||||
}()
|
||||
|
||||
// Give it a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Since we can't easily get the actual port used by Run (it's internal),
|
||||
// we just verify it didn't immediately fail.
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("Run failed immediately: %v", err)
|
||||
default:
|
||||
// still running, which is good
|
||||
}
|
||||
|
||||
cancel()
|
||||
_ = <-errCh
|
||||
}
|
||||
|
||||
func TestServeTCP_MultipleConnections(t *testing.T) {
|
||||
s, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create service: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
addr := "127.0.0.1:0"
|
||||
tr, err := NewTCPTransport(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create transport: %v", err)
|
||||
t.Fatalf("failed to create transport: %v", err)
|
||||
}
|
||||
actualAddr := tr.listener.Addr().String()
|
||||
tr.listener.Close()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- s.ServeTCP(ctx, actualAddr)
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
conn, err := net.Dial("tcp", actualAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to server: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.Write([]byte("{}\n"))
|
||||
if err != nil {
|
||||
t.Errorf("failed to write to connection: %v", err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
if err = <-errCh; err != nil {
|
||||
t.Errorf("ServeTCP returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportTcp_ServeTCP_Bad(t *testing.T) {
|
||||
// ServeTCP with an already-in-use address returns an error
|
||||
tr, err := NewTCPTransport("127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create transport: %v", err)
|
||||
}
|
||||
defer tr.listener.Close()
|
||||
addr := tr.listener.Addr().String()
|
||||
|
||||
s, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err = s.ServeTCP(ctx, addr)
|
||||
if err == nil {
|
||||
t.Error("expected error when binding to already-in-use port, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportTcp_ServeTCP_Ugly(t *testing.T) {
|
||||
// Multiple simultaneous clients can connect and write without error
|
||||
s, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
tr, err := NewTCPTransport("127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create transport: %v", err)
|
||||
}
|
||||
actualAddr := tr.listener.Addr().String()
|
||||
tr.listener.Close()
|
||||
|
|
@ -162,23 +152,68 @@ func TestServeTCP_MultipleConnections(t *testing.T) {
|
|||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Connect multiple clients
|
||||
const numClients = 3
|
||||
for i := range numClients {
|
||||
conn, err := net.Dial("tcp", actualAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("Client %d failed to connect: %v", i, err)
|
||||
t.Fatalf("client %d failed to connect: %v", i, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_, err = conn.Write([]byte("{}\n"))
|
||||
if err != nil {
|
||||
t.Errorf("Client %d failed to write: %v", i, err)
|
||||
t.Errorf("client %d failed to write: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
err = <-errCh
|
||||
if err != nil {
|
||||
if err = <-errCh; err != nil {
|
||||
t.Errorf("ServeTCP returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportTcp_Run_Good(t *testing.T) {
|
||||
s, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
os.Setenv("MCP_ADDR", "127.0.0.1:0")
|
||||
defer os.Unsetenv("MCP_ADDR")
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- s.Run(ctx)
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("Run failed immediately: %v", err)
|
||||
default:
|
||||
// still running, which is good
|
||||
}
|
||||
|
||||
cancel()
|
||||
_ = <-errCh
|
||||
}
|
||||
|
||||
func TestTransportTcp_Warning_Ugly(t *testing.T) {
|
||||
// Binding to 0.0.0.0 emits a security warning
|
||||
var buf bytes.Buffer
|
||||
old := setDiagWriter(&buf)
|
||||
defer setDiagWriter(old)
|
||||
|
||||
tr, err := NewTCPTransport("0.0.0.0:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create transport: %v", err)
|
||||
}
|
||||
defer tr.listener.Close()
|
||||
|
||||
if !strings.Contains(buf.String(), "WARNING") {
|
||||
t.Error("expected security warning for 0.0.0.0 binding, got none")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue