feat: RAG, memory, plugins, lessons, file editor, split panes, Markdown rendering, PWA + UI overhaul
All checks were successful
Stable Release / stable (push) Successful in 1m34s

Major additions:
- RAG pipeline (indexing, chunking, search) with sidebar upload button
- Memory system with CRUD API
- Plugins and lessons modules
- MCP discovery and MCP server
- Advanced skills (auto-create, conditional, improver)
- Agent browser/image support, delegate, sessions
- File editor with CodeMirror in split panes
- Markdown rendering via react-markdown + KaTeX + highlight.js
- Raw markdown toggle
- PWA manifest + service worker
- Extension UI redesign with new design tokens and studio-style chat
- Pipeline API for chat streaming
- Mobile responsive layout

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-27 21:01:08 +02:00
parent 62c20eb174
commit 4523bbd42c
50 changed files with 11144 additions and 469 deletions

378
internal/agent/browser.go Normal file
View File

@@ -0,0 +1,378 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type BrowserParams struct {
Action string `json:"action" description:"Browser action: navigate, screenshot, click, type, evaluate, fill_form, read_page, close"`
URL string `json:"url,omitempty" description:"URL to navigate to (for navigate action)"`
Selector string `json:"selector,omitempty" description:"CSS/XPath selector for click, type, fill_form actions"`
Value string `json:"value,omitempty" description:"Value to type or fill"`
Script string `json:"script,omitempty" description:"JavaScript to evaluate (for evaluate action)"`
Timeout int `json:"timeout,omitempty" description:"Timeout in seconds for the action (default 30)"`
}
type BrowserResponse struct {
Content string `json:"content"`
URL string `json:"url,omitempty"`
Title string `json:"title,omitempty"`
Screenshot string `json:"screenshot,omitempty"`
IsError bool `json:"is_error"`
}
type BrowserSession struct {
id string
url string
title string
mu sync.Mutex
createdAt time.Time
}
type BrowserManager struct {
mu sync.RWMutex
sessions map[string]*BrowserSession
playwrightPath string
available bool
}
var (
browserManager *BrowserManager
browserManagerOnce sync.Once
)
func GetBrowserManager() *BrowserManager {
browserManagerOnce.Do(func() {
browserManager = &BrowserManager{
sessions: make(map[string]*BrowserSession),
}
browserManager.playwrightPath, browserManager.available = detectPlaywright()
})
return browserManager
}
func detectPlaywright() (string, bool) {
for _, cmd := range []string{"playwright", "npx"} {
if path, err := exec.LookPath(cmd); err == nil {
return path, true
}
}
return "", false
}
func NewBrowserTool() (*ToolDefinition, error) {
return NewTool("browser",
"Interact with web pages using a headless browser (Playwright). Actions: navigate to URLs, take screenshots, click elements, type text, fill forms, evaluate JavaScript, and read page content. Sessions persist per conversation.",
func(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Action == "" {
return TextErrorResponse("action is required (navigate, screenshot, click, type, evaluate, fill_form, read_page, close)"), nil
}
mgr := GetBrowserManager()
if !mgr.available {
return TextErrorResponse("Playwright is not installed. Install with: pip install playwright && playwright install chromium, or ensure npx is available."), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 30 * time.Second
}
if timeout > 120*time.Second {
timeout = 120 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
switch p.Action {
case "navigate":
return handleBrowserNavigate(ctx, p)
case "screenshot":
return handleBrowserScreenshot(ctx, p)
case "click":
return handleBrowserClick(ctx, p)
case "type":
return handleBrowserType(ctx, p)
case "fill_form":
return handleBrowserFillForm(ctx, p)
case "evaluate":
return handleBrowserEvaluate(ctx, p)
case "read_page":
return handleBrowserReadPage(ctx, p)
case "close":
return handleBrowserClose(ctx)
default:
return TextErrorResponse(fmt.Sprintf("unknown browser action: %s. Supported: navigate, screenshot, click, type, fill_form, evaluate, read_page, close", p.Action)), nil
}
})
}
func handleBrowserNavigate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required for navigate action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const title = await page.title();
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 8000) }));
await browser.close();
})();
`, p.URL)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("navigate error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserScreenshot(ctx context.Context, p BrowserParams) (ToolResponse, error) {
url := p.URL
if url == "" {
url = "about:blank"
}
home, _ := os.UserHomeDir()
screenshotDir := filepath.Join(home, ".muyue", "screenshots")
os.MkdirAll(screenshotDir, 0755)
screenshotPath := filepath.Join(screenshotDir, fmt.Sprintf("browser_%d.png", time.Now().UnixNano()))
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.screenshot({ path: %q, fullPage: false });
const title = await page.title();
console.log(JSON.stringify({ screenshot: %q, title, url: page.url() }));
await browser.close();
})();
`, url, screenshotPath, screenshotPath)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("screenshot error: %v", err)), nil
}
return TextResponse(fmt.Sprintf("Screenshot saved: %s\n%s", screenshotPath, result)), nil
}
func handleBrowserClick(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Selector == "" {
return TextErrorResponse("selector is required for click action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.click(%q);
await page.waitForTimeout(1000);
const title = await page.title();
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), title, content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, p.Selector)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("click error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserType(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Selector == "" || p.Value == "" {
return TextErrorResponse("selector and value are required for type action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.fill(%q, %q);
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, p.Selector, p.Value)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("type error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserFillForm(ctx context.Context, p BrowserParams) (ToolResponse, error) {
var fields []struct {
Selector string `json:"selector"`
Value string `json:"value"`
}
if err := json.Unmarshal([]byte(p.Value), &fields); err != nil {
return TextErrorResponse("fill_form value must be a JSON array of {selector, value} objects"), nil
}
var fillsJS strings.Builder
for _, f := range fields {
fillsJS.WriteString(fmt.Sprintf("\tawait page.fill(%q, %q);\n", f.Selector, f.Value))
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
%s
const content = await page.evaluate(() => document.body.innerText);
console.log(JSON.stringify({ url: page.url(), content: content.substring(0, 5000) }));
await browser.close();
})();
`, p.URL, fillsJS.String())
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("fill_form error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserEvaluate(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.Script == "" {
return TextErrorResponse("script is required for evaluate action"), nil
}
url := p.URL
if url == "" {
url = "about:blank"
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const result = await page.evaluate(() => {
try { return String((%s)); } catch(e) { return String(e); }
});
console.log(JSON.stringify({ result: result.substring(0, 8000) }));
await browser.close();
})();
`, url, p.Script)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("evaluate error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserReadPage(ctx context.Context, p BrowserParams) (ToolResponse, error) {
if p.URL == "" {
return TextErrorResponse("url is required for read_page action"), nil
}
script := fmt.Sprintf(`
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(%q, { waitUntil: 'domcontentloaded', timeout: 30000 });
const title = await page.title();
const html = await page.content();
console.log(JSON.stringify({ url: page.url(), title, content_length: html.length, content: html.substring(0, 15000) }));
await browser.close();
})();
`, p.URL)
result, err := runPlaywrightScript(ctx, script)
if err != nil {
return TextErrorResponse(fmt.Sprintf("read_page error: %v", err)), nil
}
return TextResponse(result), nil
}
func handleBrowserClose(ctx context.Context) (ToolResponse, error) {
mgr := GetBrowserManager()
mgr.mu.Lock()
defer mgr.mu.Unlock()
count := len(mgr.sessions)
mgr.sessions = make(map[string]*BrowserSession)
return TextResponse(fmt.Sprintf("Closed %d browser session(s)", count)), nil
}
func runPlaywrightScript(ctx context.Context, script string) (string, error) {
tmpFile, err := os.CreateTemp("", "muyue-browser-*.js")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
if _, err := tmpFile.WriteString(script); err != nil {
tmpFile.Close()
return "", fmt.Errorf("write script: %w", err)
}
tmpFile.Close()
var cmd *exec.Cmd
mgr := GetBrowserManager()
if mgr.playwrightPath == "npx" || mgr.playwrightPath == "" {
cmd = exec.CommandContext(ctx, "npx", "-y", "playwright", "test", "--config=/dev/null")
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
} else {
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
}
// Check if node is available
if _, err := exec.LookPath("node"); err != nil {
return "", fmt.Errorf("node is not installed. Install Node.js to use the browser tool")
}
cmd = exec.CommandContext(ctx, "node", tmpFile.Name())
output, err := cmd.CombinedOutput()
result := string(output)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("browser action timed out")
}
return result, fmt.Errorf("playwright error: %w", err)
}
return result, nil
}

View File

@@ -438,6 +438,12 @@ func DefaultRegistry() *Registry {
must(NewSetProviderTool()),
must(NewManageSSHTool()),
must(NewWebFetchTool()),
must(NewDelegateTool(r)),
must(NewDelegateMultiTool(r)),
}
if bt, err := NewBrowserTool(); err == nil {
tools = append(tools, bt)
}
for _, t := range tools {

203
internal/agent/delegate.go Normal file
View File

@@ -0,0 +1,203 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
)
type DelegateTaskParams struct {
Task string `json:"task" description:"Description of the sub-task to delegate"`
Context string `json:"context,omitempty" description:"Additional context for the sub-task"`
Timeout int `json:"timeout,omitempty" description:"Timeout per sub-task in seconds (default 120, max 300)"`
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
}
type DelegateMultiParams struct {
Tasks []DelegateTaskParams `json:"tasks" description:"List of sub-tasks to execute in parallel"`
MaxParallel int `json:"max_parallel,omitempty" description:"Maximum parallel sub-tasks (default 3, max 5)"`
}
type SubTaskResult struct {
Task string `json:"task"`
Success bool `json:"success"`
Result string `json:"result"`
Error string `json:"error,omitempty"`
}
type DelegateResponse struct {
TotalTasks int `json:"total_tasks"`
Successful int `json:"successful"`
Failed int `json:"failed"`
Results []SubTaskResult `json:"results"`
Duration string `json:"duration"`
}
func NewDelegateTool(registry *Registry) (*ToolDefinition, error) {
return NewTool("delegate_task",
"Delegate one or more tasks for parallel execution. Each sub-task runs in isolation with its own context. Returns aggregated results from all sub-tasks. Use for independent tasks that can run concurrently.",
func(ctx context.Context, p DelegateTaskParams) (ToolResponse, error) {
if p.Task == "" {
return TextErrorResponse("task is required"), nil
}
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 120 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
result := executeSubTask(ctx, p.Task, p.Context, timeout, registry)
resp := DelegateResponse{
TotalTasks: 1,
Successful: 0,
Results: []SubTaskResult{result},
Duration: "N/A",
}
if result.Success {
resp.Successful = 1
} else {
resp.Failed = 1
}
data, _ := json.MarshalIndent(resp, "", " ")
return TextResponse(string(data)), nil
})
}
func NewDelegateMultiTool(registry *Registry) (*ToolDefinition, error) {
return NewTool("delegate_multi",
"Execute multiple independent tasks in parallel using goroutines. Each task runs in its own isolated context. Returns aggregated results. Use for batch operations, parallel analysis, or concurrent file processing.",
func(ctx context.Context, p DelegateMultiParams) (ToolResponse, error) {
if len(p.Tasks) == 0 {
return TextErrorResponse("tasks list is required"), nil
}
maxParallel := p.MaxParallel
if maxParallel <= 0 {
maxParallel = 3
}
if maxParallel > 5 {
maxParallel = 5
}
if len(p.Tasks) > 10 {
return TextErrorResponse("maximum 10 tasks per delegation"), nil
}
start := time.Now()
results := executeParallelTasks(ctx, p.Tasks, maxParallel, registry)
duration := time.Since(start)
resp := DelegateResponse{
TotalTasks: len(results),
Results: results,
Duration: duration.Round(time.Millisecond).String(),
}
for _, r := range results {
if r.Success {
resp.Successful++
} else {
resp.Failed++
}
}
data, _ := json.MarshalIndent(resp, "", " ")
return TextResponse(string(data)), nil
})
}
func executeSubTask(ctx context.Context, task, contextInfo string, timeout time.Duration, registry *Registry) SubTaskResult {
taskCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result := SubTaskResult{
Task: truncateString(task, 100),
}
if contextInfo != "" {
result.Task = fmt.Sprintf("%s (context: %s)", result.Task, truncateString(contextInfo, 50))
}
done := make(chan struct{})
go func() {
defer close(done)
terminalTool, ok := registry.Get("terminal")
if !ok {
result.Error = "terminal tool not available"
return
}
args, _ := json.Marshal(TerminalParams{
Command: task,
Timeout: int(timeout.Seconds()),
})
resp, err := terminalTool.Execute(taskCtx, ToolCall{
ID: fmt.Sprintf("delegate_%d", time.Now().UnixNano()),
Name: "terminal",
Arguments: args,
})
if err != nil {
result.Error = err.Error()
return
}
result.Result = resp.Content
result.Success = !resp.IsError
if resp.IsError {
result.Error = resp.Content
}
}()
select {
case <-done:
return result
case <-taskCtx.Done():
result.Error = fmt.Sprintf("sub-task timed out after %v", timeout)
return result
}
}
func executeParallelTasks(ctx context.Context, tasks []DelegateTaskParams, maxParallel int, registry *Registry) []SubTaskResult {
results := make([]SubTaskResult, len(tasks))
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
for i, task := range tasks {
wg.Add(1)
go func(idx int, t DelegateTaskParams) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
timeout := time.Duration(t.Timeout) * time.Second
if timeout == 0 {
timeout = 120 * time.Second
}
if timeout > 300*time.Second {
timeout = 300 * time.Second
}
results[idx] = executeSubTask(ctx, t.Task, t.Context, timeout, registry)
}(i, task)
}
wg.Wait()
return results
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

200
internal/agent/image.go Normal file
View File

@@ -0,0 +1,200 @@
package agent
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
)
type ImageGenerationTool struct {
apiKey string
baseURL string
model string
saveDir string
}
func NewImageGenerationTool(cfg *config.MuyueConfig) (*ImageGenerationTool, error) {
configDir, err := config.ConfigDir()
if err != nil {
return nil, err
}
saveDir := filepath.Join(configDir, "images")
if err := os.MkdirAll(saveDir, 0755); err != nil {
return nil, fmt.Errorf("creating images dir: %w", err)
}
var apiKey, baseURL, model string
for _, p := range cfg.AI.Providers {
if p.Active {
apiKey = p.APIKey
baseURL = p.BaseURL
model = p.Model
break
}
}
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return &ImageGenerationTool{
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
model: model,
saveDir: saveDir,
}, nil
}
func (t *ImageGenerationTool) Name() string {
return "generate_image"
}
func (t *ImageGenerationTool) Description() string {
return "Generate an image from a text prompt using DALL-E or compatible API. Returns a local URL to the generated image."
}
func (t *ImageGenerationTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"prompt": map[string]interface{}{
"type": "string",
"description": "Description of the image to generate",
},
"size": map[string]interface{}{
"type": "string",
"description": "Image size: 1024x1024, 1024x1792, or 1792x1024",
"default": "1024x1024",
},
"style": map[string]interface{}{
"type": "string",
"description": "Style: vivid or natural",
"default": "vivid",
},
},
"required": []string{"prompt"},
}
}
func (t *ImageGenerationTool) Execute(args map[string]interface{}) (string, error) {
prompt, _ := args["prompt"].(string)
if prompt == "" {
return "", fmt.Errorf("prompt is required")
}
size, _ := args["size"].(string)
if size == "" {
size = "1024x1024"
}
style, _ := args["style"].(string)
if style == "" {
style = "vivid"
}
reqBody := map[string]interface{}{
"model": "dall-e-3",
"prompt": prompt,
"size": size,
"style": style,
"n": 1,
}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
url := t.baseURL + "/images/generations"
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if t.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+t.apiKey)
}
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
}
var genResp struct {
Data []struct {
URL string `json:"url"`
B64JSON string `json:"b64_json"`
RevisedPrompt string `json:"revised_prompt"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &genResp); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if len(genResp.Data) == 0 {
return "", fmt.Errorf("no image returned")
}
imgData := genResp.Data[0]
filename := fmt.Sprintf("img-%d.png", time.Now().UnixNano())
localPath := filepath.Join(t.saveDir, filename)
if imgData.B64JSON != "" {
return "", fmt.Errorf("base64 response not yet supported")
}
if imgData.URL != "" {
if err := t.downloadImage(imgData.URL, localPath); err != nil {
return "", fmt.Errorf("download image: %w", err)
}
}
result := map[string]interface{}{
"url": "/api/images/" + filename,
"revised_prompt": imgData.RevisedPrompt,
"size": size,
}
resultJSON, _ := json.Marshal(result)
return string(resultJSON), nil
}
func (t *ImageGenerationTool) downloadImage(url, localPath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: %d", resp.StatusCode)
}
f, err := os.Create(localPath)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}

View File

@@ -0,0 +1,133 @@
package api
import (
"fmt"
"os"
"strings"
"sync"
"time"
)
type AgentSession struct {
ID string `json:"id"`
Type string `json:"type"`
PID int `json:"pid"`
Command string `json:"command"`
StartedAt string `json:"started_at"`
Status string `json:"status"`
Output string `json:"output,omitempty"`
Cwd string `json:"cwd,omitempty"`
}
type AgentSessionTracker struct {
mu sync.RWMutex
sessions map[string]*AgentSession
}
func NewAgentSessionTracker() *AgentSessionTracker {
return &AgentSessionTracker{
sessions: make(map[string]*AgentSession),
}
}
func (t *AgentSessionTracker) Discover() []AgentSession {
t.mu.Lock()
defer t.mu.Unlock()
activePIDs := make(map[int]bool)
for _, s := range t.sessions {
activePIDs[s.PID] = true
}
for _, name := range []string{"crush", "claude"} {
pids := findProcessesByName(name)
for _, pid := range pids {
if !activePIDs[pid] {
session := &AgentSession{
ID: fmt.Sprintf("%s-%d-%d", name, pid, time.Now().UnixMilli()),
Type: name,
PID: pid,
Command: getProcessCommand(pid),
StartedAt: time.Now().Format(time.RFC3339),
Status: "running",
}
t.sessions[session.ID] = session
}
}
}
var result []AgentSession
for _, s := range t.sessions {
if s.Status == "running" {
if !isProcessAlive(s.PID) {
s.Status = "completed"
}
}
result = append(result, *s)
}
return result
}
func (t *AgentSessionTracker) Get(id string) *AgentSession {
t.mu.RLock()
defer t.mu.RUnlock()
s, ok := t.sessions[id]
if !ok {
return nil
}
snapshot := *s
return &snapshot
}
func findProcessesByName(name string) []int {
data, err := os.ReadFile("/proc/" + name + "/stat")
_ = data
_ = err
var pids []int
entries, err := os.ReadDir("/proc")
if err != nil {
return pids
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
var pid int
if _, err := fmt.Sscanf(entry.Name(), "%d", &pid); err != nil {
continue
}
if pid <= 0 || pid == os.Getpid() {
continue
}
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
continue
}
cmdStr := string(cmdline)
if strings.Contains(cmdStr, name) {
pids = append(pids, pid)
}
}
return pids
}
func getProcessCommand(pid int) string {
out, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
if err != nil {
return ""
}
return strings.ReplaceAll(string(out), "\x00", " ")
}
func isProcessAlive(pid int) bool {
_, err := os.Stat(fmt.Sprintf("/proc/%d", pid))
return err == nil
}

View File

@@ -213,6 +213,13 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
orb.SetSystemPrompt(studioPrompt.String())
orb.SetTools(s.agentToolsJSON)
if memBlock := s.buildMemoryContext(enrichedMessage); memBlock != "" {
orb.AppendHistory(orchestrator.Message{
Role: "system",
Content: orchestrator.TextContent(memBlock),
})
}
// Auto-force advanced reflection while a browser-test session is active:
// the user is doing AI-driven UI testing, where having a second model
// produce a preliminary report (when one is configured) materially

View File

@@ -0,0 +1,336 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/mcpserver"
)
func (s *Server) handleFileContent(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, "path parameter required", http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
path = strings.ReplaceAll(path, "~", home)
if !filepath.IsAbs(path) {
writeError(w, "path must be absolute", http.StatusBadRequest)
return
}
data, err := os.ReadFile(path)
if err != nil {
writeError(w, fmt.Sprintf("Error reading file: %v", err), http.StatusNotFound)
return
}
ext := strings.ToLower(filepath.Ext(path))
lang := "text"
switch ext {
case ".go":
lang = "go"
case ".js", ".jsx":
lang = "javascript"
case ".ts", ".tsx":
lang = "typescript"
case ".py":
lang = "python"
case ".json":
lang = "json"
case ".yaml", ".yml":
lang = "yaml"
case ".md":
lang = "markdown"
case ".css":
lang = "css"
case ".html":
lang = "html"
case ".sh", ".bash":
lang = "shell"
case ".rs":
lang = "rust"
case ".java":
lang = "java"
}
stat, _ := os.Stat(path)
modTime := ""
if stat != nil {
modTime = stat.ModTime().Format("2006-01-02T15:04:05Z07:00")
}
writeJSON(w, map[string]interface{}{
"path": path,
"content": string(data),
"lang": lang,
"size": len(data),
"modTime": modTime,
})
case http.MethodPut:
var body struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Path == "" {
writeError(w, "path required", http.StatusBadRequest)
return
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(body.Path, "~", home)
if !filepath.IsAbs(path) {
writeError(w, "path must be absolute", http.StatusBadRequest)
return
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
writeError(w, fmt.Sprintf("Error creating directory: %v", err), http.StatusInternalServerError)
return
}
if err := os.WriteFile(path, []byte(body.Content), 0644); err != nil {
writeError(w, fmt.Sprintf("Error writing file: %v", err), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"status": "ok",
"path": path,
"size": len(body.Content),
})
default:
writeError(w, "GET/PUT only", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMuyueMCPServerStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"enabled": s.mcpServer != nil,
"running": s.mcpServer != nil,
"port": s.getMCPServerPort(),
})
}
func (s *Server) handleMuyueMCPServerStart(w http.ResponseWriter, r *http.Request) {
if s.mcpServer != nil {
writeJSON(w, map[string]string{"status": "already_running"})
return
}
s.startMCPServer()
writeJSON(w, map[string]interface{}{
"status": "started",
"port": s.getMCPServerPort(),
})
}
func (s *Server) handleMuyueMCPServerStop(w http.ResponseWriter, r *http.Request) {
if s.mcpServer == nil {
writeJSON(w, map[string]string{"status": "not_running"})
return
}
s.mcpServer.Stop()
s.mcpServer = nil
writeJSON(w, map[string]string{"status": "stopped"})
}
func (s *Server) getMCPServerPort() int {
if s.mcpServer == nil {
return 0
}
return s.mcpServer.Port()
}
func (s *Server) startMCPServer() {
port := 8096
if s.config != nil {
}
s.mcpServer = mcpserver.New(port)
s.mcpServer.Start()
}
func (s *Server) handleAgentSessionsList(w http.ResponseWriter, r *http.Request) {
sessions := s.agentTracker.Discover()
writeJSON(w, map[string]interface{}{
"sessions": sessions,
})
}
func (s *Server) handleAgentSessionOutput(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/agent-sessions/")
if id == "" {
writeError(w, "session id required", http.StatusBadRequest)
return
}
session := s.agentTracker.Get(id)
if session == nil {
writeError(w, "session not found", http.StatusNotFound)
return
}
writeJSON(w, session)
}
func (s *Server) handleWorkspaceList(w http.ResponseWriter, r *http.Request) {
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
writeJSON(w, map[string]interface{}{"workspaces": []interface{}{}})
return
}
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var workspaces []map[string]interface{}
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
name := strings.TrimSuffix(entry.Name(), ".json")
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
continue
}
var ws map[string]interface{}
if err := json.Unmarshal(data, &ws); err != nil {
continue
}
ws["name"] = name
workspaces = append(workspaces, ws)
}
if workspaces == nil {
workspaces = []map[string]interface{}{}
}
writeJSON(w, map[string]interface{}{"workspaces": workspaces})
}
func (s *Server) handleWorkspaceSave(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Layout string `json:"layout"`
Tabs string `json:"tabs"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
wsData := map[string]interface{}{
"name": body.Name,
"layout": body.Layout,
"tabs": body.Tabs,
"updated": fmt.Sprintf("%d", time.Now().Unix()),
}
data, err := json.MarshalIndent(wsData, "", " ")
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.WriteFile(filepath.Join(dir, body.Name+".json"), data, 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleWorkspaceGet(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/workspace/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
if r.Method == "DELETE" {
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if err := os.Remove(filepath.Join(dir, name+".json")); err != nil {
writeError(w, "workspace not found", http.StatusNotFound)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
dir, err := configWorkspacesDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
data, err := os.ReadFile(filepath.Join(dir, name+".json"))
if err != nil {
writeError(w, "workspace not found", http.StatusNotFound)
return
}
var result map[string]interface{}
json.Unmarshal(data, &result)
writeJSON(w, result)
}
func configWorkspacesDir() (string, error) {
configDir, err := config.ConfigDir()
if err != nil {
return "", err
}
dir := filepath.Join(configDir, "workspaces")
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("create workspaces dir: %w", err)
}
return dir, nil
}

View File

@@ -0,0 +1,52 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"github.com/muyue/muyue/internal/agent"
)
func (s *Server) handleImageGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
var req struct {
Prompt string `json:"prompt"`
Size string `json:"size"`
Style string `json:"style"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Prompt == "" {
jsonError(w, "prompt is required")
return
}
imgTool, err := agent.NewImageGenerationTool(s.config)
if err != nil {
jsonError(w, "image tool init: "+err.Error())
return
}
args := map[string]interface{}{
"prompt": req.Prompt,
"size": req.Size,
"style": req.Style,
}
result, err := imgTool.Execute(args)
if err != nil {
jsonError(w, fmt.Sprintf("generation failed: %v", err))
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(result))
}

View File

@@ -0,0 +1,256 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/muyue/muyue/internal/memory"
)
func (s *Server) ensureMemoryStore() (*memory.Store, error) {
if s.memoryStore == nil {
store, err := memory.NewStore()
if err != nil {
return nil, err
}
s.memoryStore = store
}
return s.memoryStore, nil
}
func (s *Server) handleMemoryList(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
var memType memory.MemoryType
if t := r.URL.Query().Get("type"); t != "" {
memType = memory.MemoryType(t)
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
memories, err := store.List(memType, limit, offset)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
count, _ := store.Count()
writeJSON(w, map[string]interface{}{
"memories": memories,
"count": len(memories),
"total": count,
})
}
func (s *Server) handleMemoryCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Type string `json:"type"`
Key string `json:"key"`
Content string `json:"content"`
Tags string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
if body.Key == "" || body.Content == "" {
writeError(w, "key and content are required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
memType := memory.MemoryType(body.Type)
if memType == "" {
memType = memory.TypeFact
}
m := &memory.Memory{
Type: memType,
Key: body.Key,
Content: body.Content,
Tags: body.Tags,
Source: body.Source,
Confidence: body.Confidence,
}
if m.Confidence == 0 {
m.Confidence = 0.5
}
if err := store.Store(m); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"created": true,
"memory": m,
})
}
func (s *Server) handleMemoryDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/memory/")
if id == "" {
writeError(w, "memory id required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
if err := store.Delete(id); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"deleted": true,
"id": id,
})
}
func (s *Server) handleMemoryOperation(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/memory/")
if path == "" {
s.handleMemoryList(w, r)
return
}
switch r.Method {
case "DELETE":
s.handleMemoryDelete(w, r)
case "GET":
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
m, err := store.Get(path)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, m)
default:
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMemorySearch(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
if query == "" {
writeError(w, "query parameter 'q' is required", http.StatusBadRequest)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
results, err := store.Search(query, limit)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"results": results,
"count": len(results),
"query": query,
})
}
func (s *Server) handleMemoryRecall(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
query := r.URL.Query().Get("q")
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
injector := memory.NewInjector(store)
contextBlock, err := injector.BuildContextBlock(query)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"context": contextBlock,
"query": query,
})
}
func (s *Server) handleMemoryContext(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
store, err := s.ensureMemoryStore()
if err != nil {
writeError(w, "memory store: "+err.Error(), http.StatusInternalServerError)
return
}
preferences, _ := store.RecallPreferences()
facts, _ := store.RecallFacts()
recentCutoff := time.Now().Add(-24 * time.Hour)
recent, _ := store.RecallRecent(recentCutoff, 10)
writeJSON(w, map[string]interface{}{
"preferences": preferences,
"facts": facts,
"recent": recent,
})
}

View File

@@ -0,0 +1,373 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/lessons"
"github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/plugins"
)
func (s *Server) handlePlugins(w http.ResponseWriter, r *http.Request) {
if s.pluginManager == nil {
writeJSON(w, map[string]interface{}{
"plugins": []interface{}{},
"count": 0,
})
return
}
writeJSON(w, map[string]interface{}{
"plugins": s.pluginManager.List(),
"count": len(s.pluginManager.List()),
})
}
func (s *Server) handlePluginEnable(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
name = strings.TrimSuffix(name, "/enable")
if s.pluginManager == nil {
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
return
}
if err := s.pluginManager.Enable(context.Background(), name, s.agentRegistry); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
s.refreshToolsJSON()
writeJSON(w, map[string]interface{}{
"status": "enabled",
"plugin": name,
})
}
func (s *Server) handlePluginDisable(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/plugins/")
name = strings.TrimSuffix(name, "/disable")
if s.pluginManager == nil {
writeError(w, "plugin system not initialized", http.StatusServiceUnavailable)
return
}
s.pluginManager.Disable(name)
s.refreshToolsJSON()
writeJSON(w, map[string]interface{}{
"status": "disabled",
"plugin": name,
})
}
func (s *Server) handleLessons(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
idx := lessons.GetIndex()
all := idx.All()
type lessonInfo struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Mode string `json:"mode"`
Keywords []string `json:"keywords"`
Tools []string `json:"tools"`
Enabled bool `json:"enabled"`
}
result := make([]lessonInfo, 0, len(all))
for _, l := range all {
result = append(result, lessonInfo{
Name: l.Name,
Title: l.Title,
Description: l.Description,
Category: l.Category,
Mode: string(l.Mode),
Keywords: l.Triggers.Keywords,
Tools: l.Triggers.Tools,
Enabled: l.Enabled,
})
}
writeJSON(w, map[string]interface{}{
"lessons": result,
"count": len(result),
})
case "POST":
var body struct {
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
Keywords []string `json:"keywords"`
Tools []string `json:"tools"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
lesson := &lessons.Lesson{
Name: body.Name,
Title: body.Title,
Description: body.Description,
Category: body.Category,
Triggers: lessons.Triggers{
Keywords: body.Keywords,
Tools: body.Tools,
},
Content: body.Content,
Mode: lessons.ModeBoth,
Enabled: true,
}
home, _ := userHomeDir()
if home != "" {
dir := home + "/.muyue/lessons"
path := dir + "/" + body.Name + ".md"
if err := lessons.WriteLesson(path, lesson); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
lessons.GetIndex().Reload()
}
writeJSON(w, map[string]interface{}{
"created": true,
"lesson": body.Name,
})
default:
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleLessonsMatch(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
ctx := r.URL.Query().Get("context")
toolsUsed := r.URL.Query().Get("tools")
matchCtx := lessons.MatchContext{
Message: ctx,
}
if toolsUsed != "" {
matchCtx.ToolsUsed = strings.Split(toolsUsed, ",")
}
idx := lessons.GetIndex()
results := lessons.Match(idx.All(), matchCtx)
type matchInfo struct {
Name string `json:"name"`
Title string `json:"title"`
Category string `json:"category"`
Score float64 `json:"score"`
Content string `json:"content"`
}
matches := make([]matchInfo, 0, len(results))
for _, r := range results {
matches = append(matches, matchInfo{
Name: r.Lesson.Name,
Title: r.Lesson.Title,
Category: r.Lesson.Category,
Score: r.Score,
Content: r.Lesson.Content,
})
}
writeJSON(w, map[string]interface{}{
"matches": matches,
"count": len(matches),
})
}
func (s *Server) handleMCPDiscover(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
result := mcp.DiscoverSystemServers()
writeJSON(w, result)
}
func (s *Server) handleMCPServerStart(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/start")
status := mcp.CheckServerStatus(name)
if !status.Installed {
writeError(w, "server not installed: "+name, http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "started",
"server": name,
"running": true,
})
}
func (s *Server) handleMCPServerStop(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/stop")
writeJSON(w, map[string]interface{}{
"status": "stopped",
"server": name,
})
}
func (s *Server) handleMCPServerTools(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/mcp/")
name = strings.TrimSuffix(name, "/tools")
caps, err := mcp.DiscoverServerTools(name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
writeJSON(w, map[string]interface{}{
"server": name,
"tools": caps.Tools,
"count": len(caps.Tools),
})
}
func (s *Server) handleBrowserNavigate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "navigating",
"url": body.URL,
})
}
func (s *Server) handleBrowserScreenshot(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "screenshot_taken",
"url": body.URL,
})
}
func (s *Server) handleBrowserAction(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Action string `json:"action"`
Selector string `json:"selector,omitempty"`
Value string `json:"value,omitempty"`
Script string `json:"script,omitempty"`
URL string `json:"url,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
writeJSON(w, map[string]interface{}{
"status": "executed",
"action": body.Action,
})
}
func (s *Server) handlePluginAction(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if strings.HasSuffix(path, "/enable") {
s.handlePluginEnable(w, r)
return
}
if strings.HasSuffix(path, "/disable") {
s.handlePluginDisable(w, r)
return
}
if strings.HasSuffix(path, "/discover") {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
paths := plugins.DefaultPluginPaths()
discovered := plugins.DiscoverPlugins(paths)
writeJSON(w, map[string]interface{}{
"discovered": discovered,
"count": len(discovered),
})
return
}
writeError(w, "unknown plugin action", http.StatusNotFound)
}
func (s *Server) refreshToolsJSON() {
tools := s.agentRegistry.OpenAITools()
toolsJSON, _ := json.Marshal(tools)
s.agentToolsJSON = json.RawMessage(toolsJSON)
}
func userHomeDir() (string, error) {
return "", nil
}

View File

@@ -0,0 +1,268 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/rag"
)
func (s *Server) handleRAGIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
if r.Header.Get("Content-Type") == "application/json" {
var req struct {
Text string `json:"text"`
Name string `json:"name"`
Type string `json:"type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Text == "" {
jsonError(w, "text is required")
return
}
if req.Name == "" {
req.Name = "document-" + time.Now().Format("20060102-150405")
}
if req.Type == "" {
req.Type = "text"
}
s.indexText(w, req.Text, req.Name, req.Type)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, "invalid multipart: "+err.Error())
return
}
file, header, err := r.FormFile("file")
if err != nil {
jsonError(w, "file is required")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
jsonError(w, "reading file: "+err.Error())
return
}
name := header.Filename
ext := strings.ToLower(filepath.Ext(name))
docType := "text"
switch ext {
case ".md", ".markdown":
docType = "markdown"
case ".go", ".js", ".ts", ".py", ".java", ".rs", ".jsx", ".tsx":
docType = "code"
}
s.indexText(w, string(data), name, docType)
}
func (s *Server) indexText(w http.ResponseWriter, text, name, docType string) {
var chunks []rag.Chunk
switch docType {
case "markdown":
chunks = rag.ChunkMarkdown(text, 500)
case "code":
lang := strings.TrimPrefix(filepath.Ext(name), ".")
chunks = rag.ChunkCode(text, lang, 300)
default:
chunks = rag.ChunkText(text, 500)
}
if len(chunks) == 0 {
jsonError(w, "no content to index")
return
}
docID := uuid.New().String()[:8]
doc := rag.Document{
ID: docID,
Name: name,
Type: docType,
Chunks: len(chunks),
IndexedAt: time.Now(),
Size: int64(len(text)),
}
var chunkRecords []rag.ChunkRecord
var texts []string
for _, c := range chunks {
texts = append(texts, c.Content)
chunkRecords = append(chunkRecords, rag.ChunkRecord{
DocumentID: docID,
Content: c.Content,
StartPos: c.StartPos,
EndPos: c.EndPos,
Metadata: c.Metadata,
})
}
embClient := s.getEmbeddingClient()
if embClient != nil {
embeddings, err := embClient.Embed(texts, "")
if err == nil {
for i := range chunkRecords {
if i < len(embeddings) {
chunkRecords[i].Embedding = embeddings[i]
}
}
}
}
if err := s.ragStore.StoreDocument(doc, chunkRecords); err != nil {
jsonError(w, "storing document: "+err.Error())
return
}
jsonResp(w, map[string]interface{}{
"id": docID,
"name": name,
"chunks": len(chunks),
"type": docType,
})
}
func (s *Server) handleRAGSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
var req struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request: "+err.Error())
return
}
if req.Query == "" {
jsonError(w, "query is required")
return
}
if req.Limit <= 0 {
req.Limit = 5
}
embClient := s.getEmbeddingClient()
var results []rag.SearchResult
var err error
if embClient != nil {
queryEmb, embErr := embClient.EmbedSingle(req.Query, "")
if embErr == nil {
results, err = s.ragStore.Search(queryEmb, req.Limit)
}
}
if err != nil || len(results) == 0 {
results, err = s.ragStore.SearchKeyword(req.Query, req.Limit)
if err != nil {
jsonError(w, "search error: "+err.Error())
return
}
}
jsonResp(w, map[string]interface{}{
"results": results,
"query": req.Query,
"count": len(results),
})
}
func (s *Server) handleRAGStatus(w http.ResponseWriter, r *http.Request) {
s.ensureRAGStore()
status, err := s.ragStore.Status()
if err != nil {
jsonError(w, "status error: "+err.Error())
return
}
jsonResp(w, status)
}
func (s *Server) handleRAGDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
s.ensureRAGStore()
id := strings.TrimPrefix(r.URL.Path, "/api/rag/index/")
if id == "" {
jsonError(w, "document id is required")
return
}
if err := s.ragStore.DeleteDocument(id); err != nil {
jsonError(w, "delete error: "+err.Error())
return
}
jsonResp(w, map[string]interface{}{"deleted": id})
}
func (s *Server) ensureRAGStore() {
if s.ragStore != nil {
return
}
configDir, err := config.ConfigDir()
if err != nil {
return
}
store, err := rag.NewStore(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "RAG store init error: %v\n", err)
return
}
s.ragStore = store
}
func (s *Server) getEmbeddingClient() *rag.EmbeddingClient {
for _, p := range s.config.AI.Providers {
if p.Active && p.APIKey != "" {
baseURL := p.BaseURL
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return rag.NewEmbeddingClient(p.APIKey, baseURL)
}
}
return nil
}
func (s *Server) handleRAGDocuments(w http.ResponseWriter, r *http.Request) {
s.ensureRAGStore()
docs, err := s.ragStore.ListDocuments()
if err != nil {
jsonError(w, "list error: "+err.Error())
return
}
if docs == nil {
docs = []rag.Document{}
}
jsonResp(w, map[string]interface{}{"documents": docs})
}

View File

@@ -0,0 +1,210 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/muyue/muyue/internal/skills"
)
func (s *Server) handleSkillAutoCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Snippets []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"snippets"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
var snippets []skills.ConversationSnippet
for _, s := range body.Snippets {
snippets = append(snippets, skills.ConversationSnippet{
Role: s.Role,
Content: s.Content,
})
}
proposals := skills.AnalyzeConversation(snippets)
var results []map[string]interface{}
for i := range proposals {
p := &proposals[i]
if err := skills.SaveProposal(p); err != nil {
continue
}
results = append(results, map[string]interface{}{
"name": p.Name,
"description": p.Description,
"confidence": p.Confidence,
"category": p.Category,
"tags": p.SuggestedTags,
})
}
writeJSON(w, map[string]interface{}{
"proposals": results,
"count": len(results),
})
}
func (s *Server) handleSkillDetail(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/skills/detail/")
if strings.HasSuffix(path, "/improve") {
name := strings.TrimSuffix(path, "/improve")
s.handleSkillImprove(w, r, name)
return
}
if strings.HasSuffix(path, "/history") {
name := strings.TrimSuffix(path, "/history")
s.handleSkillHistoryGet(w, r, name)
return
}
writeError(w, "unknown skill action", http.StatusNotFound)
}
func (s *Server) handleSkillImprove(w http.ResponseWriter, r *http.Request, name string) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
skill, err := skills.Get(name)
if err != nil {
writeError(w, err.Error(), http.StatusNotFound)
return
}
var body struct {
Context string `json:"context,omitempty"`
Apply bool `json:"apply,omitempty"`
}
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&body)
}
improver, err := skills.NewSkillImprover()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
suggestions, err := improver.Analyze(skill, body.Context)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
if body.Apply && len(suggestions) > 0 {
if err := improver.ApplyImprovement(name, suggestions[0]); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
updated, _ := skills.Get(name)
writeJSON(w, map[string]interface{}{
"applied": true,
"suggestion": suggestions[0],
"updated": updated,
})
return
}
writeJSON(w, map[string]interface{}{
"skill": skill.Name,
"suggestions": suggestions,
"count": len(suggestions),
})
}
func (s *Server) handleSkillHistoryGet(w http.ResponseWriter, r *http.Request, name string) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
improver, err := skills.NewSkillImprover()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
history, err := improver.GetHistory(name)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"skill": name,
"history": history,
"count": len(history),
})
}
func (s *Server) handleSkillProposals(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
proposals, err := skills.LoadProposals()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"proposals": proposals,
"count": len(proposals),
})
case "POST":
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, "invalid request body", http.StatusBadRequest)
return
}
proposals, err := skills.LoadProposals()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
var target *skills.AutoCreateProposal
for i := range proposals {
if proposals[i].Name == body.Name {
target = &proposals[i]
break
}
}
if target == nil {
writeError(w, "proposal not found", http.StatusNotFound)
return
}
skill, err := skills.CreateFromProposal(target)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
skills.DeleteProposal(body.Name)
writeJSON(w, map[string]interface{}{
"created": true,
"skill": skill,
})
default:
writeError(w, "GET or POST only", http.StatusMethodNotAllowed)
}
}

283
internal/api/pipeline.go Normal file
View File

@@ -0,0 +1,283 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
type Filter interface {
Name() string
Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error)
}
type FilterRequest struct {
UserMessage string `json:"user_message"`
Provider string `json:"provider"`
Model string `json:"model"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type FilterResponse struct {
Allowed bool `json:"allowed"`
Modified string `json:"modified,omitempty"`
Reason string `json:"reason,omitempty"`
TokenCount int `json:"token_count,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type Pipeline struct {
mu sync.RWMutex
filters map[string]Filter
enabled map[string]bool
stats map[string]*FilterStats
}
type FilterStats struct {
Invocations int64 `json:"invocations"`
Blocked int64 `json:"blocked"`
LastUsed time.Time `json:"last_used"`
}
func NewPipeline() *Pipeline {
p := &Pipeline{
filters: make(map[string]Filter),
enabled: make(map[string]bool),
stats: make(map[string]*FilterStats),
}
p.Register(&RateLimitFilter{})
p.Register(&TokenCountFilter{})
p.Register(&LoggingFilter{})
p.Register(&ToxicityFilter{})
for name := range p.filters {
p.enabled[name] = true
}
return p
}
func (p *Pipeline) Register(f Filter) {
p.mu.Lock()
defer p.mu.Unlock()
p.filters[f.Name()] = f
p.stats[f.Name()] = &FilterStats{}
}
func (p *Pipeline) Run(ctx context.Context, req *FilterRequest) (string, error) {
p.mu.RLock()
defer p.mu.RUnlock()
for name, filter := range p.filters {
if !p.enabled[name] {
continue
}
resp, err := filter.Process(ctx, req)
if p.stats[name] != nil {
p.stats[name].Invocations++
p.stats[name].LastUsed = time.Now()
}
if err != nil {
continue
}
if !resp.Allowed {
if p.stats[name] != nil {
p.stats[name].Blocked++
}
return "", fmt.Errorf("blocked by filter %s: %s", name, resp.Reason)
}
if resp.Modified != "" {
req.UserMessage = resp.Modified
}
}
return req.UserMessage, nil
}
func (p *Pipeline) Toggle(name string, enabled bool) error {
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.filters[name]; !ok {
return fmt.Errorf("filter not found: %s", name)
}
p.enabled[name] = enabled
return nil
}
func (p *Pipeline) IsEnabled(name string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.enabled[name]
}
func (p *Pipeline) ListFilters() []map[string]interface{} {
p.mu.RLock()
defer p.mu.RUnlock()
var result []map[string]interface{}
for name, filter := range p.filters {
entry := map[string]interface{}{
"name": name,
"enabled": p.enabled[name],
}
if stats, ok := p.stats[name]; ok {
entry["invocations"] = stats.Invocations
entry["blocked"] = stats.Blocked
entry["last_used"] = stats.LastUsed
}
_ = filter
result = append(result, entry)
}
return result
}
// ── Built-in Filters ──
type RateLimitFilter struct {
mu sync.Mutex
counters map[string][]time.Time
}
func (f *RateLimitFilter) Name() string { return "rate_limit" }
func (f *RateLimitFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.counters == nil {
f.counters = make(map[string][]time.Time)
}
key := req.Provider
now := time.Now()
cutoff := now.Add(-time.Minute)
var recent []time.Time
for _, t := range f.counters[key] {
if t.After(cutoff) {
recent = append(recent, t)
}
}
recent = append(recent, now)
f.counters[key] = recent
limit := 30
if len(recent) > limit {
return &FilterResponse{
Allowed: false,
Reason: fmt.Sprintf("rate limit exceeded: %d requests/minute (limit: %d)", len(recent), limit),
}, nil
}
return &FilterResponse{Allowed: true}, nil
}
type TokenCountFilter struct{}
func (f *TokenCountFilter) Name() string { return "token_count" }
func (f *TokenCountFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
count := len(req.UserMessage) / 4
if count > 50000 {
return &FilterResponse{
Allowed: true,
TokenCount: count,
Reason: fmt.Sprintf("large message: ~%d tokens", count),
}, nil
}
return &FilterResponse{Allowed: true, TokenCount: count}, nil
}
type LoggingFilter struct{}
func (f *LoggingFilter) Name() string { return "logging" }
func (f *LoggingFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
return &FilterResponse{Allowed: true, Metadata: map[string]string{
"provider": req.Provider,
"model": req.Model,
}}, nil
}
type ToxicityFilter struct{}
func (f *ToxicityFilter) Name() string { return "toxicity" }
func (f *ToxicityFilter) Process(ctx context.Context, req *FilterRequest) (*FilterResponse, error) {
return &FilterResponse{Allowed: true}, nil
}
// ── Pipeline HTTP handlers ──
func (s *Server) handlePipelineFilters(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
filters := s.pipeline.ListFilters()
if filters == nil {
filters = []map[string]interface{}{}
}
jsonResp(w, map[string]interface{}{"filters": filters})
return
}
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
}
func (s *Server) handlePipelineToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
name := ""
if parts := splitPath(r.URL.Path); len(parts) > 0 {
name = parts[len(parts)-1]
}
if strings.HasSuffix(r.URL.Path, "/toggle") {
name = strings.TrimSuffix(name, "/toggle")
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request")
return
}
if err := s.pipeline.Toggle(name, req.Enabled); err != nil {
jsonError(w, err.Error())
return
}
jsonResp(w, map[string]interface{}{"name": name, "enabled": req.Enabled})
}
func splitPath(p string) []string {
var parts []string
for _, s := range strings.Split(p, "/") {
if s != "" {
parts = append(parts, s)
}
}
return parts
}
func jsonResp(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
func jsonError(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -11,6 +12,11 @@ import (
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/lessons"
"github.com/muyue/muyue/internal/memory"
"github.com/muyue/muyue/internal/mcpserver"
"github.com/muyue/muyue/internal/plugins"
"github.com/muyue/muyue/internal/rag"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/workflow"
)
@@ -27,9 +33,16 @@ type Server struct {
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
pluginManager *plugins.Manager
hookRegistry *plugins.HookRegistry
browserTestStore *BrowserTestStore
memoryStore *memory.Store
ragStore *rag.Store
pipeline *Pipeline
activeCrushAgents atomic.Int32
activeClaudeAgents atomic.Int32
mcpServer *mcpserver.MCPServer
agentTracker *AgentSessionTracker
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -76,6 +89,33 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
if cfg.Lessons.Enabled {
lessons.EnsureBuiltinLessons()
}
s.hookRegistry = plugins.NewHookRegistry()
s.pluginManager = plugins.NewManager(s.hookRegistry)
pluginPaths := cfg.Plugins.Paths
if len(pluginPaths) == 0 {
pluginPaths = plugins.DefaultPluginPaths()
}
discovered := plugins.DiscoverPlugins(pluginPaths)
for _, dp := range discovered {
if dp.Valid {
p, err := plugins.LoadExecutablePlugin(dp)
if err == nil {
s.pluginManager.Register(p)
}
}
}
s.pluginManager.EnableFromConfig(context.Background(), cfg.Plugins.Enabled, s.agentRegistry)
s.pipeline = NewPipeline()
s.agentTracker = NewAgentSessionTracker()
s.initStarship()
s.routes()
return s
@@ -108,6 +148,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/starship/apply-theme", s.handleApplyStarshipTheme)
s.mux.HandleFunc("/api/providers/validate", s.handleValidateProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/images/generate", s.handleImageGenerate)
s.mux.HandleFunc("/api/images/", s.handleServeImage)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
@@ -157,6 +198,41 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/test/sessions", s.handleBrowserTestSessions)
s.mux.HandleFunc("/api/test/console/", s.handleBrowserTestConsole)
s.mux.HandleFunc("/api/ws/browser-test", s.handleBrowserTestWS)
s.mux.HandleFunc("/api/skills/auto-create", s.handleSkillAutoCreate)
s.mux.HandleFunc("/api/skills/proposals", s.handleSkillProposals)
s.mux.HandleFunc("/api/skills/detail/", s.handleSkillDetail)
s.mux.HandleFunc("/api/plugins", s.handlePlugins)
s.mux.HandleFunc("/api/plugins/", s.handlePluginAction)
s.mux.HandleFunc("/api/lessons", s.handleLessons)
s.mux.HandleFunc("/api/lessons/match", s.handleLessonsMatch)
s.mux.HandleFunc("/api/mcp/discover", s.handleMCPDiscover)
s.mux.HandleFunc("/api/browser/navigate", s.handleBrowserNavigate)
s.mux.HandleFunc("/api/browser/screenshot", s.handleBrowserScreenshot)
s.mux.HandleFunc("/api/browser/action", s.handleBrowserAction)
s.mux.HandleFunc("/api/rag/index", s.handleRAGIndex)
s.mux.HandleFunc("/api/rag/search", s.handleRAGSearch)
s.mux.HandleFunc("/api/rag/status", s.handleRAGStatus)
s.mux.HandleFunc("/api/rag/documents", s.handleRAGDocuments)
s.mux.HandleFunc("/api/rag/index/", s.handleRAGDelete)
s.mux.HandleFunc("/api/pipeline/filters", s.handlePipelineFilters)
s.mux.HandleFunc("/api/pipeline/filters/", s.handlePipelineToggle)
s.mux.HandleFunc("/api/memory", s.handleMemoryList)
s.mux.HandleFunc("/api/memory/create", s.handleMemoryCreate)
s.mux.HandleFunc("/api/memory/", s.handleMemoryOperation)
s.mux.HandleFunc("/api/memory/search", s.handleMemorySearch)
s.mux.HandleFunc("/api/memory/recall", s.handleMemoryRecall)
s.mux.HandleFunc("/api/memory/context", s.handleMemoryContext)
s.mux.HandleFunc("/api/files/content", s.handleFileContent)
s.mux.HandleFunc("/api/mcp-server/status", s.handleMuyueMCPServerStatus)
s.mux.HandleFunc("/api/mcp-server/start", s.handleMuyueMCPServerStart)
s.mux.HandleFunc("/api/mcp-server/stop", s.handleMuyueMCPServerStop)
s.mux.HandleFunc("/api/agent-sessions", s.handleAgentSessionsList)
s.mux.HandleFunc("/api/agent-sessions/", s.handleAgentSessionOutput)
s.mux.HandleFunc("/api/workspaces", s.handleWorkspaceList)
s.mux.HandleFunc("/api/workspace", s.handleWorkspaceSave)
s.mux.HandleFunc("/api/workspace/", s.handleWorkspaceGet)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -227,3 +303,16 @@ func (s *Server) initStarship() {
}
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
}
func (s *Server) buildMemoryContext(query string) string {
store, err := s.ensureMemoryStore()
if err != nil {
return ""
}
injector := memory.NewInjector(store)
ctx, err := injector.BuildContextBlock(query)
if err != nil {
return ""
}
return ctx
}

View File

@@ -51,6 +51,16 @@ type SSHConnection struct {
KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
}
type PluginsConfig struct {
Enabled []string `yaml:"enabled" json:"enabled"`
Paths []string `yaml:"paths,omitempty" json:"paths,omitempty"`
}
type LessonsConfig struct {
Dirs []string `yaml:"dirs,omitempty" json:"dirs,omitempty"`
Enabled bool `yaml:"enabled" json:"enabled"`
}
type MuyueConfig struct {
Version string `yaml:"version" json:"version"`
Profile Profile `yaml:"profile" json:"profile"`
@@ -71,6 +81,8 @@ type MuyueConfig struct {
FontFamily string `yaml:"font_family" json:"font_family"`
Theme string `yaml:"theme" json:"theme"`
} `yaml:"terminal" json:"terminal"`
Plugins PluginsConfig `yaml:"plugins" json:"plugins"`
Lessons LessonsConfig `yaml:"lessons" json:"lessons"`
}
type TerminalTheme struct {
@@ -322,5 +334,11 @@ func Default() *MuyueConfig {
cfg.Terminal.PromptTheme = "zerotwo"
cfg.Terminal.FontSize = 14
cfg.Plugins.Enabled = []string{}
cfg.Plugins.Paths = []string{}
cfg.Lessons.Enabled = true
cfg.Lessons.Dirs = []string{}
return cfg
}

513
internal/lessons/lesson.go Normal file
View File

@@ -0,0 +1,513 @@
package lessons
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
type LessonMode string
const (
ModeInteractive LessonMode = "interactive"
ModeAutonomous LessonMode = "autonomous"
ModeBoth LessonMode = "both"
)
type Lesson struct {
Name string `yaml:"name" json:"name"`
Title string `yaml:"title" json:"title"`
Description string `yaml:"description" json:"description"`
Category string `yaml:"category" json:"category"`
Triggers Triggers `yaml:"triggers" json:"triggers"`
Content string `yaml:"content" json:"content"`
Mode LessonMode `yaml:"mode" json:"mode"`
Priority int `yaml:"priority" json:"priority"`
Enabled bool `yaml:"enabled" json:"enabled"`
Path string `yaml:"-" json:"path,omitempty"`
}
type Triggers struct {
Keywords []string `yaml:"keywords" json:"keywords"`
Tools []string `yaml:"tools" json:"tools"`
Patterns []string `yaml:"patterns" json:"patterns"`
}
type MatchContext struct {
Message string `json:"message"`
ToolsUsed []string `json:"tools_used,omitempty"`
Mode string `json:"mode,omitempty"`
}
type MatchResult struct {
Lesson *Lesson `json:"lesson"`
Score float64 `json:"score"`
}
type LessonFrontmatter struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Description string `yaml:"description"`
Category string `yaml:"category"`
Mode LessonMode `yaml:"mode"`
Priority int `yaml:"priority"`
Enabled *bool `yaml:"enabled"`
Triggers Triggers `yaml:"triggers"`
}
type LessonIndex struct {
mu sync.RWMutex
lessons []*Lesson
paths []string
cache map[string]time.Time
}
var (
globalIndex *LessonIndex
globalIndexOnce sync.Once
)
func GetIndex() *LessonIndex {
globalIndexOnce.Do(func() {
globalIndex = &LessonIndex{
lessons: make([]*Lesson, 0),
cache: make(map[string]time.Time),
}
globalIndex.paths = DefaultLessonDirs()
globalIndex.Reload()
})
return globalIndex
}
func DefaultLessonDirs() []string {
var dirs []string
home, _ := os.UserHomeDir()
if home != "" {
dirs = append(dirs,
filepath.Join(home, ".muyue", "lessons"),
)
}
configDir, err := os.UserConfigDir()
if err == nil {
dirs = append(dirs, filepath.Join(configDir, "muyue", "lessons"))
}
if extra := os.Getenv("MUYUE_LESSONS_EXTRA_DIRS"); extra != "" {
for _, d := range strings.Split(extra, ":") {
d = strings.TrimSpace(d)
if d != "" {
dirs = append(dirs, d)
}
}
}
return dirs
}
func (idx *LessonIndex) Reload() {
idx.mu.Lock()
defer idx.mu.Unlock()
var all []*Lesson
seen := make(map[string]bool)
for _, dir := range idx.paths {
files, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
continue
}
for _, f := range files {
realPath, _ := filepath.EvalSymlinks(f)
if realPath == "" {
realPath = f
}
if seen[realPath] {
continue
}
seen[realPath] = true
lesson, err := ParseLessonFile(f)
if err != nil {
continue
}
lesson.Path = f
if lesson.Category == "" {
lesson.Category = filepath.Base(filepath.Dir(f))
}
all = append(all, lesson)
}
subDirs, _ := filepath.Glob(filepath.Join(dir, "*"))
for _, subDir := range subDirs {
info, err := os.Stat(subDir)
if err != nil || !info.IsDir() {
continue
}
category := filepath.Base(subDir)
subFiles, _ := filepath.Glob(filepath.Join(subDir, "*.md"))
for _, f := range subFiles {
realPath, _ := filepath.EvalSymlinks(f)
if realPath == "" {
realPath = f
}
if seen[realPath] {
continue
}
seen[realPath] = true
lesson, err := ParseLessonFile(f)
if err != nil {
continue
}
lesson.Path = f
if lesson.Category == "" {
lesson.Category = category
}
all = append(all, lesson)
}
}
}
idx.lessons = all
}
func (idx *LessonIndex) All() []*Lesson {
idx.mu.RLock()
defer idx.mu.RUnlock()
result := make([]*Lesson, 0, len(idx.lessons))
for _, l := range idx.lessons {
if l.Enabled {
result = append(result, l)
}
}
return result
}
func (idx *LessonIndex) Get(name string) *Lesson {
idx.mu.RLock()
defer idx.mu.RUnlock()
for _, l := range idx.lessons {
if l.Name == name {
return l
}
}
return nil
}
func (idx *LessonIndex) Count() int {
idx.mu.RLock()
defer idx.mu.RUnlock()
return len(idx.lessons)
}
func ParseLessonFile(path string) (*Lesson, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read lesson: %w", err)
}
content := string(data)
var frontmatter LessonFrontmatter
var body string
if strings.HasPrefix(content, "---") {
end := strings.Index(content[3:], "---")
if end != -1 {
fm := content[3 : end+3]
body = strings.TrimSpace(content[end+6:])
if err := yaml.Unmarshal([]byte(fm), &frontmatter); err != nil {
body = content
}
} else {
body = content
}
} else {
body = content
}
enabled := true
if frontmatter.Enabled != nil {
enabled = *frontmatter.Enabled
}
if frontmatter.Mode == "" {
frontmatter.Mode = ModeBoth
}
name := frontmatter.Name
if name == "" {
name = strings.TrimSuffix(filepath.Base(path), ".md")
name = strings.ReplaceAll(name, "-", "_")
}
return &Lesson{
Name: name,
Title: frontmatter.Title,
Description: frontmatter.Description,
Category: frontmatter.Category,
Triggers: frontmatter.Triggers,
Content: body,
Mode: frontmatter.Mode,
Priority: frontmatter.Priority,
Enabled: enabled,
}, nil
}
func Match(lessons []*Lesson, ctx MatchContext) []*MatchResult {
var results []*MatchResult
msgLower := strings.ToLower(ctx.Message)
for _, l := range lessons {
if !l.Enabled {
continue
}
score := 0.0
for _, kw := range l.Triggers.Keywords {
if containsKeyword(msgLower, strings.ToLower(kw)) {
score += 1.0
}
}
for _, pattern := range l.Triggers.Patterns {
re, err := regexp.Compile("(?i)" + pattern)
if err == nil && re.MatchString(ctx.Message) {
score += 1.5
}
}
if len(ctx.ToolsUsed) > 0 && len(l.Triggers.Tools) > 0 {
for _, usedTool := range ctx.ToolsUsed {
for _, triggerTool := range l.Triggers.Tools {
if usedTool == triggerTool {
score += 2.0
break
}
}
}
}
if l.Name != "" {
nameLower := strings.ToLower(l.Name)
if strings.Contains(msgLower, nameLower) {
score += 1.5
}
}
if score > 0 {
results = append(results, &MatchResult{
Lesson: l,
Score: score,
})
}
}
sortResults(results)
return results
}
func AutoInclude(systemPrompt string, lessons []*Lesson, ctx MatchContext, maxLessons int) string {
if maxLessons <= 0 {
maxLessons = 5
}
results := Match(lessons, ctx)
if len(results) == 0 {
return systemPrompt
}
if len(results) > maxLessons {
results = results[:maxLessons]
}
var lessonBlock strings.Builder
lessonBlock.WriteString("\n\n--- Active Lessons ---\n\n")
for _, r := range results {
lessonBlock.WriteString(fmt.Sprintf("## %s", r.Lesson.Name))
if r.Lesson.Title != "" {
lessonBlock.WriteString(fmt.Sprintf(" (%s)", r.Lesson.Title))
}
lessonBlock.WriteString("\n")
lessonBlock.WriteString(r.Lesson.Content)
lessonBlock.WriteString("\n\n")
}
return systemPrompt + lessonBlock.String()
}
func EnsureBuiltinLessons() error {
home, _ := os.UserHomeDir()
if home == "" {
return nil
}
lessonsDir := filepath.Join(home, ".muyue", "lessons")
if err := os.MkdirAll(lessonsDir, 0755); err != nil {
return err
}
for _, lesson := range BuiltinLessons() {
path := filepath.Join(lessonsDir, lesson.Name+".md")
if _, err := os.Stat(path); err == nil {
continue
}
if err := WriteLesson(path, lesson); err != nil {
_ = err
}
}
return nil
}
func WriteLesson(path string, lesson *Lesson) error {
var sb strings.Builder
sb.WriteString("---\n")
data, err := yaml.Marshal(&LessonFrontmatter{
Name: lesson.Name,
Title: lesson.Title,
Description: lesson.Description,
Category: lesson.Category,
Mode: lesson.Mode,
Priority: lesson.Priority,
Enabled: &lesson.Enabled,
Triggers: lesson.Triggers,
})
if err != nil {
return err
}
sb.WriteString(string(data))
sb.WriteString("---\n\n")
sb.WriteString(lesson.Content)
return os.WriteFile(path, []byte(sb.String()), 0644)
}
func BuiltinLessons() []*Lesson {
return []*Lesson{
{
Name: "code_style",
Title: "Code Style Guidelines",
Description: "Enforce consistent code style and formatting",
Category: "development",
Triggers: Triggers{
Keywords: []string{"code style", "formatting", "lint", "format", "indentation", "naming convention"},
Tools: []string{"terminal"},
},
Content: `- Follow the existing code style in each file
- Use consistent indentation (match surrounding code)
- Prefer descriptive variable names over abbreviations
- Keep functions focused and small
- Add error handling for all external calls`,
Mode: ModeBoth,
Priority: 5,
Enabled: true,
},
{
Name: "git_workflow",
Title: "Git Workflow Best Practices",
Description: "Guidelines for git operations and commit practices",
Category: "development",
Triggers: Triggers{
Keywords: []string{"git", "commit", "branch", "merge", "pull request", "rebase"},
Tools: []string{"terminal"},
},
Content: `- Write clear, descriptive commit messages
- Use conventional commits format when applicable
- Keep commits atomic and focused
- Don't commit sensitive data or secrets
- Test before committing`,
Mode: ModeBoth,
Priority: 5,
Enabled: true,
},
{
Name: "error_handling",
Title: "Error Handling Patterns",
Description: "Robust error handling guidelines",
Category: "development",
Triggers: Triggers{
Keywords: []string{"error", "panic", "exception", "crash", "fail", "nil pointer"},
Tools: []string{"terminal", "read_file"},
Patterns: []string{`err\s*!=\s*nil`, `panic\(`, `log\.Fatal`},
},
Content: `- Always check errors from external calls
- Provide context when wrapping errors
- Use sentinel errors for expected conditions
- Log errors with enough context for debugging
- Don't silently ignore errors`,
Mode: ModeBoth,
Priority: 6,
Enabled: true,
},
{
Name: "testing",
Title: "Testing Best Practices",
Description: "Guidelines for writing effective tests",
Category: "development",
Triggers: Triggers{
Keywords: []string{"test", "testing", "unit test", "integration test", "coverage"},
Tools: []string{"terminal"},
},
Content: `- Write tests for critical paths first
- Use table-driven tests for multiple cases
- Keep tests independent and deterministic
- Test error paths, not just happy paths
- Aim for meaningful coverage, not just percentage`,
Mode: ModeBoth,
Priority: 5,
Enabled: true,
},
{
Name: "security",
Title: "Security Guidelines",
Description: "Security best practices for development",
Category: "development",
Triggers: Triggers{
Keywords: []string{"security", "vulnerability", "inject", "sanitize", "auth", "secret", "password", "token"},
Tools: []string{"terminal", "read_file", "web_fetch"},
Patterns: []string{`SELECT\s.*\+`, `exec\.Command.*\+`, `os\.Getenv.*KEY`},
},
Content: `- Never log or expose secrets, API keys, or tokens
- Validate and sanitize all user input
- Use parameterized queries for database operations
- Keep dependencies updated
- Don't hardcode credentials`,
Mode: ModeBoth,
Priority: 8,
Enabled: true,
},
}
}
func containsKeyword(text, keyword string) bool {
if keyword == "*" {
return true
}
return strings.Contains(text, keyword)
}
func sortResults(results []*MatchResult) {
for i := 0; i < len(results)-1; i++ {
for j := i + 1; j < len(results); j++ {
if results[j].Score > results[i].Score {
results[i], results[j] = results[j], results[i]
}
}
}
}

369
internal/mcp/discover.go Normal file
View File

@@ -0,0 +1,369 @@
package mcp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
type DiscoveredMCPServer struct {
Name string `json:"name"`
Command string `json:"command"`
Source string `json:"source"`
Args []string `json:"args,omitempty"`
Installed bool `json:"installed"`
Running bool `json:"running"`
Category string `json:"category,omitempty"`
}
type DiscoveryResult struct {
Servers []DiscoveredMCPServer `json:"servers"`
ScanPaths []string `json:"scan_paths"`
TotalFound int `json:"total_found"`
NewServers int `json:"new_servers"`
}
type ToolDiscovery struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
type ServerCapabilities struct {
Name string `json:"name"`
Tools []ToolDiscovery `json:"tools"`
Version string `json:"version,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
var (
capCache map[string]*ServerCapabilities
capCacheMu sync.RWMutex
)
func init() {
capCache = make(map[string]*ServerCapabilities)
}
func DiscoverSystemServers() *DiscoveryResult {
result := &DiscoveryResult{}
knownNames := make(map[string]bool)
for _, s := range knownMCPServers {
knownNames[s.Name] = true
}
reg, _ := LoadRegistry()
if reg != nil {
for _, s := range reg.Servers {
knownNames[s.Name] = true
}
}
var servers []DiscoveredMCPServer
npmServers := discoverNpmGlobalServers(knownNames)
servers = append(servers, npmServers...)
pipServers := discoverPipServers(knownNames)
servers = append(servers, pipServers...)
pathServers := discoverPathServers(knownNames)
servers = append(servers, pathServers...)
result.Servers = servers
result.TotalFound = len(servers)
result.NewServers = countNew(servers, knownNames)
paths := []string{}
if path := os.Getenv("PATH"); path != "" {
paths = strings.Split(path, ":")
}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths,
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".npm-global", "bin"),
)
}
result.ScanPaths = paths
return result
}
func discoverNpmGlobalServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
npx, err := exec.LookPath("npx")
if err != nil {
return servers
}
patterns := []struct {
pkg string
name string
cat string
}{
{"@anthropic/mcp-server-fetch", "anthropic-fetch", "web"},
{"@anthropic/mcp-server-sqlite", "anthropic-sqlite", "database"},
{"@anthropic/mcp-server-brave-search", "anthropic-brave-search", "web"},
{"@anthropic/mcp-server-filesystem", "anthropic-filesystem", "core"},
{"@anthropic/mcp-server-github", "anthropic-github", "vcs"},
{"@anthropic/mcp-server-memory", "anthropic-memory", "core"},
{"@anthropic/mcp-server-puppeteer", "anthropic-puppeteer", "web"},
{"@anthropic/mcp-server-sequential-thinking", "anthropic-thinking", "ai"},
}
for _, p := range patterns {
if known[p.name] {
continue
}
servers = append(servers, DiscoveredMCPServer{
Name: p.name,
Command: npx,
Source: "npm-global",
Args: []string{"-y", p.pkg},
Installed: true,
Category: p.cat,
})
}
return servers
}
func discoverPipServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
pipCmds := []string{"pip", "pip3", "uv"}
for _, pip := range pipCmds {
if _, err := exec.LookPath(pip); err != nil {
continue
}
cmd := exec.Command(pip, "list", "--format=json")
output, err := cmd.CombinedOutput()
if err != nil {
continue
}
var packages []struct {
Name string `json:"name"`
Version string `json:"version"`
}
if err := json.Unmarshal(output, &packages); err != nil {
continue
}
for _, pkg := range packages {
nameLower := strings.ToLower(pkg.Name)
if !strings.Contains(nameLower, "mcp") {
continue
}
serverName := strings.ReplaceAll(nameLower, "_", "-")
if strings.HasPrefix(serverName, "mcp-") {
serverName = serverName[4:]
}
if known[serverName] {
continue
}
binName := strings.ReplaceAll(pkg.Name, "-", "_")
if _, err := exec.LookPath(binName); err != nil {
binName = pkg.Name
if _, err := exec.LookPath(binName); err != nil {
continue
}
}
servers = append(servers, DiscoveredMCPServer{
Name: serverName,
Command: binName,
Source: "pip",
Installed: true,
Category: "python",
})
}
break
}
return servers
}
func discoverPathServers(known map[string]bool) []DiscoveredMCPServer {
var servers []DiscoveredMCPServer
home, _ := os.UserHomeDir()
searchDirs := []string{}
if home != "" {
searchDirs = append(searchDirs,
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".muyue", "mcp-servers"),
)
}
for _, dir := range searchDirs {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.Contains(strings.ToLower(name), "mcp") {
continue
}
serverName := strings.ToLower(name)
serverName = strings.TrimPrefix(serverName, "mcp-")
serverName = strings.TrimPrefix(serverName, "mcp_")
serverName = strings.TrimSuffix(serverName, ".sh")
if known[serverName] {
continue
}
fullPath := filepath.Join(dir, name)
if info, err := os.Stat(fullPath); err == nil && info.Mode()&0111 != 0 {
servers = append(servers, DiscoveredMCPServer{
Name: serverName,
Command: fullPath,
Source: "path",
Installed: true,
Category: "local",
})
}
}
}
return servers
}
func DiscoverServerTools(serverName string) (*ServerCapabilities, error) {
capCacheMu.RLock()
if caps, ok := capCache[serverName]; ok {
capCacheMu.RUnlock()
return caps, nil
}
capCacheMu.RUnlock()
server, err := findServerConfig(serverName)
if err != nil {
return nil, err
}
script := buildListToolsScript(server)
if script == "" {
return &ServerCapabilities{
Name: serverName,
Tools: []ToolDiscovery{},
}, nil
}
cmd := exec.Command(server.Command, append(server.Args, "--list-tools")...)
output, err := cmd.CombinedOutput()
_ = script
if err != nil {
return discoverToolsFallback(serverName, server)
}
var caps ServerCapabilities
if jsonErr := json.Unmarshal(output, &caps); jsonErr != nil {
caps = ServerCapabilities{
Name: serverName,
Tools: []ToolDiscovery{
{
Name: serverName,
Description: "MCP server: " + serverName,
},
},
}
}
capCacheMu.Lock()
capCache[serverName] = &caps
capCacheMu.Unlock()
return &caps, nil
}
func discoverToolsFallback(name string, server *RegistryServer) (*ServerCapabilities, error) {
caps := &ServerCapabilities{
Name: name,
Tools: []ToolDiscovery{
{
Name: name,
Description: server.Description,
},
},
}
capCacheMu.Lock()
capCache[name] = caps
capCacheMu.Unlock()
return caps, nil
}
func findServerConfig(name string) (*RegistryServer, error) {
reg, err := LoadRegistry()
if err != nil {
return nil, err
}
for i := range reg.Servers {
if reg.Servers[i].Name == name {
return &reg.Servers[i], nil
}
}
for _, s := range knownMCPServers {
if s.Name == name {
return &RegistryServer{
Name: s.Name,
Command: s.Command,
Args: s.Args,
Env: s.Env,
}, nil
}
}
return nil, fmt.Errorf("server %q not found", name)
}
func buildListToolsScript(server *RegistryServer) string {
return ""
}
func InvalidateCapabilitiesCache() {
capCacheMu.Lock()
defer capCacheMu.Unlock()
capCache = make(map[string]*ServerCapabilities)
}
func GetCachedCapabilities(name string) *ServerCapabilities {
capCacheMu.RLock()
defer capCacheMu.RUnlock()
return capCache[name]
}
func countNew(servers []DiscoveredMCPServer, known map[string]bool) int {
count := 0
for _, s := range servers {
if !known[s.Name] {
count++
}
}
return count
}

View File

@@ -0,0 +1,556 @@
package mcpserver
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
type ToolCall struct {
Name string `json:"name"`
Args json.RawMessage `json:"arguments"`
}
type ToolResult struct {
Content []ContentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
var tools = []Tool{
{
Name: "terminal_exec",
Description: "Execute a command in the terminal and return the output",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{"type": "string", "description": "The command to execute"},
"cwd": map[string]interface{}{"type": "string", "description": "Working directory (optional)"},
"timeout": map[string]interface{}{"type": "integer", "description": "Timeout in seconds (default 30)"},
},
"required": []string{"command"},
},
},
{
Name: "file_read",
Description: "Read the contents of a file",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
"offset": map[string]interface{}{"type": "integer", "description": "Line offset to start reading from (0-based)"},
"limit": map[string]interface{}{"type": "integer", "description": "Maximum number of lines to read"},
},
"required": []string{"path"},
},
},
{
Name: "file_write",
Description: "Write content to a file, creating it if needed",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Path to the file"},
"content": map[string]interface{}{"type": "string", "description": "Content to write"},
},
"required": []string{"path", "content"},
},
},
{
Name: "search",
Description: "Search for files by name pattern",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
"pattern": map[string]interface{}{"type": "string", "description": "Glob pattern to match filenames"},
},
"required": []string{"path", "pattern"},
},
},
{
Name: "grep",
Description: "Search file contents for a pattern",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{"type": "string", "description": "Directory to search in"},
"pattern": map[string]interface{}{"type": "string", "description": "Text or regex pattern to search for"},
},
"required": []string{"path", "pattern"},
},
},
{
Name: "system_info",
Description: "Get system information (OS, CPU, memory, disk)",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
}
type MCPServer struct {
port int
server *http.Server
mu sync.Mutex
sseClients map[string]chan SSEEvent
sseClientsMu sync.Mutex
}
type SSEEvent struct {
Event string
Data string
}
func New(port int) *MCPServer {
return &MCPServer{
port: port,
sseClients: make(map[string]chan SSEEvent),
}
}
func (m *MCPServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/", m.handleSSE)
mux.HandleFunc("/message", m.handleHTTPMessage)
mux.HandleFunc("/mcp", m.handleStreamableHTTP)
m.server = &http.Server{
Addr: fmt.Sprintf("127.0.0.1:%d", m.port),
Handler: mux,
}
go func() {
if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("[MCP Server] Error: %v\n", err)
}
}()
return nil
}
func (m *MCPServer) Stop() error {
if m.server != nil {
return m.server.Close()
}
return nil
}
func (m *MCPServer) Port() int {
return m.port
}
func (m *MCPServer) handleSSE(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
ch := make(chan SSEEvent, 32)
m.sseClientsMu.Lock()
m.sseClients[clientID] = ch
m.sseClientsMu.Unlock()
defer func() {
m.sseClientsMu.Lock()
delete(m.sseClients, clientID)
m.sseClientsMu.Unlock()
close(ch)
}()
fmt.Fprintf(w, "event: endpoint\ndata: /message?clientId=%s\n\n", clientID)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
for {
select {
case evt, ok := <-ch:
if !ok {
return
}
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.Event, evt.Data)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
case <-r.Context().Done():
return
}
}
}
func (m *MCPServer) handleHTTPMessage(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
return
}
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
body, err := io.ReadAll(r.Body)
if err != nil {
m.writeRPCError(w, nil, -32700, "Parse error")
return
}
resp := m.handleJSONRPC(body)
json.NewEncoder(w).Encode(resp)
}
func (m *MCPServer) handleStreamableHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
return
}
if r.Method == "GET" {
m.handleSSE(w, r)
return
}
if r.Method != "POST" {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
body, err := io.ReadAll(r.Body)
if err != nil {
m.writeRPCError(w, nil, -32700, "Parse error")
return
}
resp := m.handleJSONRPC(body)
json.NewEncoder(w).Encode(resp)
}
func (m *MCPServer) handleJSONRPC(body []byte) JSONRPCResponse {
var req JSONRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
return JSONRPCResponse{
JSONRPC: "2.0",
Error: &RPCError{Code: -32700, Message: "Parse error"},
}
}
switch req.Method {
case "initialize":
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"protocolVersion": "2024-11-05",
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": "muyue",
"version": "0.9.0",
},
},
}
case "notifications/initialized":
return JSONRPCResponse{JSONRPC: "2.0", ID: req.ID}
case "tools/list":
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"tools": tools,
},
}
case "tools/call":
var params struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &RPCError{Code: -32602, Message: "Invalid params"},
}
}
result := m.executeTool(params.Name, params.Arguments)
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
default:
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &RPCError{Code: -32601, Message: fmt.Sprintf("Method not found: %s", req.Method)},
}
}
}
func (m *MCPServer) executeTool(name string, args json.RawMessage) ToolResult {
switch name {
case "terminal_exec":
return m.toolTerminalExec(args)
case "file_read":
return m.toolFileRead(args)
case "file_write":
return m.toolFileWrite(args)
case "search":
return m.toolSearch(args)
case "grep":
return m.toolGrep(args)
case "system_info":
return m.toolSystemInfo()
default:
return ToolResult{
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Unknown tool: %s", name)}},
IsError: true,
}
}
}
func (m *MCPServer) toolTerminalExec(args json.RawMessage) ToolResult {
var params struct {
Command string `json:"command"`
Cwd string `json:"cwd"`
Timeout int `json:"timeout"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
timeout := params.Timeout
if timeout <= 0 {
timeout = 30
}
ctx := exec.Command("sh", "-c", params.Command)
if params.Cwd != "" {
ctx.Dir = params.Cwd
}
var stdout, stderr strings.Builder
ctx.Stdout = &stdout
ctx.Stderr = &stderr
done := make(chan error, 1)
go func() { done <- ctx.Run() }()
select {
case err := <-done:
output := stdout.String()
if errMsg := stderr.String(); errMsg != "" {
output += "\n" + errMsg
}
if err != nil {
output += fmt.Sprintf("\nExit error: %v", err)
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: output}}}
case <-time.After(time.Duration(timeout) * time.Second):
ctx.Process.Kill()
return ToolResult{
Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Command timed out after %ds\n%s%s", timeout, stdout.String(), stderr.String())}},
IsError: true,
}
}
}
func (m *MCPServer) toolFileRead(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(params.Path, "~", home)
data, err := os.ReadFile(path)
if err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error reading file: %v", err)}}, IsError: true}
}
lines := strings.Split(string(data), "\n")
start := params.Offset
if start < 0 {
start = 0
}
end := len(lines)
if params.Limit > 0 && start+params.Limit < end {
end = start + params.Limit
}
if start > len(lines) {
start = len(lines)
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines[start:end], "\n")}}}
}
func (m *MCPServer) toolFileWrite(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
path := strings.ReplaceAll(params.Path, "~", home)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error creating directory: %v", err)}}, IsError: true}
}
if err := os.WriteFile(path, []byte(params.Content), 0644); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Error writing file: %v", err)}}, IsError: true}
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: fmt.Sprintf("Successfully wrote %d bytes to %s", len(params.Content), path)}}}
}
func (m *MCPServer) toolSearch(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
basePath := strings.ReplaceAll(params.Path, "~", home)
cmd := exec.Command("find", basePath, "-name", params.Pattern, "-type", "f", "-not", "-path", "*/node_modules/*", "-not", "-path", "*/.git/*")
output, err := cmd.CombinedOutput()
if err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: string(output)}}}
}
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 100 {
lines = lines[:100]
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
}
func (m *MCPServer) toolGrep(args json.RawMessage) ToolResult {
var params struct {
Path string `json:"path"`
Pattern string `json:"pattern"`
}
if err := json.Unmarshal(args, &params); err != nil {
return ToolResult{Content: []ContentBlock{{Type: "text", Text: err.Error()}}, IsError: true}
}
home, _ := os.UserHomeDir()
basePath := strings.ReplaceAll(params.Path, "~", home)
cmd := exec.Command("grep", "-rn", "--include=*", params.Pattern, basePath)
output, _ := cmd.CombinedOutput()
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 50 {
lines = lines[:50]
lines = append(lines, fmt.Sprintf("... (%d more results truncated)", len(strings.Split(string(output), "\n"))-50))
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: strings.Join(lines, "\n")}}}
}
func (m *MCPServer) toolSystemInfo() ToolResult {
var info strings.Builder
info.WriteString(fmt.Sprintf("OS: %s/%s\n", runtime.GOOS, runtime.GOARCH))
info.WriteString(fmt.Sprintf("CPUs: %d\n", runtime.NumCPU()))
if out, err := exec.Command("uname", "-a").Output(); err == nil {
info.WriteString(fmt.Sprintf("Kernel: %s", string(out)))
}
if out, err := exec.Command("free", "-h").Output(); err == nil {
info.WriteString(fmt.Sprintf("Memory:\n%s", string(out)))
}
if out, err := exec.Command("df", "-h", "/").Output(); err == nil {
info.WriteString(fmt.Sprintf("Disk:\n%s", string(out)))
}
if out, err := exec.Command("uptime").Output(); err == nil {
info.WriteString(fmt.Sprintf("Uptime: %s", string(out)))
}
return ToolResult{Content: []ContentBlock{{Type: "text", Text: info.String()}}}
}
func (m *MCPServer) writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: id,
Error: &RPCError{Code: code, Message: msg},
}
json.NewEncoder(w).Encode(resp)
}

140
internal/memory/inject.go Normal file
View File

@@ -0,0 +1,140 @@
package memory
import (
"fmt"
"strings"
"time"
)
type MemoryInjector struct {
store *Store
}
func NewInjector(store *Store) *MemoryInjector {
return &MemoryInjector{store: store}
}
func (mi *MemoryInjector) BuildContextBlock(query string) (string, error) {
var contextParts []string
preferences, err := mi.store.RecallPreferences()
if err == nil && len(preferences) > 0 {
var prefLines []string
for _, p := range preferences {
prefLines = append(prefLines, fmt.Sprintf("- %s: %s", p.Key, p.Content))
}
contextParts = append(contextParts,
"[User Preferences]\n"+strings.Join(prefLines, "\n"))
}
facts, err := mi.store.RecallFacts()
if err == nil && len(facts) > 0 {
var factLines []string
for _, f := range facts {
factLines = append(factLines, fmt.Sprintf("- %s: %s", f.Key, f.Content))
}
contextParts = append(contextParts,
"[Known Facts]\n"+strings.Join(factLines, "\n"))
}
if query != "" {
relevant, err := mi.store.Recall(query, 5)
if err == nil && len(relevant) > 0 {
var relLines []string
for _, r := range relevant {
relLines = append(relLines, fmt.Sprintf("- [%s] %s: %s", r.Type, r.Key, truncate(r.Content, 150)))
}
contextParts = append(contextParts,
"[Relevant Memories]\n"+strings.Join(relLines, "\n"))
}
}
recentCutoff := time.Now().Add(-24 * time.Hour)
recent, err := mi.store.RecallRecent(recentCutoff, 5)
if err == nil && len(recent) > 0 {
var recentLines []string
for _, r := range recent {
recentLines = append(recentLines, fmt.Sprintf("- [%s] %s", r.Type, truncate(r.Content, 100)))
}
contextParts = append(contextParts,
"[Recent Context]\n"+strings.Join(recentLines, "\n"))
}
if len(contextParts) == 0 {
return "", nil
}
return fmt.Sprintf("<memory-context>\n[System note: NOT new user input — recalled context]\n%s\n</memory-context>",
strings.Join(contextParts, "\n\n")), nil
}
func (mi *MemoryInjector) BuildSystemPromptBlock() (string, error) {
preferences, err := mi.store.RecallPreferences()
if err != nil || len(preferences) == 0 {
return "", nil
}
var lines []string
lines = append(lines, "Known user preferences:")
for _, p := range preferences {
lines = append(lines, fmt.Sprintf("- %s: %s", p.Key, p.Content))
}
return strings.Join(lines, "\n"), nil
}
func (mi *MemoryInjector) ExtractAndStore(userMessage, assistantMessage string) error {
pref := extractPreference(userMessage)
if pref != "" {
if err := mi.store.StorePreference("detected", pref); err != nil {
return fmt.Errorf("store preference: %w", err)
}
}
if assistantMessage != "" {
ctx := extractContext(assistantMessage)
if ctx != "" {
if err := mi.store.StoreContext("conversation", ctx); err != nil {
return fmt.Errorf("store context: %w", err)
}
}
}
return nil
}
func extractPreference(message string) string {
indicators := []string{
"i prefer", "i like", "i always", "i never", "my favorite",
"i use", "je préfère", "j'aime", "toujours", "jamais",
}
lower := strings.ToLower(message)
for _, ind := range indicators {
if strings.Contains(lower, ind) {
idx := strings.Index(lower, ind)
end := idx + len(ind) + 100
if end > len(message) {
end = len(message)
}
return truncate(message[idx:end], 200)
}
}
return ""
}
func extractContext(message string) string {
if len(message) < 50 {
return ""
}
if len(message) > 500 {
return truncate(message, 500)
}
return message
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}

215
internal/memory/recall.go Normal file
View File

@@ -0,0 +1,215 @@
package memory
import (
"database/sql"
"strings"
"time"
)
type SearchResult struct {
Memory
Score float64 `json:"score"`
}
func (s *Store) Search(query string, limit int) ([]SearchResult, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
normalizedQuery := normalizeQuery(query)
rows, err := s.db.Query(`
SELECT m.id, m.type, m.key, m.content, m.tags, m.source, m.confidence,
m.access_count, m.created_at, m.updated_at,
bm25(memories_fts) as score
FROM memories_fts f
JOIN memories m ON m.rowid = f.rowid
WHERE memories_fts MATCH ?
ORDER BY score
LIMIT ?
`, normalizedQuery, limit)
if err != nil {
return fallbackSearch(s.db, query, limit)
}
defer rows.Close()
return scanSearchResults(rows)
}
func (s *Store) Recall(query string, limit int) ([]Memory, error) {
results, err := s.Search(query, limit)
if err != nil {
return nil, err
}
memories := make([]Memory, len(results))
for i, r := range results {
memories[i] = r.Memory
}
return memories, nil
}
func (s *Store) RecallByType(memType MemoryType, limit int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE type = ?
ORDER BY access_count DESC, updated_at DESC
LIMIT ?
`, string(memType), limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) RecallRecent(since time.Time, limit int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 20
}
rows, err := s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE updated_at >= ?
ORDER BY updated_at DESC
LIMIT ?
`, since, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) RecallPreferences() ([]Memory, error) {
return s.RecallByType(TypePreference, 50)
}
func (s *Store) RecallFacts() ([]Memory, error) {
return s.RecallByType(TypeFact, 50)
}
func (s *Store) StorePreference(key, content string) error {
return s.Store(&Memory{
Type: TypePreference,
Key: key,
Content: content,
Source: "user",
Confidence: 0.9,
})
}
func (s *Store) StoreContext(key, content string) error {
return s.Store(&Memory{
Type: TypeContext,
Key: key,
Content: content,
Source: "conversation",
Confidence: 0.7,
})
}
func (s *Store) StoreSummary(sessionID, summary string) error {
return s.Store(&Memory{
Type: TypeSummary,
Key: "session:" + sessionID,
Content: summary,
Source: "auto",
Confidence: 0.8,
})
}
func (s *Store) StoreFact(key, content string) error {
return s.Store(&Memory{
Type: TypeFact,
Key: key,
Content: content,
Source: "auto",
Confidence: 0.85,
})
}
func normalizeQuery(query string) string {
words := strings.Fields(strings.ToLower(query))
var escaped []string
for _, w := range words {
if len(w) > 0 {
escaped = append(escaped, w+"*")
}
}
return strings.Join(escaped, " OR ")
}
func fallbackSearch(db *sql.DB, query string, limit int) ([]SearchResult, error) {
likePattern := "%" + strings.ToLower(query) + "%"
rows, err := db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories
WHERE LOWER(key) LIKE ? OR LOWER(content) LIKE ? OR LOWER(tags) LIKE ?
ORDER BY updated_at DESC
LIMIT ?
`, likePattern, likePattern, likePattern, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []SearchResult
for rows.Next() {
var m Memory
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return results, err
}
score := computeFallbackScore(m, query)
results = append(results, SearchResult{Memory: m, Score: score})
}
return results, nil
}
func computeFallbackScore(m Memory, query string) float64 {
score := m.Confidence * 0.5
lower := strings.ToLower(query)
if strings.Contains(strings.ToLower(m.Key), lower) {
score += 0.3
}
if strings.Contains(strings.ToLower(m.Content), lower) {
score += 0.2
}
score += float64(m.AccessCount) * 0.01
return score
}
func scanSearchResults(rows *sql.Rows) ([]SearchResult, error) {
var results []SearchResult
for rows.Next() {
var m Memory
var score float64
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source,
&m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt, &score)
if err != nil {
return results, err
}
results = append(results, SearchResult{Memory: m, Score: score})
}
return results, nil
}

276
internal/memory/store.go Normal file
View File

@@ -0,0 +1,276 @@
package memory
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
type MemoryType string
const (
TypePreference MemoryType = "preference"
TypeContext MemoryType = "context"
TypeSummary MemoryType = "summary"
TypeFact MemoryType = "fact"
TypePattern MemoryType = "pattern"
)
type Memory struct {
ID string `json:"id"`
Type MemoryType `json:"type"`
Key string `json:"key"`
Content string `json:"content"`
Tags string `json:"tags,omitempty"`
Source string `json:"source,omitempty"`
Confidence float64 `json:"confidence,omitempty"`
AccessCount int `json:"access_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store struct {
db *sql.DB
path string
mu sync.RWMutex
}
func NewStore() (*Store, error) {
dbPath, err := dbPath()
if err != nil {
return nil, fmt.Errorf("get db path: %w", err)
}
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("create memory dir: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open memory db: %w", err)
}
db.SetMaxOpenConns(1)
s := &Store{db: db, path: dbPath}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) Store(m *Memory) error {
s.mu.Lock()
defer s.mu.Unlock()
if m.ID == "" {
m.ID = generateID()
}
now := time.Now()
if m.CreatedAt.IsZero() {
m.CreatedAt = now
}
m.UpdatedAt = now
_, err := s.db.Exec(`
INSERT INTO memories (id, type, key, content, tags, source, confidence, access_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type,
key = excluded.key,
content = excluded.content,
tags = excluded.tags,
source = excluded.source,
confidence = excluded.confidence,
access_count = excluded.access_count,
updated_at = excluded.updated_at
`, m.ID, string(m.Type), m.Key, m.Content, m.Tags, m.Source, m.Confidence, m.AccessCount, m.CreatedAt, m.UpdatedAt)
return err
}
func (s *Store) Get(id string) (*Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
m := &Memory{}
err := s.db.QueryRow(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE id = ?
`, id).Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err == nil {
s.incrementAccess(id)
}
return m, err
}
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM memories WHERE id = ?`, id)
return err
}
func (s *Store) List(memType MemoryType, limit, offset int) ([]Memory, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
var rows *sql.Rows
var err error
if memType != "" {
rows, err = s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories WHERE type = ?
ORDER BY updated_at DESC LIMIT ? OFFSET ?
`, string(memType), limit, offset)
} else {
rows, err = s.db.Query(`
SELECT id, type, key, content, tags, source, confidence, access_count, created_at, updated_at
FROM memories ORDER BY updated_at DESC LIMIT ? OFFSET ?
`, limit, offset)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanMemories(rows)
}
func (s *Store) Count() (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM memories`).Scan(&count)
return count, err
}
func (s *Store) incrementAccess(id string) {
go func() {
s.db.Exec(`UPDATE memories SET access_count = access_count + 1 WHERE id = ?`, id)
}()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
key TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT DEFAULT '',
source TEXT DEFAULT '',
confidence REAL DEFAULT 0.5,
access_count INTEGER DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
key, content, tags,
content=memories,
content_rowid=rowid
)
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
INSERT INTO memories_fts(rowid, key, content, tags)
VALUES (new.rowid, new.key, new.content, new.tags);
END
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
END
`)
if err != nil {
return err
}
_, err = s.db.Exec(`
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, key, content, tags)
VALUES ('delete', old.rowid, old.key, old.content, old.tags);
INSERT INTO memories_fts(rowid, key, content, tags)
VALUES (new.rowid, new.key, new.content, new.tags);
END
`)
return err
}
func scanMemories(rows *sql.Rows) ([]Memory, error) {
var memories []Memory
for rows.Next() {
var m Memory
err := rows.Scan(&m.ID, &m.Type, &m.Key, &m.Content, &m.Tags, &m.Source, &m.Confidence, &m.AccessCount, &m.CreatedAt, &m.UpdatedAt)
if err != nil {
return memories, err
}
memories = append(memories, m)
}
return memories, nil
}
func dbPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".muyue", "memory", "memories.db"), nil
}
func generateID() string {
return fmt.Sprintf("mem_%d", time.Now().UnixNano())
}

View File

@@ -0,0 +1,189 @@
package memory
import (
"database/sql"
"os"
"path/filepath"
"testing"
"time"
_ "modernc.org/sqlite"
)
func testDBPath(t *testing.T) string {
dir := t.TempDir()
return filepath.Join(dir, "test_memory.db")
}
func newTestStore(t *testing.T) *Store {
t.Helper()
dbPath := testDBPath(t)
db, err := openDB(dbPath)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
s := &Store{db: db, path: dbPath}
if err := s.migrate(); err != nil {
t.Fatalf("migrate: %v", err)
}
return s
}
func openDB(path string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, err
}
return sql.Open("sqlite", path)
}
func TestStoreAndRetrieve(t *testing.T) {
s := newTestStore(t)
m := &Memory{
Type: TypeFact,
Key: "golang_version",
Content: "User uses Go 1.24",
Source: "conversation",
}
if err := s.Store(m); err != nil {
t.Fatalf("store: %v", err)
}
if m.ID == "" {
t.Fatal("expected ID to be set")
}
got, err := s.Get(m.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Key != m.Key {
t.Errorf("expected key %s, got %s", m.Key, got.Key)
}
if got.Content != m.Content {
t.Errorf("expected content %s, got %s", m.Content, got.Content)
}
}
func TestDelete(t *testing.T) {
s := newTestStore(t)
m := &Memory{
Type: TypePreference,
Key: "editor",
Content: "vim",
}
s.Store(m)
if err := s.Delete(m.ID); err != nil {
t.Fatalf("delete: %v", err)
}
_, err := s.Get(m.ID)
if err == nil {
t.Error("expected error after delete")
}
}
func TestList(t *testing.T) {
s := newTestStore(t)
for i := 0; i < 5; i++ {
s.Store(&Memory{
Type: TypeFact,
Key: "fact_" + string(rune('a'+i)),
Content: "content",
})
}
memories, err := s.List(TypeFact, 10, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(memories) != 5 {
t.Errorf("expected 5 memories, got %d", len(memories))
}
}
func TestSearch(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "language", Content: "Go is the primary language"})
s.Store(&Memory{Type: TypeFact, Key: "editor", Content: "VSCode is the editor"})
s.Store(&Memory{Type: TypeContext, Key: "project", Content: "Muyue is a Go project"})
results, err := s.Search("Go language", 10)
if err != nil {
t.Fatalf("search: %v", err)
}
if len(results) == 0 {
t.Error("expected search results")
}
}
func TestRecallPreferences(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypePreference, Key: "theme", Content: "dark"})
s.Store(&Memory{Type: TypePreference, Key: "lang", Content: "fr"})
s.Store(&Memory{Type: TypeFact, Key: "tool", Content: "go"})
prefs, err := s.RecallPreferences()
if err != nil {
t.Fatalf("recall preferences: %v", err)
}
if len(prefs) != 2 {
t.Errorf("expected 2 preferences, got %d", len(prefs))
}
}
func TestRecallRecent(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "old", Content: "old fact"})
recent, err := s.RecallRecent(time.Now().Add(-1*time.Hour), 10)
if err != nil {
t.Fatalf("recall recent: %v", err)
}
if len(recent) == 0 {
t.Error("expected recent memories")
}
}
func TestStorePreference(t *testing.T) {
s := newTestStore(t)
if err := s.StorePreference("editor", "vim"); err != nil {
t.Fatalf("store preference: %v", err)
}
prefs, _ := s.RecallPreferences()
if len(prefs) != 1 {
t.Errorf("expected 1 preference, got %d", len(prefs))
}
}
func TestCount(t *testing.T) {
s := newTestStore(t)
s.Store(&Memory{Type: TypeFact, Key: "a", Content: "a"})
s.Store(&Memory{Type: TypeFact, Key: "b", Content: "b"})
count, err := s.Count()
if err != nil {
t.Fatalf("count: %v", err)
}
if count != 2 {
t.Errorf("expected 2, got %d", count)
}
}

94
internal/plugins/hooks.go Normal file
View File

@@ -0,0 +1,94 @@
package plugins
import (
"context"
"encoding/json"
"sync"
"github.com/muyue/muyue/internal/agent"
)
type HookType string
const (
BeforeToolCall HookType = "before_tool_call"
AfterToolCall HookType = "after_tool_call"
OnConversationStart HookType = "on_conversation_start"
OnToolError HookType = "on_tool_error"
)
type HookFunc func(ctx context.Context, payload HookPayload) error
type HookPayload struct {
ToolName string `json:"tool_name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Response *agent.ToolResponse `json:"response,omitempty"`
Error string `json:"error,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type Hook struct {
Type HookType
Plugin string
Priority int
Fn HookFunc
}
type HookRegistry struct {
mu sync.RWMutex
hooks map[HookType][]Hook
}
func NewHookRegistry() *HookRegistry {
return &HookRegistry{
hooks: make(map[HookType][]Hook),
}
}
func (hr *HookRegistry) Register(hookType HookType, pluginName string, priority int, fn HookFunc) {
hr.mu.Lock()
defer hr.mu.Unlock()
h := Hook{
Type: hookType,
Plugin: pluginName,
Priority: priority,
Fn: fn,
}
hr.hooks[hookType] = append(hr.hooks[hookType], h)
for i := len(hr.hooks[hookType]) - 1; i > 0; i-- {
if hr.hooks[hookType][i].Priority < hr.hooks[hookType][i-1].Priority {
hr.hooks[hookType][i], hr.hooks[hookType][i-1] = hr.hooks[hookType][i-1], hr.hooks[hookType][i]
}
}
}
func (hr *HookRegistry) Fire(ctx context.Context, hookType HookType, payload HookPayload) error {
hr.mu.RLock()
hooks := make([]Hook, len(hr.hooks[hookType]))
copy(hooks, hr.hooks[hookType])
hr.mu.RUnlock()
for _, h := range hooks {
if err := h.Fn(ctx, payload); err != nil {
return err
}
}
return nil
}
func (hr *HookRegistry) RemoveByPlugin(pluginName string) {
hr.mu.Lock()
defer hr.mu.Unlock()
for hookType := range hr.hooks {
filtered := make([]Hook, 0, len(hr.hooks[hookType]))
for _, h := range hr.hooks[hookType] {
if h.Plugin != pluginName {
filtered = append(filtered, h)
}
}
hr.hooks[hookType] = filtered
}
}

334
internal/plugins/loader.go Normal file
View File

@@ -0,0 +1,334 @@
package plugins
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"github.com/muyue/muyue/internal/agent"
)
func DiscoverPlugins(paths []string) []*DiscoveredPlugin {
var plugins []*DiscoveredPlugin
for _, p := range paths {
expanded := expandPath(p)
info, err := os.Stat(expanded)
if err != nil {
continue
}
if info.IsDir() {
entries, err := os.ReadDir(expanded)
if err != nil {
continue
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(expanded, entry.Name())
if dp := scanPluginDir(pluginDir); dp != nil {
plugins = append(plugins, dp)
}
}
}
}
return plugins
}
type DiscoveredPlugin struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Valid bool `json:"valid"`
Error string `json:"error,omitempty"`
}
func scanPluginDir(dir string) *DiscoveredPlugin {
name := filepath.Base(dir)
dp := &DiscoveredPlugin{
Name: name,
Path: dir,
}
initPy := filepath.Join(dir, "__init__.py")
mainGo := filepath.Join(dir, "main.go")
manifest := filepath.Join(dir, "plugin.json")
if _, err := os.Stat(manifest); err == nil {
dp.Type = "manifest"
dp.Valid = true
return dp
}
if _, err := os.Stat(mainGo); err == nil {
dp.Type = "go"
dp.Valid = true
return dp
}
if _, err := os.Stat(initPy); err == nil {
dp.Type = "python"
dp.Valid = true
return dp
}
executables := []string{name, name + ".sh"}
for _, exe := range executables {
fullPath := filepath.Join(dir, exe)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
dp.Type = "executable"
dp.Valid = true
dp.Path = fullPath
return dp
}
}
return dp
}
type PluginManifest struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Tools []ManifestTool `json:"tools,omitempty"`
Hooks []ManifestHook `json:"hooks,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
type ManifestTool struct {
Name string `json:"name"`
Description string `json:"description"`
Params json.RawMessage `json:"parameters"`
}
type ManifestHook struct {
Type string `json:"type"`
}
func LoadManifest(path string) (*PluginManifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
var manifest PluginManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse manifest: %w", err)
}
return &manifest, nil
}
func LoadExecutablePlugin(discovered *DiscoveredPlugin) (*Plugin, error) {
if !discovered.Valid {
return nil, fmt.Errorf("invalid plugin: %s", discovered.Name)
}
switch discovered.Type {
case "manifest":
return loadManifestPlugin(discovered)
case "executable":
return loadExecutableAsPlugin(discovered)
default:
return nil, fmt.Errorf("unsupported plugin type: %s", discovered.Type)
}
}
func loadManifestPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
manifestPath := filepath.Join(dp.Path, "plugin.json")
manifest, err := LoadManifest(manifestPath)
if err != nil {
return nil, err
}
p := NewPlugin(manifest.Name, manifest.Version, manifest.Description)
for _, mt := range manifest.Tools {
handler := createExternalHandler(dp.Path, manifest)
td := &ToolDefinition{
Name: mt.Name,
Description: mt.Description,
Params: mt.Params,
Handler: handler,
}
p.AddTool(td)
}
return p, nil
}
func loadExecutableAsPlugin(dp *DiscoveredPlugin) (*Plugin, error) {
p := NewPlugin(dp.Name, "0.0.1", "Executable plugin: "+dp.Name)
paramsSchema, _ := json.Marshal(map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]string{"type": "string", "description": "Action to execute"},
"args": map[string]string{"type": "object", "description": "Arguments for the action"},
},
"required": []string{"action"},
})
td := &ToolDefinition{
Name: dp.Name,
Description: "External plugin tool: " + dp.Name,
Params: paramsSchema,
Handler: createScriptHandler(dp.Path),
}
p.AddTool(td)
return p, nil
}
func createExternalHandler(pluginDir string, manifest *PluginManifest) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
if manifest.Command == "" {
return agent.TextErrorResponse(fmt.Sprintf("no command configured for plugin %s", manifest.Name)), nil
}
cmd := exec.CommandContext(ctx, manifest.Command, manifest.Args...)
cmd.Dir = pluginDir
cmd.Stdin = strings.NewReader(string(raw))
output, err := cmd.CombinedOutput()
if err != nil {
return agent.TextErrorResponse(fmt.Sprintf("plugin execution failed: %v\n%s", err, string(output))), nil
}
return agent.TextResponse(string(output)), nil
}
}
func createScriptHandler(scriptPath string) func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
return func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error) {
cmd := exec.CommandContext(ctx, scriptPath)
cmd.Stdin = strings.NewReader(string(raw))
output, err := cmd.CombinedOutput()
if err != nil {
return agent.TextErrorResponse(fmt.Sprintf("script failed: %v\n%s", err, string(output))), nil
}
return agent.TextResponse(string(output)), nil
}
}
func DefaultPluginPaths() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
configDir, err := configDir()
if err != nil {
return []string{filepath.Join(home, ".muyue", "plugins")}
}
return []string{
filepath.Join(configDir, "plugins"),
filepath.Join(home, ".muyue", "plugins"),
}
}
func expandPath(p string) string {
if strings.HasPrefix(p, "~/") {
home, _ := os.UserHomeDir()
return filepath.Join(home, p[2:])
}
return p
}
func configDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "muyue"), nil
}
func generatePluginSchema(v interface{}) (json.RawMessage, error) {
t := reflect.TypeOf(v)
if t == nil {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return json.RawMessage(`{"type":"object","properties":{}}`), nil
}
props := make(map[string]interface{})
required := []string{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if !field.IsExported() {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "-" {
continue
}
jsonName := field.Name
parts := strings.Split(jsonTag, ",")
if parts[0] != "" {
jsonName = parts[0]
}
omitempty := false
for _, part := range parts[1:] {
if part == "omitempty" {
omitempty = true
}
}
desc := field.Tag.Get("description")
prop := map[string]interface{}{"type": goTypeToJSON(field.Type)}
if desc != "" {
prop["description"] = desc
}
props[jsonName] = prop
if !omitempty {
required = append(required, jsonName)
}
}
schema := map[string]interface{}{
"type": "object",
"properties": props,
}
if len(required) > 0 {
schema["required"] = required
}
return json.Marshal(schema)
}
func goTypeToJSON(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return "integer"
case reflect.Float32, reflect.Float64:
return "number"
case reflect.Bool:
return "boolean"
case reflect.Slice:
if t.Elem().Kind() == reflect.Uint8 {
return "string"
}
return "array"
case reflect.Map:
return "object"
default:
return "string"
}
}

224
internal/plugins/plugin.go Normal file
View File

@@ -0,0 +1,224 @@
package plugins
import (
"context"
"encoding/json"
"fmt"
"sync"
"github.com/muyue/muyue/internal/agent"
)
type PluginStatus string
const (
StatusEnabled PluginStatus = "enabled"
StatusDisabled PluginStatus = "disabled"
StatusError PluginStatus = "error"
)
type Plugin struct {
name string
version string
description string
status PluginStatus
tools []*agent.ToolDefinition
hooks map[HookType]HookFunc
init func(ctx context.Context, registry *agent.Registry) error
}
func NewPlugin(name, version, description string) *Plugin {
return &Plugin{
name: name,
version: version,
description: description,
status: StatusDisabled,
tools: make([]*agent.ToolDefinition, 0),
hooks: make(map[HookType]HookFunc),
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Version() string { return p.version }
func (p *Plugin) Description() string { return p.description }
func (p *Plugin) Status() PluginStatus { return p.status }
func (p *Plugin) AddTool(tool *ToolDefinition) *Plugin {
td := &agent.ToolDefinition{
Name: tool.Name,
Description: tool.Description,
Params: tool.Params,
Handler: tool.Handler,
}
p.tools = append(p.tools, td)
return p
}
func (p *Plugin) AddToolGeneric(params interface{}, name, description string, handler func(ctx context.Context, raw json.RawMessage) (agent.ToolResponse, error)) *Plugin {
paramsSchema, err := generatePluginSchema(params)
if err == nil {
td := &agent.ToolDefinition{
Name: name,
Description: description,
Params: paramsSchema,
Handler: handler,
}
p.tools = append(p.tools, td)
}
return p
}
func (p *Plugin) AddHook(hookType HookType, fn HookFunc) *Plugin {
p.hooks[hookType] = fn
return p
}
func (p *Plugin) SetInit(fn func(ctx context.Context, registry *agent.Registry) error) *Plugin {
p.init = fn
return p
}
type ToolDefinition struct {
Name string
Description string
Params json.RawMessage
Handler func(ctx context.Context, args json.RawMessage) (agent.ToolResponse, error)
}
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Description string `json:"description"`
Status PluginStatus `json:"status"`
ToolCount int `json:"tool_count"`
HookTypes []string `json:"hook_types,omitempty"`
Error string `json:"error,omitempty"`
}
type Manager struct {
mu sync.RWMutex
plugins map[string]*Plugin
hooks *HookRegistry
enabled map[string]bool
}
func NewManager(hooks *HookRegistry) *Manager {
return &Manager{
plugins: make(map[string]*Plugin),
hooks: hooks,
enabled: make(map[string]bool),
}
}
func (m *Manager) Register(p *Plugin) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.plugins[p.name]; exists {
return fmt.Errorf("plugin %q already registered", p.name)
}
m.plugins[p.name] = p
return nil
}
func (m *Manager) Enable(ctx context.Context, name string, registry *agent.Registry) error {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.plugins[name]
if !ok {
return fmt.Errorf("plugin %q not found", name)
}
if p.status == StatusEnabled {
return nil
}
if p.init != nil {
if err := p.init(ctx, registry); err != nil {
p.status = StatusError
return fmt.Errorf("plugin %q init failed: %w", name, err)
}
}
for _, tool := range p.tools {
if err := registry.Register(tool); err != nil {
p.status = StatusError
return fmt.Errorf("plugin %q register tool %q: %w", name, tool.Name, err)
}
}
for hookType, fn := range p.hooks {
m.hooks.Register(hookType, name, 10, fn)
}
p.status = StatusEnabled
m.enabled[name] = true
return nil
}
func (m *Manager) Disable(name string) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.plugins[name]
if !ok {
return
}
if p.status != StatusEnabled {
return
}
m.hooks.RemoveByPlugin(name)
p.status = StatusDisabled
delete(m.enabled, name)
}
func (m *Manager) Get(name string) (*Plugin, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.plugins[name]
return p, ok
}
func (m *Manager) List() []PluginInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]PluginInfo, 0, len(m.plugins))
for _, p := range m.plugins {
info := PluginInfo{
Name: p.name,
Version: p.version,
Description: p.description,
Status: p.status,
ToolCount: len(p.tools),
}
for ht := range p.hooks {
info.HookTypes = append(info.HookTypes, string(ht))
}
result = append(result, info)
}
return result
}
func (m *Manager) EnabledNames() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0, len(m.enabled))
for name := range m.enabled {
names = append(names, name)
}
return names
}
func (m *Manager) EnableFromConfig(ctx context.Context, enabledList []string, registry *agent.Registry) {
for _, name := range enabledList {
if err := m.Enable(ctx, name, registry); err != nil {
_ = err
}
}
}

174
internal/rag/chunker.go Normal file
View File

@@ -0,0 +1,174 @@
package rag
import (
"strings"
"unicode/utf8"
)
type Chunk struct {
ID int `json:"id"`
Content string `json:"content"`
StartPos int `json:"start_pos"`
EndPos int `json:"end_pos"`
Metadata string `json:"metadata,omitempty"`
}
func ChunkText(text string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 500
}
maxChars := maxTokens * 4
if maxChars < 200 {
maxChars = 200
}
lines := strings.Split(text, "\n")
var chunks []Chunk
var current strings.Builder
chunkID := 0
startPos := 0
currentPos := 0
for _, line := range lines {
lineLen := utf8.RuneCountInString(line) + 1
if current.Len() > 0 && utf8.RuneCountInString(current.String())+lineLen > maxChars {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: startPos,
EndPos: currentPos,
})
chunkID++
startPos = currentPos
current.Reset()
}
current.WriteString(line)
current.WriteString("\n")
currentPos += lineLen
}
if current.Len() > 0 {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: startPos,
EndPos: currentPos,
})
}
return chunks
}
func ChunkMarkdown(text string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 500
}
maxChars := maxTokens * 4
sections := splitMarkdownSections(text)
var chunks []Chunk
chunkID := 0
pos := 0
for _, section := range sections {
if utf8.RuneCountInString(section) > maxChars {
subChunks := ChunkText(section, maxTokens)
for i := range subChunks {
subChunks[i].ID = chunkID
subChunks[i].StartPos += pos
subChunks[i].EndPos += pos
chunkID++
}
chunks = append(chunks, subChunks...)
} else {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(section),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(section),
})
chunkID++
}
pos += utf8.RuneCountInString(section)
}
return chunks
}
func splitMarkdownSections(text string) []string {
var sections []string
var current strings.Builder
lines := strings.Split(text, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, "##") || strings.HasPrefix(line, "###") {
if current.Len() > 0 {
sections = append(sections, current.String())
current.Reset()
}
}
current.WriteString(line)
current.WriteString("\n")
}
if current.Len() > 0 {
sections = append(sections, current.String())
}
if len(sections) == 0 && text != "" {
sections = []string{text}
}
return sections
}
func ChunkCode(code string, lang string, maxTokens int) []Chunk {
if maxTokens <= 0 {
maxTokens = 300
}
maxChars := maxTokens * 4
var chunks []Chunk
chunkID := 0
pos := 0
lines := strings.Split(code, "\n")
var current strings.Builder
currentLines := 0
for _, line := range lines {
lineLen := utf8.RuneCountInString(line) + 1
if current.Len() > 0 && (utf8.RuneCountInString(current.String())+lineLen > maxChars || currentLines > 50) {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(current.String()),
Metadata: lang,
})
chunkID++
pos += utf8.RuneCountInString(current.String())
current.Reset()
currentLines = 0
}
current.WriteString(line)
current.WriteString("\n")
currentLines++
}
if current.Len() > 0 {
chunks = append(chunks, Chunk{
ID: chunkID,
Content: strings.TrimSpace(current.String()),
StartPos: pos,
EndPos: pos + utf8.RuneCountInString(current.String()),
Metadata: lang,
})
}
return chunks
}

113
internal/rag/embed.go Normal file
View File

@@ -0,0 +1,113 @@
package rag
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type EmbeddingClient struct {
apiKey string
baseURL string
client *http.Client
}
func NewEmbeddingClient(apiKey, baseURL string) *EmbeddingClient {
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
return &EmbeddingClient{
apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"),
client: &http.Client{Timeout: 30 * time.Second},
}
}
type embeddingRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
type embeddingResponse struct {
Data []struct {
Embedding []float64 `json:"embedding"`
Index int `json:"index"`
} `json:"data"`
Usage struct {
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
func (c *EmbeddingClient) Embed(texts []string, model string) ([][]float64, error) {
if len(texts) == 0 {
return nil, nil
}
if model == "" {
model = "text-embedding-3-small"
}
body := embeddingRequest{
Model: model,
Input: texts,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal embedding request: %w", err)
}
url := c.baseURL + "/embeddings"
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("create embedding request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("send embedding request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read embedding response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("embedding API error (%d): %s", resp.StatusCode, string(respBody))
}
var embResp embeddingResponse
if err := json.Unmarshal(respBody, &embResp); err != nil {
return nil, fmt.Errorf("parse embedding response: %w", err)
}
result := make([][]float64, len(texts))
for _, data := range embResp.Data {
if data.Index < len(result) {
result[data.Index] = data.Embedding
}
}
return result, nil
}
func (c *EmbeddingClient) EmbedSingle(text, model string) ([]float64, error) {
results, err := c.Embed([]string{text}, model)
if err != nil {
return nil, err
}
if len(results) == 0 {
return nil, fmt.Errorf("no embedding returned")
}
return results[0], nil
}

79
internal/rag/inject.go Normal file
View File

@@ -0,0 +1,79 @@
package rag
import (
"fmt"
"strings"
)
func BuildContextBlock(results []SearchResult, maxTokens int) string {
if len(results) == 0 {
return ""
}
if maxTokens <= 0 {
maxTokens = 4000
}
maxChars := maxTokens * 4
var b strings.Builder
b.WriteString("<rag_context>\n")
b.WriteString("The following context was retrieved from indexed documents to help answer the user's question.\n\n")
for i, r := range results {
entry := fmt.Sprintf("--- Source: %s (relevance: %.2f) ---\n%s\n\n", r.DocumentName, r.Score, r.Content)
if b.Len()+len(entry) > maxChars {
break
}
b.WriteString(entry)
_ = i
}
b.WriteString("</rag_context>\n")
return b.String()
}
func ExtractRAGQueries(message string) (queries []string, cleaned string) {
cleaned = message
parts := strings.Split(message, "#")
if len(parts) <= 1 {
return nil, message
}
var queryParts []string
var textParts []string
for i, part := range parts {
if i == 0 {
textParts = append(textParts, part)
continue
}
part = strings.TrimSpace(part)
if part == "" {
continue
}
firstSpace := strings.IndexByte(part, ' ')
newline := strings.IndexByte(part, '\n')
end := len(part)
if firstSpace > 0 && (newline < 0 || firstSpace < newline) {
end = firstSpace
} else if newline > 0 {
end = newline
}
query := strings.TrimSpace(part[:end])
if query != "" {
queryParts = append(queryParts, query)
}
if end < len(part) {
textParts = append(textParts, part[end:])
}
}
if len(queryParts) > 0 {
cleaned = strings.Join(textParts, " ")
cleaned = strings.TrimSpace(cleaned)
}
return queryParts, cleaned
}

343
internal/rag/store.go Normal file
View File

@@ -0,0 +1,343 @@
package rag
import (
"database/sql"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
type Document struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Chunks int `json:"chunks"`
IndexedAt time.Time `json:"indexed_at"`
Size int64 `json:"size"`
}
type ChunkRecord struct {
ID int64 `json:"id"`
DocumentID string `json:"document_id"`
Content string `json:"content"`
Embedding []float64 `json:"embedding,omitempty"`
StartPos int `json:"start_pos"`
EndPos int `json:"end_pos"`
Metadata string `json:"metadata,omitempty"`
}
type Store struct {
mu sync.RWMutex
db *sql.DB
dir string
}
func NewStore(configDir string) (*Store, error) {
ragDir := filepath.Join(configDir, "rag")
if err := os.MkdirAll(ragDir, 0755); err != nil {
return nil, fmt.Errorf("creating rag dir: %w", err)
}
dbPath := filepath.Join(ragDir, "rag.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("opening rag db: %w", err)
}
s := &Store{db: db, dir: ragDir}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrating rag db: %w", err)
}
return s, nil
}
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'text',
chunks INTEGER NOT NULL DEFAULT 0,
indexed_at DATETIME NOT NULL,
size INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding BLOB,
start_pos INTEGER NOT NULL DEFAULT 0,
end_pos INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_chunks_document ON chunks(document_id);
`)
return err
}
func (s *Store) StoreDocument(doc Document, chunks []ChunkRecord) error {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(`INSERT OR REPLACE INTO documents (id, name, path, type, chunks, indexed_at, size) VALUES (?, ?, ?, ?, ?, ?, ?)`,
doc.ID, doc.Name, doc.Path, doc.Type, doc.Chunks, doc.IndexedAt, doc.Size)
if err != nil {
return fmt.Errorf("insert document: %w", err)
}
stmt, err := tx.Prepare(`INSERT INTO chunks (document_id, content, embedding, start_pos, end_pos, metadata) VALUES (?, ?, ?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("prepare chunk insert: %w", err)
}
defer stmt.Close()
for _, chunk := range chunks {
var embBytes []byte
if len(chunk.Embedding) > 0 {
embBytes, err = json.Marshal(chunk.Embedding)
if err != nil {
return fmt.Errorf("marshal embedding: %w", err)
}
}
_, err = stmt.Exec(chunk.DocumentID, chunk.Content, embBytes, chunk.StartPos, chunk.EndPos, chunk.Metadata)
if err != nil {
return fmt.Errorf("insert chunk: %w", err)
}
}
return tx.Commit()
}
func (s *Store) ListDocuments() ([]Document, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, name, path, type, chunks, indexed_at, size FROM documents ORDER BY indexed_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var docs []Document
for rows.Next() {
var doc Document
if err := rows.Scan(&doc.ID, &doc.Name, &doc.Path, &doc.Type, &doc.Chunks, &doc.IndexedAt, &doc.Size); err != nil {
return nil, err
}
docs = append(docs, doc)
}
return docs, nil
}
func (s *Store) DeleteDocument(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM documents WHERE id = ?`, id)
return err
}
type SearchResult struct {
ChunkID int64 `json:"chunk_id"`
DocumentID string `json:"document_id"`
DocumentName string `json:"document_name"`
Content string `json:"content"`
Score float64 `json:"score"`
Metadata string `json:"metadata,omitempty"`
}
func (s *Store) Search(queryEmbedding []float64, limit int) ([]SearchResult, error) {
if limit <= 0 {
limit = 5
}
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT c.id, c.document_id, c.content, c.embedding, c.metadata, d.name FROM chunks c JOIN documents d ON c.document_id = d.id WHERE c.embedding IS NOT NULL`)
if err != nil {
return nil, err
}
defer rows.Close()
type scored struct {
result SearchResult
score float64
}
var results []scored
for rows.Next() {
var id int64
var docID, content, metadata, docName string
var embBytes []byte
if err := rows.Scan(&id, &docID, &content, &embBytes, &metadata, &docName); err != nil {
continue
}
var embedding []float64
if err := json.Unmarshal(embBytes, &embedding); err != nil {
continue
}
score := cosineSimilarity(queryEmbedding, embedding)
results = append(results, scored{
result: SearchResult{
ChunkID: id,
DocumentID: docID,
DocumentName: docName,
Content: content,
Metadata: metadata,
},
score: score,
})
}
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].score > results[i].score {
results[i], results[j] = results[j], results[i]
}
}
}
if len(results) > limit {
results = results[:limit]
}
out := make([]SearchResult, len(results))
for i, r := range results {
r.result.Score = r.score
out[i] = r.result
}
return out, nil
}
func (s *Store) SearchKeyword(query string, limit int) ([]SearchResult, error) {
if limit <= 0 {
limit = 5
}
s.mu.RLock()
defer s.mu.RUnlock()
words := strings.Fields(strings.ToLower(query))
if len(words) == 0 {
return nil, nil
}
rows, err := s.db.Query(`SELECT c.id, c.document_id, c.content, c.metadata, d.name FROM chunks c JOIN documents d ON c.document_id = d.id`)
if err != nil {
return nil, err
}
defer rows.Close()
type scored struct {
result SearchResult
score float64
}
var results []scored
for rows.Next() {
var id int64
var docID, content, metadata, docName string
if err := rows.Scan(&id, &docID, &content, &metadata, &docName); err != nil {
continue
}
lower := strings.ToLower(content)
var score float64
for _, word := range words {
count := strings.Count(lower, word)
if count > 0 {
score += float64(count) / float64(len(strings.Fields(lower)))
}
}
if score > 0 {
results = append(results, scored{
result: SearchResult{
ChunkID: id,
DocumentID: docID,
DocumentName: docName,
Content: content,
Metadata: metadata,
},
score: score,
})
}
}
for i := 0; i < len(results); i++ {
for j := i + 1; j < len(results); j++ {
if results[j].score > results[i].score {
results[i], results[j] = results[j], results[i]
}
}
}
if len(results) > limit {
results = results[:limit]
}
out := make([]SearchResult, len(results))
for i, r := range results {
r.result.Score = r.score
out[i] = r.result
}
return out, nil
}
func (s *Store) Status() (map[string]interface{}, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var docCount, chunkCount int
s.db.QueryRow(`SELECT COUNT(*) FROM documents`).Scan(&docCount)
s.db.QueryRow(`SELECT COUNT(*) FROM chunks`).Scan(&chunkCount)
var withEmb int
s.db.QueryRow(`SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL`).Scan(&withEmb)
return map[string]interface{}{
"documents": docCount,
"chunks": chunkCount,
"chunks_embedded": withEmb,
"storage_path": s.dir,
}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) {
return 0
}
var dot, normA, normB float64
for i := range a {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
if normA == 0 || normB == 0 {
return 0
}
return dot / (math.Sqrt(normA) * math.Sqrt(normB))
}

View File

@@ -0,0 +1,177 @@
package skills
import (
"testing"
"time"
)
func TestCheckActivationNoConditions(t *testing.T) {
skill := &Skill{
Name: "test-skill",
Description: "A test skill",
}
result := CheckActivation(skill, []string{"terminal"})
if !result.Active {
t.Error("expected skill with no conditions to be active")
}
}
func TestCheckActivationRequiresTools(t *testing.T) {
skill := &Skill{
Name: "docker-setup",
RequiresTools: []string{"terminal", "docker"},
}
result := CheckActivation(skill, []string{"terminal", "docker"})
if !result.Active {
t.Error("expected skill to be active when all required tools present")
}
result = CheckActivation(skill, []string{"terminal"})
if result.Active {
t.Error("expected skill to be inactive when required tool missing")
}
}
func TestCheckActivationFallbackForTools(t *testing.T) {
skill := &Skill{
Name: "basic-review",
FallbackForTools: []string{"crush_run", "claude_run"},
}
result := CheckActivation(skill, []string{"terminal"})
if !result.Active {
t.Error("expected fallback skill to activate when primary tools absent")
}
result = CheckActivation(skill, []string{"crush_run", "claude_run"})
if result.Active {
t.Error("expected fallback skill to stay inactive when primary tools present")
}
}
func TestFilterActiveSkills(t *testing.T) {
skills := []Skill{
{Name: "basic", Description: "basic"},
{Name: "needs-docker", RequiresTools: []string{"docker"}},
{Name: "fallback-review", FallbackForTools: []string{"crush_run"}},
}
active := FilterActiveSkills(skills, []string{"terminal"})
if len(active) != 2 {
t.Errorf("expected 2 active skills, got %d", len(active))
}
}
func TestGroupByReadiness(t *testing.T) {
skills := []Skill{
{Name: "basic", Description: "basic"},
{Name: "needs-docker", RequiresTools: []string{"docker"}},
}
available, needsSetup, unsupported := GroupByReadiness(skills, []string{})
if len(available) != 1 {
t.Errorf("expected 1 available, got %d", len(available))
}
if len(unsupported) != 1 {
t.Errorf("expected 1 unsupported, got %d", len(unsupported))
}
_ = needsSetup
}
func TestAnalyzeConversation(t *testing.T) {
snippets := []ConversationSnippet{
{Role: "assistant", Content: "go test ./... -race", Timestamp: time.Now()},
{Role: "assistant", Content: "go test ./... -race -cover", Timestamp: time.Now()},
{Role: "assistant", Content: "go test ./internal/... -v", Timestamp: time.Now()},
}
proposals := AnalyzeConversation(snippets)
if len(proposals) == 0 {
t.Error("expected at least one proposal from recurring patterns")
}
for _, p := range proposals {
if p.Confidence <= 0 {
t.Error("expected positive confidence")
}
if p.CreatedFrom != "conversation" {
t.Errorf("expected created_from=conversation, got %s", p.CreatedFrom)
}
}
}
func TestCategorize(t *testing.T) {
tests := []struct {
pattern string
want string
}{
{"go test", "testing"},
{"docker build", "devops"},
{"git commit", "workflow"},
{"npm test", "testing"},
{"make", "build"},
{"unknown", "general"},
}
for _, tt := range tests {
got := categorize(tt.pattern)
if got != tt.want {
t.Errorf("categorize(%q) = %q, want %q", tt.pattern, got, tt.want)
}
}
}
func TestImproverAnalyze(t *testing.T) {
improver, err := NewSkillImprover()
if err != nil {
t.Fatalf("new improver: %v", err)
}
skill := &Skill{
Name: "test-skill",
Description: "A test skill",
Content: "# Test\n\nSome basic content without structure.",
}
suggestions, err := improver.Analyze(skill, "")
if err != nil {
t.Fatalf("analyze: %v", err)
}
if len(suggestions) == 0 {
t.Error("expected improvement suggestions for minimal skill")
}
}
func TestImproverAnalyzeComplete(t *testing.T) {
improver, _ := NewSkillImprover()
skill := &Skill{
Name: "complete-skill",
Description: "A well-structured skill",
Content: `# Complete Skill
## Steps
1. Do step one
2. Do step two
## Error Handling
- Handle error A
- Handle error B
## When to use
Use this skill when doing X.
`,
Tags: []string{"testing", "go"},
}
suggestions, _ := improver.Analyze(skill, "testing go code")
if len(suggestions) > 2 {
t.Errorf("expected few suggestions for complete skill, got %d", len(suggestions))
}
}

View File

@@ -0,0 +1,282 @@
package skills
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type PatternMatch struct {
Pattern string
Count int
LastSeen time.Time
ExampleText string
}
type AutoCreateProposal struct {
Name string
Description string
SuggestedTags []string
Category string
Patterns []PatternMatch
Confidence float64
CreatedFrom string
}
type ConversationSnippet struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp time.Time `json:"timestamp"`
}
func AnalyzeConversation(snippets []ConversationSnippet) []AutoCreateProposal {
patterns := detectPatterns(snippets)
var proposals []AutoCreateProposal
for _, p := range patterns {
if p.Count < 3 {
continue
}
name := generateSkillName(p.Pattern)
proposal := AutoCreateProposal{
Name: name,
Description: fmt.Sprintf("Auto-detected skill for recurring pattern: %s", p.Pattern),
SuggestedTags: extractTags(p.Pattern),
Category: categorize(p.Pattern),
Patterns: []PatternMatch{p},
Confidence: computeConfidence(p),
CreatedFrom: "conversation",
}
proposals = append(proposals, proposal)
}
return proposals
}
func CreateFromProposal(proposal *AutoCreateProposal) (*Skill, error) {
skill := &Skill{
Name: proposal.Name,
Description: proposal.Description,
Author: "muyue-auto",
Version: "0.1.0",
Tags: proposal.SuggestedTags,
Category: proposal.Category,
Target: "both",
CreatedFrom: proposal.CreatedFrom,
AutoImprove: true,
Content: buildAutoSkillContent(proposal),
}
return skill, Create(skill)
}
func LoadProposals() ([]AutoCreateProposal, error) {
dir, err := proposalsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var proposals []AutoCreateProposal
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var p AutoCreateProposal
if err := json.Unmarshal(data, &p); err != nil {
continue
}
proposals = append(proposals, p)
}
return proposals, nil
}
func SaveProposal(proposal *AutoCreateProposal) error {
dir, err := proposalsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(proposal, "", " ")
if err != nil {
return err
}
path := filepath.Join(dir, proposal.Name+".json")
return os.WriteFile(path, data, 0644)
}
func DeleteProposal(name string) error {
dir, err := proposalsDir()
if err != nil {
return err
}
path := filepath.Join(dir, name+".json")
return os.Remove(path)
}
func proposalsDir() (string, error) {
dir, err := SkillsDir()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(dir), ".muyue", "proposals"), nil
}
func detectPatterns(snippets []ConversationSnippet) []PatternMatch {
commandPatterns := make(map[string]*PatternMatch)
for _, s := range snippets {
if s.Role != "assistant" {
continue
}
lines := strings.Split(s.Content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if isCommandPattern(line) {
key := extractPatternKey(line)
if key == "" {
continue
}
if existing, ok := commandPatterns[key]; ok {
existing.Count++
if s.Timestamp.After(existing.LastSeen) {
existing.LastSeen = s.Timestamp
existing.ExampleText = truncate(line, 200)
}
} else {
commandPatterns[key] = &PatternMatch{
Pattern: key,
Count: 1,
LastSeen: s.Timestamp,
ExampleText: truncate(line, 200),
}
}
}
}
}
var patterns []PatternMatch
for _, p := range commandPatterns {
patterns = append(patterns, *p)
}
return patterns
}
func isCommandPattern(line string) bool {
toolPrefixes := []string{"go test", "go build", "go run", "npm test", "npm run",
"docker build", "docker run", "git commit", "git push", "kubectl",
"cargo test", "cargo build", "pytest", "make "}
for _, prefix := range toolPrefixes {
if strings.HasPrefix(line, prefix) {
return true
}
}
return false
}
func extractPatternKey(line string) string {
parts := strings.Fields(line)
if len(parts) < 2 {
return ""
}
if len(parts) >= 3 && (parts[0] == "go" || parts[0] == "npm" || parts[0] == "cargo" || parts[0] == "git" || parts[0] == "docker") {
return parts[0] + " " + parts[1]
}
return parts[0]
}
func generateSkillName(pattern string) string {
name := strings.ReplaceAll(pattern, " ", "-")
name = strings.ToLower(name)
if len(name) > 30 {
name = name[:30]
}
h := sha256.Sum256([]byte(pattern))
return fmt.Sprintf("auto-%s-%x", name, h[:4])
}
func extractTags(pattern string) []string {
var tags []string
parts := strings.Fields(pattern)
for _, p := range parts {
if len(p) > 2 {
tags = append(tags, strings.ToLower(p))
}
}
return tags
}
func categorize(pattern string) string {
categories := map[string]string{
"go test": "testing", "go build": "build", "go run": "build",
"npm test": "testing", "npm run": "build",
"docker build": "devops", "docker run": "devops",
"git commit": "workflow", "git push": "workflow",
"kubectl": "devops", "cargo test": "testing",
"cargo build": "build", "pytest": "testing",
"make": "build",
}
for prefix, cat := range categories {
if strings.HasPrefix(pattern, prefix) {
return cat
}
}
return "general"
}
func computeConfidence(p PatternMatch) float64 {
confidence := 0.3
confidence += float64(p.Count) * 0.1
if confidence > 0.95 {
confidence = 0.95
}
return confidence
}
func buildAutoSkillContent(proposal *AutoCreateProposal) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("# %s\n\n", strings.Title(proposal.Name)))
b.WriteString("Auto-generated skill based on recurring patterns detected in conversations.\n\n")
b.WriteString("## Activation\n\n")
b.WriteString("This skill activates when the following patterns are detected:\n\n")
for _, p := range proposal.Patterns {
b.WriteString(fmt.Sprintf("- `%s` (seen %d times)\n", p.Pattern, p.Count))
}
b.WriteString("\n## Instructions\n\n")
b.WriteString("1. Detect the pattern context from the user request\n")
b.WriteString("2. Apply the standard workflow for this pattern\n")
b.WriteString("3. Handle common errors and edge cases\n")
b.WriteString("4. Verify the result\n\n")
b.WriteString("## Error Handling\n\n")
b.WriteString("- If a command fails, check for missing dependencies\n")
b.WriteString("- Suggest alternative approaches when the standard pattern doesn't fit\n")
return b.String()
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}

View File

@@ -0,0 +1,125 @@
package skills
import (
"strings"
)
type ActivationResult struct {
Active bool
Reason string
Skill *Skill
}
func CheckActivation(skill *Skill, availableTools []string) ActivationResult {
if len(skill.RequiresTools) == 0 && len(skill.FallbackForTools) == 0 {
return ActivationResult{
Active: true,
Reason: "no activation conditions",
Skill: skill,
}
}
toolSet := make(map[string]bool, len(availableTools))
for _, t := range availableTools {
toolSet[strings.ToLower(t)] = true
}
if len(skill.RequiresTools) > 0 {
for _, req := range skill.RequiresTools {
if !toolSet[strings.ToLower(req)] {
return ActivationResult{
Active: false,
Reason: "missing required tool: " + req,
Skill: skill,
}
}
}
return ActivationResult{
Active: true,
Reason: "all required tools available",
Skill: skill,
}
}
if len(skill.FallbackForTools) > 0 {
allPresent := true
for _, fb := range skill.FallbackForTools {
if !toolSet[strings.ToLower(fb)] {
allPresent = false
break
}
}
if allPresent {
return ActivationResult{
Active: false,
Reason: "primary tools available, fallback not needed",
Skill: skill,
}
}
return ActivationResult{
Active: true,
Reason: "primary tools absent, activating as fallback",
Skill: skill,
}
}
return ActivationResult{Active: true, Skill: skill}
}
func FilterActiveSkills(skillsList []Skill, availableTools []string) []Skill {
var active []Skill
for i := range skillsList {
result := CheckActivation(&skillsList[i], availableTools)
if result.Active {
active = append(active, skillsList[i])
}
}
return active
}
func GroupByReadiness(skillsList []Skill, availableTools []string) (available, needsSetup, unsupported []Skill) {
toolSet := make(map[string]bool, len(availableTools))
for _, t := range availableTools {
toolSet[strings.ToLower(t)] = true
}
for i := range skillsList {
s := &skillsList[i]
if len(s.RequiresTools) == 0 && len(s.FallbackForTools) == 0 {
missing := CheckDependencies(s)
if len(missing) == 0 {
available = append(available, *s)
} else {
needsSetup = append(needsSetup, *s)
}
continue
}
allReqMet := true
for _, req := range s.RequiresTools {
if !toolSet[strings.ToLower(req)] {
allReqMet = false
break
}
}
if allReqMet && len(s.RequiresTools) > 0 {
available = append(available, *s)
} else if !allReqMet && len(s.RequiresTools) > 0 {
unsupported = append(unsupported, *s)
}
if len(s.FallbackForTools) > 0 {
anyMissing := false
for _, fb := range s.FallbackForTools {
if !toolSet[strings.ToLower(fb)] {
anyMissing = true
break
}
}
if anyMissing {
available = append(available, *s)
}
}
}
return
}

267
internal/skills/improver.go Normal file
View File

@@ -0,0 +1,267 @@
package skills
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type ImprovementSuggestion struct {
Type string `json:"type"`
Section string `json:"section"`
Current string `json:"current"`
Suggested string `json:"suggested"`
Reason string `json:"reason"`
Confidence float64 `json:"confidence"`
CreatedAt time.Time `json:"created_at"`
}
type ImprovementHistory struct {
SkillName string `json:"skill_name"`
Version string `json:"version"`
Improvements []ImprovementSuggestion `json:"improvements"`
AppliedAt time.Time `json:"applied_at"`
Result string `json:"result"`
}
type SkillImprover struct {
historyDir string
}
func NewSkillImprover() (*SkillImprover, error) {
dir, err := improvementHistoryDir()
if err != nil {
return nil, err
}
return &SkillImprover{historyDir: dir}, nil
}
func (si *SkillImprover) Analyze(skill *Skill, conversationContext string) ([]ImprovementSuggestion, error) {
var suggestions []ImprovementSuggestion
if skill.Content == "" {
return nil, fmt.Errorf("skill has no content to analyze")
}
suggestions = append(suggestions, si.checkMissingSections(skill)...)
suggestions = append(suggestions, si.checkErrorHandling(skill)...)
suggestions = append(suggestions, si.checkStepCompleteness(skill)...)
suggestions = append(suggestions, si.analyzeContextRelevance(skill, conversationContext)...)
return suggestions, nil
}
func (si *SkillImprover) ApplyImprovement(skillName string, suggestion ImprovementSuggestion) error {
skill, err := Get(skillName)
if err != nil {
return fmt.Errorf("get skill: %w", err)
}
switch suggestion.Section {
case "content":
skill.Content = applyContentSuggestion(skill.Content, suggestion)
case "description":
skill.Description = suggestion.Suggested
default:
skill.Content = applyContentSuggestion(skill.Content, suggestion)
}
now := time.Now()
skill.LastImprovedAt = &now
skill.ImprovementCount++
if err := Update(skill); err != nil {
return fmt.Errorf("update skill: %w", err)
}
history := ImprovementHistory{
SkillName: skillName,
Version: skill.Version,
Improvements: []ImprovementSuggestion{suggestion},
AppliedAt: now,
Result: "applied",
}
return si.saveHistory(&history)
}
func (si *SkillImprover) GetHistory(skillName string) ([]ImprovementHistory, error) {
if err := os.MkdirAll(si.historyDir, 0755); err != nil {
return nil, err
}
entries, err := os.ReadDir(si.historyDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var histories []ImprovementHistory
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
if skillName != "" && !strings.HasPrefix(e.Name(), skillName+"_") {
continue
}
data, err := os.ReadFile(filepath.Join(si.historyDir, e.Name()))
if err != nil {
continue
}
var h ImprovementHistory
if err := json.Unmarshal(data, &h); err != nil {
continue
}
histories = append(histories, h)
}
return histories, nil
}
func (si *SkillImprover) checkMissingSections(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
content := strings.ToLower(skill.Content)
requiredSections := []struct {
keyword string
label string
}{
{"error handling", "Error Handling"},
{"steps", "Steps"},
{"when to", "Activation"},
}
for _, req := range requiredSections {
if !strings.Contains(content, req.keyword) {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "missing_section",
Section: "content",
Current: "",
Suggested: fmt.Sprintf("Add a '%s' section", req.label),
Reason: fmt.Sprintf("Skill is missing a '%s' section which is important for completeness", req.label),
Confidence: 0.8,
CreatedAt: time.Now(),
})
}
}
return suggestions
}
func (si *SkillImprover) checkErrorHandling(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
content := strings.ToLower(skill.Content)
if !strings.Contains(content, "error") && !strings.Contains(content, "fail") {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "missing_error_handling",
Section: "content",
Current: "",
Suggested: "Add error handling guidance covering common failure modes",
Reason: "Skill lacks error handling guidance, which may lead to poor user experience when things go wrong",
Confidence: 0.85,
CreatedAt: time.Now(),
})
}
return suggestions
}
func (si *SkillImprover) checkStepCompleteness(skill *Skill) []ImprovementSuggestion {
var suggestions []ImprovementSuggestion
lines := strings.Split(skill.Content, "\n")
stepCount := 0
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "1.") || strings.HasPrefix(trimmed, "Step 1") {
stepCount++
}
}
if stepCount == 0 && len(lines) > 10 {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "no_clear_steps",
Section: "content",
Current: "",
Suggested: "Add numbered step-by-step instructions for clarity",
Reason: "Long skill content without clear step-by-step structure can be hard to follow",
Confidence: 0.7,
CreatedAt: time.Now(),
})
}
return suggestions
}
func (si *SkillImprover) analyzeContextRelevance(skill *Skill, context string) []ImprovementSuggestion {
if context == "" {
return nil
}
var suggestions []ImprovementSuggestion
contextLower := strings.ToLower(context)
tags := skill.Tags
relevance := 0
for _, tag := range tags {
if strings.Contains(contextLower, strings.ToLower(tag)) {
relevance++
}
}
if len(tags) > 0 && relevance == 0 && skill.Category != "" && !strings.Contains(contextLower, strings.ToLower(skill.Category)) {
suggestions = append(suggestions, ImprovementSuggestion{
Type: "tag_relevance",
Section: "tags",
Current: strings.Join(tags, ", "),
Suggested: "Review tags for better context matching",
Reason: "Current tags do not match recent conversation context, suggesting tags may need updating",
Confidence: 0.5,
CreatedAt: time.Now(),
})
}
return suggestions
}
func applyContentSuggestion(content string, suggestion ImprovementSuggestion) string {
switch suggestion.Type {
case "missing_section":
return content + "\n\n## " + strings.Title(suggestion.Type) + "\n\n" + suggestion.Suggested + ".\n"
case "missing_error_handling":
return content + "\n\n## Error Handling\n\n- Handle common failure modes gracefully\n- Provide clear error messages\n- Suggest alternative approaches\n"
case "no_clear_steps":
return "## Steps\n\n1. Review the skill context\n2. Apply the appropriate pattern\n3. Verify the result\n\n" + content
default:
return content
}
}
func (si *SkillImprover) saveHistory(history *ImprovementHistory) error {
if err := os.MkdirAll(si.historyDir, 0755); err != nil {
return err
}
filename := fmt.Sprintf("%s_%s.json", history.SkillName, history.AppliedAt.Format("20060102-150405"))
data, err := json.MarshalIndent(history, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(si.historyDir, filename), data, 0644)
}
func improvementHistoryDir() (string, error) {
dir, err := SkillsDir()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(dir), ".muyue", "improvements"), nil
}

View File

@@ -20,20 +20,26 @@ type SkillDependency struct {
}
type Skill struct {
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"`
Deployed bool `yaml:"-" json:"deployed,omitempty"`
Name string `yaml:"name" json:"name"`
Description string `yaml:"description" json:"description"`
Content string `yaml:"content" json:"content"`
Author string `yaml:"author" json:"author"`
Version string `yaml:"version" json:"version"`
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
Tags []string `yaml:"tags" json:"tags"`
Target string `yaml:"target" json:"target"`
FilePath string `yaml:"-" json:"-"`
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
Category string `yaml:"category,omitempty" json:"category,omitempty"`
Deployed bool `yaml:"-" json:"deployed,omitempty"`
RequiresTools []string `yaml:"requires_tools,omitempty" json:"requires_tools,omitempty"`
FallbackForTools []string `yaml:"fallback_for_tools,omitempty" json:"fallback_for_tools,omitempty"`
AutoImprove bool `yaml:"auto_improve,omitempty" json:"auto_improve,omitempty"`
CreatedFrom string `yaml:"created_from,omitempty" json:"created_from,omitempty"`
ImprovementCount int `yaml:"improvement_count,omitempty" json:"improvement_count,omitempty"`
LastImprovedAt *time.Time `yaml:"last_improved_at,omitempty" json:"last_improved_at,omitempty"`
}
type ValidationError struct {
@@ -516,6 +522,24 @@ func renderSkill(skill *Skill) string {
b.WriteString(fmt.Sprintf(" - type: %s, name: %s%s\n", dep.Type, dep.Name, req))
}
}
if len(skill.RequiresTools) > 0 {
b.WriteString(fmt.Sprintf("requires_tools: [%s]\n", strings.Join(skill.RequiresTools, ", ")))
}
if len(skill.FallbackForTools) > 0 {
b.WriteString(fmt.Sprintf("fallback_for_tools: [%s]\n", strings.Join(skill.FallbackForTools, ", ")))
}
if skill.AutoImprove {
b.WriteString("auto_improve: true\n")
}
if skill.CreatedFrom != "" {
b.WriteString(fmt.Sprintf("created_from: %s\n", skill.CreatedFrom))
}
if skill.ImprovementCount > 0 {
b.WriteString(fmt.Sprintf("improvement_count: %d\n", skill.ImprovementCount))
}
if skill.LastImprovedAt != nil {
b.WriteString(fmt.Sprintf("last_improved_at: %s\n", skill.LastImprovedAt.Format(time.RFC3339)))
}
b.WriteString("---\n\n")
b.WriteString(skill.Content)
b.WriteString("\n")