feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Beta Release / beta (push) Failing after 33s

- Agent slot limiter for concurrent tool execution
- Conversation summarization with soft-delete (MarkSummarized)
- ANSI stripping in terminal tool output
- Configurable crush-run timeout (default 600s, max 900s)
- Starship theme refactor, AI tools config grid, system update UI
- Streaming segments refactor, summarized messages block in feed
- CSS: headings, scrollbars, tool cards, summary block styles
- i18n additions (en+fr) for tools, updates, config

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-27 00:01:22 +02:00
parent e8a289ccf3
commit d2bb42b212
23 changed files with 1028 additions and 556 deletions

View File

@@ -6,11 +6,18 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
func stripANSI(s string) string {
return ansiRegex.ReplaceAllString(s, "")
}
var (
sudoCache bool
sudoCacheSet bool
@@ -103,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
output, err := cmd.CombinedOutput()
result := string(output)
result = stripANSI(result)
if len(result) > 10000 {
result = result[:10000] + "\n... [truncated]"
}
@@ -116,7 +124,8 @@ func NewTerminalTool() (*ToolDefinition, error) {
}
type CrushRunParams struct {
Task string `json:"task" description:"The task description for Crush to execute"`
Task string `json:"task" description:"The task description for Crush to execute"`
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 600, max 900)"`
}
func NewCrushRunTool() (*ToolDefinition, error) {
@@ -127,7 +136,14 @@ func NewCrushRunTool() (*ToolDefinition, error) {
return TextErrorResponse("task is required"), nil
}
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
timeout := time.Duration(p.Timeout) * time.Second
if timeout == 0 {
timeout = 600 * time.Second
}
if timeout > 900*time.Second {
timeout = 900 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
@@ -139,7 +155,14 @@ func NewCrushRunTool() (*ToolDefinition, error) {
}
if err != nil {
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
errMsg := fmt.Sprintf("Crush error: %v", err)
if ctx.Err() == context.DeadlineExceeded {
errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
}
if result != "" {
errMsg += "\n\n" + result
}
return TextErrorResponse(errMsg), nil
}
return TextResponse(result), nil

View File

@@ -13,6 +13,9 @@ const (
MaxToolIterations = 15
)
// ToolLimiter checks if a tool call is allowed and returns a release function.
type ToolLimiter func(toolName string) (release func(), err error)
// ChatEngine handles chat interactions with tool execution.
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
type ChatEngine struct {
@@ -21,6 +24,7 @@ type ChatEngine struct {
tools json.RawMessage
onChunk func(map[string]interface{})
stream bool
limiter ToolLimiter
TotalTokens int
}
@@ -44,6 +48,11 @@ func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
ce.onChunk = fn
}
// SetLimiter sets the tool call limiter for agent concurrency control.
func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
ce.limiter = l
}
// RunWithTools executes the chat loop with tool calls.
// Returns final content, tool calls, tool results, and error.
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
@@ -77,7 +86,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
if ce.onChunk != nil {
@@ -115,6 +124,35 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments),
}
if ce.limiter != nil {
release, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
limResultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": limitErr.Error(),
"is_error": true,
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
"result": limitErr.Error(),
"is_error": true,
})
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_result": limResultData})
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
defer release()
}
result, execErr := ce.registry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{
@@ -179,7 +217,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
}
choice := resp.Choices[0]
content := cleanThinkingTags(choice.Message.Content)
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
finalContent = content
@@ -203,6 +241,20 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
Arguments: json.RawMessage(tc.Function.Arguments),
}
if ce.limiter != nil {
release, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
defer release()
}
result, execErr := ce.registry.Execute(ctx, call)
if execErr != nil {
result = agent.ToolResponse{

View File

@@ -17,12 +17,53 @@ const contextWindowTokens = 150000
const summarizeRatio = 0.80
const charsPerToken = 4
func extractDisplayContent(role, content string) string {
if role != "assistant" {
return content
}
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
ToolResults []struct {
Name string `json:"name"`
Result string `json:"result"`
} `json:"tool_results"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
return content
}
var sb strings.Builder
if parsed.Content != "" {
sb.WriteString(parsed.Content)
}
for _, tc := range parsed.ToolCalls {
sb.WriteString("\n[")
sb.WriteString(tc.Name)
sb.WriteString("] ")
sb.WriteString(tc.Args)
}
for _, tr := range parsed.ToolResults {
sb.WriteString("\n[result")
if tr.Name != "" {
sb.WriteString(":")
sb.WriteString(tr.Name)
}
sb.WriteString("] ")
sb.WriteString(tr.Result)
}
return sb.String()
}
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
Images []string `json:"images,omitempty"`
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
Images []string `json:"images,omitempty"`
Summarized bool `json:"summarized,omitempty"`
}
type Conversation struct {
@@ -168,13 +209,15 @@ func (cs *ConversationStore) SetSummary(summary string) {
cs.save()
}
func (cs *ConversationStore) TrimOld(keepCount int) {
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if len(cs.conv.Messages) <= keepCount {
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
return
}
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
for i := 0; i < upToIndex; i++ {
cs.conv.Messages[i].Summarized = true
}
cs.save()
}
@@ -191,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
}
for _, m := range cs.conv.Messages {
count := utf8.RuneCountInString(m.Content) / charsPerToken
if m.Role == "system" || m.Summarized {
continue
}
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
result.byMessage += count
result.byRole[m.Role] += count
}

View File

@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
@@ -64,15 +63,13 @@ func (s *Server) describeImages(images []ImageAttachment) []string {
}
}
if apiKey == "" {
log.Printf("[vlm] no API key found for image description")
return nil
}
descriptions := make([]string, 0, len(images))
for i, img := range images {
for _, img := range images {
desc, err := s.callVLM(apiKey, img)
if err != nil {
log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err)
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
} else {
descriptions = append(descriptions, desc)
@@ -163,7 +160,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
if err != nil {
log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err)
_ = err
} else {
imageIDs = append(imageIDs, id)
}
@@ -227,6 +224,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
@@ -265,6 +263,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
messages := s.buildContextMessages(userMessage)
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -299,7 +298,11 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
if history[i].Summarized {
break
}
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
@@ -315,14 +318,21 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
start = 0
}
hasSummarized := false
for i := 0; i < start; i++ {
if history[i].Summarized {
hasSummarized = true
break
}
}
if start > 0 {
log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start)
_ = start
}
messages := make([]orchestrator.Message, 0, included+2)
summary := s.convStore.GetSummary()
if summary != "" && start > 0 {
if summary != "" && (start > 0 || hasSummarized) {
messages = append(messages, orchestrator.Message{
Role: "system",
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
@@ -330,27 +340,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
}
for _, m := range history[start:] {
content := m.Content
if m.Role == "assistant" {
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
content = parsed.Content
}
}
role := m.Role
if role == "system" {
if m.Role == "system" {
continue
}
displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
Role: role,
Content: orchestrator.TextContent(content),
Role: m.Role,
Content: orchestrator.TextContent(displayContent),
})
}
@@ -391,8 +387,7 @@ func (s *Server) autoSummarize() {
}
s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
s.convStore.MarkSummarized(half)
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {

View File

@@ -335,30 +335,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
body.Theme = s.config.Terminal.PromptTheme
}
cfgDir, err := config.ConfigDir()
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeFile := ApplyStarshipTheme(body.Theme)
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
}
func ApplyStarshipTheme(theme string) string {
cfgDir, _ := config.ConfigDir()
starshipDir := filepath.Join(cfgDir, "starship")
if err := os.MkdirAll(starshipDir, 0755); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
os.MkdirAll(starshipDir, 0755)
themeFile := filepath.Join(starshipDir, "starship.toml")
themeContent := getStarshipThemeConfig(body.Theme)
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
themeContent := getStarshipThemeConfig(theme)
os.WriteFile(themeFile, []byte(themeContent), 0644)
home, _ := os.UserHomeDir()
shellRCs := []string{
filepath.Join(home, ".bashrc"),
filepath.Join(home, ".zshrc"),
}
for _, rc := range shellRCs {
for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
if _, err := os.Stat(rc); err != nil {
continue
}
@@ -375,10 +370,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
f.Close()
}
s.config.Terminal.PromptTheme = body.Theme
config.Save(s.config)
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
return themeFile
}
func getStarshipThemeConfig(theme string) string {

View File

@@ -91,6 +91,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
for i := range list {
list[i].Deployed = skills.IsDeployed(list[i].Name)
}
writeJSON(w, map[string]interface{}{
"skills": list,
"count": len(list),

View File

@@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "all deployed"})
}
func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" {
writeError(w, "name is required", http.StatusBadRequest)
return
}
if err := skills.Undeploy(body.Name); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
}
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
@@ -108,6 +107,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
engine.OnChunk(func(data map[string]interface{}) {
if data == nil {
return
@@ -149,6 +149,7 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
messages := s.buildShellContextMessages()
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
engine.SetLimiter(s.AcquireAgentSlot)
finalContent, err := engine.RunNonStream(ctx, messages)
if err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
@@ -185,7 +186,8 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
included := 0
tokensUsed := 0
for i := len(history) - 1; i >= 0; i-- {
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
if msgTokens == 0 {
msgTokens = 1
}
@@ -202,33 +204,19 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
}
if start > 0 {
log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start)
_ = start
}
messages := make([]orchestrator.Message, 0, included)
for _, m := range history[start:] {
content := m.Content
if m.Role == "assistant" {
var parsed struct {
Content string `json:"content"`
ToolCalls []struct {
ToolCallID string `json:"tool_call_id"`
Name string `json:"name"`
Args string `json:"args"`
} `json:"tool_calls"`
}
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
content = parsed.Content
}
}
role := m.Role
if role == "system" {
if m.Role == "system" {
continue
}
displayContent := extractDisplayContent(m.Role, m.Content)
messages = append(messages, orchestrator.Message{
Role: role,
Content: orchestrator.TextContent(content),
Role: m.Role,
Content: orchestrator.TextContent(displayContent),
})
}

View File

@@ -3,7 +3,6 @@ package api
import (
"encoding/base64"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
@@ -64,7 +63,7 @@ func cleanupImages(ids []string) {
for _, id := range ids {
p := imagePath(id)
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
log.Printf("[images] failed to delete %s: %v", id, err)
_ = err
}
}
}

View File

@@ -2,12 +2,15 @@ package api
import (
"encoding/json"
"log"
"fmt"
"net/http"
"os/exec"
"strings"
"sync/atomic"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/installer"
"github.com/muyue/muyue/internal/scanner"
"github.com/muyue/muyue/internal/workflow"
)
@@ -24,6 +27,8 @@ type Server struct {
shellAgentRegistry *agent.Registry
shellAgentToolsJSON json.RawMessage
workflowEngine *workflow.Engine
activeCrushAgents atomic.Int32
activeClaudeAgents atomic.Int32
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -43,7 +48,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
}
// Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil {
log.Printf("config: initial save failed: %v", err)
_ = err
}
cfg = defaultCfg
}
@@ -65,6 +70,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
s.initStarship()
s.routes()
return s
}
@@ -120,6 +126,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
@@ -156,3 +163,37 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
s.mux.ServeHTTP(w, r)
}
const maxCrushAgents = 2
const maxClaudeAgents = 2
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
var counter *atomic.Int32
var max int32
switch toolName {
case "crush_run":
counter = &s.activeCrushAgents
max = maxCrushAgents
case "claude_run":
counter = &s.activeClaudeAgents
max = maxClaudeAgents
default:
return func() {}, nil
}
current := counter.Add(1)
if current > max {
counter.Add(-1)
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
}
return func() { counter.Add(-1) }, nil
}
func (s *Server) initStarship() {
if _, err := exec.LookPath("starship"); err != nil {
inst := installer.New(s.config)
if result := inst.InstallTool("starship"); !result.Success {
return
}
}
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
}

View File

@@ -147,7 +147,10 @@ func (s *ShellConvStore) ApproxTokens() int {
defer s.mu.RUnlock()
total := 0
for _, m := range s.msgs {
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
if m.Role == "system" {
continue
}
total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
}
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
if analysis := LoadSystemAnalysis(); analysis != "" {

View File

@@ -3,7 +3,6 @@ package api
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
@@ -48,7 +47,6 @@ type wsMessage struct {
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade: %v", err)
return
}
defer conn.Close()
@@ -56,17 +54,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
log.Printf("terminal: read init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
log.Printf("terminal: init message received: %s", string(raw))
if err := json.Unmarshal(raw, &initMsg); err != nil {
log.Printf("terminal: unmarshal init message failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
var cmd *exec.Cmd
@@ -111,24 +105,19 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
}
} else {
shell := strings.TrimSpace(initMsg.Data)
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
if shell == "" {
shell = detectShell()
log.Printf("terminal: auto-detected shell=%q", shell)
}
if shell == "" {
log.Printf("terminal: no shell detected, falling back to /bin/sh")
shell = "/bin/sh"
}
if path, err := exec.LookPath(shell); err == nil {
shell = path
log.Printf("terminal: resolved shell path=%q", shell)
}
if _, err := os.Stat(shell); err != nil {
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
@@ -148,14 +137,11 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
ptmx, err := pty.Start(cmd)
if err != nil {
log.Printf("terminal: pty start failed: %v", err)
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
log.Printf("terminal: pty started successfully")
var once sync.Once
cleanup := func() {

View File

@@ -2,7 +2,6 @@ package config
import (
"fmt"
"log"
"os"
"path/filepath"
@@ -162,7 +161,7 @@ func ConfigDir() (string, error) {
if _, err := os.Stat(legacyDir); err == nil {
if _, err := os.Stat(dir); err != nil {
if err := os.Rename(legacyDir, dir); err != nil {
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
_ = err
}
}
}

View File

@@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"regexp"
"strings"
@@ -17,6 +16,14 @@ import (
)
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
const maxHistorySize = 100
@@ -197,7 +204,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
return "", err
}
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
content := CleanAIResponse(chatResp.Choices[0].Message.Content)
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
@@ -297,7 +304,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
return fullContent.String(), fmt.Errorf("read stream: %w", err)
}
content := cleanAIResponse(fullContent.String())
content := CleanAIResponse(fullContent.String())
o.histMu.Lock()
o.history = append(o.history, Message{
Role: "assistant",
@@ -388,6 +395,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
var fullContent strings.Builder
var accumulatedToolCalls []ToolCallMsg
var totalTokens int
var insideToolBlock bool
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
@@ -411,7 +419,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
chunk := chatResp.Choices[0].Delta.Content
if chunk != "" {
fullContent.WriteString(chunk)
onChunk(chunk, nil)
cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
if cleanedChunk != "" {
onChunk(cleanedChunk, nil)
}
}
// Handle delta tool calls
@@ -463,15 +474,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
}{},
}
finalContent := cleanAIResponse(fullContent.String())
finalContent := CleanAIResponse(fullContent.String())
finalResp.Choices[0].Message.Content = finalContent
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
return finalResp, nil
}
func cleanAIResponse(content string) string {
func CleanAIResponse(content string) string {
content = thinkRegex.ReplaceAllString(content, "")
content = providerToolBlockRegex.ReplaceAllString(content, "")
content = providerTagRegex.ReplaceAllString(content, "")
content = xmlToolTagRegex.ReplaceAllString(content, "")
content = bracketToolCallRegex.ReplaceAllString(content, "")
lines := strings.Split(content, "\n")
var clean []string
inBlock := false
@@ -494,6 +509,35 @@ func cleanAIResponse(content string) string {
return result
}
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
func CleanStreamChunk(chunk string, insideBlock *bool) string {
if *insideBlock {
// Check for closing tag
if strings.Contains(chunk, ":tool_call>") {
*insideBlock = false
}
return ""
}
// Check for opening tool_call block
if streamBlockStartRegex.MatchString(chunk) {
*insideBlock = true
// If closing tag also in same chunk, emit nothing
if strings.Contains(chunk, ":tool_call>") {
*insideBlock = false
}
return ""
}
// Clean individual tags and bracket calls
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
return cleaned
}
func getProviderBaseURL(name string) string {
switch name {
case "minimax":
@@ -616,6 +660,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
return &chatResp, prov.Name, nil
}
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
return nil, "", lastErr
}

View File

@@ -33,6 +33,7 @@ type Skill struct {
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"`
}
type ValidationError struct {
@@ -155,6 +156,27 @@ func Delete(name string) error {
return nil
}
func IsDeployed(name string) bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
_, crushErr := os.Stat(crushPath)
_, claudeErr := os.Stat(claudePath)
return crushErr == nil || claudeErr == nil
}
func Undeploy(name string) error {
skill, err := Get(name)
if err != nil {
return err
}
undeployFromTargets(skill.Name)
return nil
}
func Update(skill *Skill) error {
if errs := Validate(skill); len(errs) > 0 {
return fmt.Errorf("validation failed: %v", errs)