Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'master' of github.com:quailyquaily/mistermorph

Lyric a5c7d6c3 b616f5d9

+1835 -1
+8
assets/config/config.example.yaml
··· 587 587 tool_repeat_limit: 3 588 588 # Overall run timeout. 589 589 timeout: "10m" 590 + 591 + # Chat display settings. 592 + chat: 593 + # Compact mode: omit user/assistant name prefixes in prompts and output. 594 + # When true, shows a green bullet (•) for user prompt and plain text for assistant. 595 + # Default: false (chat-style display with colored name prefixes). 596 + compact_mode: false 597 + 590 598 # Base directory for local state (memory/skills/heartbeat). 591 599 file_state_dir: "~/.morph" 592 600 # Prompt profile context injection.
+113
cmd/mistermorph/chatcmd/agents.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "os" 9 + "os/signal" 10 + "path/filepath" 11 + "strings" 12 + "time" 13 + 14 + "github.com/quailyquaily/mistermorph/agent" 15 + "github.com/quailyquaily/mistermorph/llm" 16 + ) 17 + 18 + func handleInitRead(writer io.Writer, agentsPath string) bool { 19 + if _, err := os.Stat(agentsPath); err == nil { 20 + data, err := os.ReadFile(agentsPath) 21 + if err != nil { 22 + _, _ = fmt.Fprintf(writer, "Error reading AGENTS.md: %v\n", err) 23 + } else { 24 + _, _ = fmt.Fprintln(writer, "\n--- AGENTS.md ---") 25 + _, _ = fmt.Fprintln(writer, string(data)) 26 + _, _ = fmt.Fprintln(writer, "-----------------") 27 + } 28 + return true 29 + } 30 + return false 31 + } 32 + 33 + func handleAgentsGenerate( 34 + writer io.Writer, 35 + input string, 36 + chatFileCacheDir string, 37 + timeout time.Duration, 38 + engine *agent.Engine, 39 + model string, 40 + history []llm.Message, 41 + ) ([]llm.Message, bool) { 42 + agentsPath := filepath.Join(chatFileCacheDir, "AGENTS.md") 43 + isUpdate := strings.ToLower(input) == "/update" 44 + if isUpdate { 45 + _, _ = fmt.Fprintln(writer, "\033[33m⚙️ Regenerating AGENTS.md...\033[0m") 46 + } 47 + stopInitAnim, _ := thinkingAnimation(writer) 48 + initCtx, initCancel := context.WithCancel(context.Background()) 49 + go func() { 50 + <-time.After(timeout) 51 + initCancel() 52 + }() 53 + sigCh := make(chan os.Signal, 1) 54 + signal.Notify(sigCh, os.Interrupt) 55 + go func() { 56 + select { 57 + case <-sigCh: 58 + initCancel() 59 + case <-initCtx.Done(): 60 + } 61 + signal.Stop(sigCh) 62 + }() 63 + initPrompt := fmt.Sprintf(`Please analyze the project in directory %q and generate an AGENTS.md file. 64 + 65 + AGENTS.md is a project-level guide for AI coding assistants. It should contain: 66 + 67 + 1. **Project Overview** — what this project does, its purpose, tech stack 68 + 2. **Directory Structure** — key directories and their purposes 69 + 3. **Build & Development** — how to build, test, run 70 + 4. **Coding Conventions** — naming, formatting, architecture patterns 71 + 5. **Key Dependencies** — major libraries/frameworks 72 + 6. **Special Notes** — anything AI assistants should know (env vars, config files, gotchas) 73 + 74 + Use bash and read_file tools to explore the project structure, README, go.mod, package.json, Makefile, etc. to gather accurate information. 75 + 76 + IMPORTANT: Do NOT use the write_file tool. Instead, write the final AGENTS.md content directly as your response text. Use markdown format. Be concise but thorough.`, chatFileCacheDir) 77 + final, _, err := engine.Run(initCtx, initPrompt, agent.RunOptions{ 78 + Model: strings.TrimSpace(model), 79 + Scene: "chat.init", 80 + History: append([]llm.Message(nil), history...), 81 + }) 82 + stopInitAnim() 83 + initCancel() 84 + if err != nil { 85 + if errors.Is(err, context.Canceled) { 86 + _, _ = fmt.Fprintln(writer, "\n\033[33m⚡ Interrupted.\033[0m") 87 + return history, false 88 + } 89 + _, _ = fmt.Fprintf(writer, "Error generating AGENTS.md: %v\n", err) 90 + return history, false 91 + } 92 + content := formatChatOutput(final) 93 + if content == "" { 94 + _, _ = fmt.Fprintln(writer, "AI returned empty content. AGENTS.md not created.") 95 + return history, false 96 + } 97 + content = stripMarkdownFences(content) 98 + if err := os.WriteFile(agentsPath, []byte(content), 0o644); err != nil { 99 + _, _ = fmt.Fprintf(writer, "Error writing AGENTS.md: %v\n", err) 100 + return history, false 101 + } 102 + if isUpdate { 103 + _, _ = fmt.Fprintf(writer, "\033[32m✓ AGENTS.md updated at %s\033[0m\n", agentsPath) 104 + } else { 105 + _, _ = fmt.Fprintf(writer, "\033[32m✓ AGENTS.md created at %s\033[0m\n", agentsPath) 106 + } 107 + _, _ = fmt.Fprintln(writer, "\n--- AGENTS.md ---") 108 + _, _ = fmt.Fprintln(writer, content) 109 + _, _ = fmt.Fprintln(writer, "-----------------") 110 + history = append(history, llm.Message{Role: "user", Content: fmt.Sprintf("I have initialized this project. Here is the AGENTS.md for this project:\n\n%s", content)}) 111 + history = append(history, llm.Message{Role: "assistant", Content: "Got it. I've read the AGENTS.md and understand this project's structure, conventions, and guidelines. I'm ready to help."}) 112 + return history, true 113 + }
+60
cmd/mistermorph/chatcmd/chat.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "os" 7 + "path/filepath" 8 + "time" 9 + 10 + "github.com/quailyquaily/mistermorph/guard" 11 + "github.com/quailyquaily/mistermorph/tools" 12 + "github.com/spf13/cobra" 13 + ) 14 + 15 + type Dependencies struct { 16 + RegistryFromViper func() *tools.Registry 17 + GuardFromViper func(*slog.Logger) *guard.Guard 18 + } 19 + 20 + func New(deps Dependencies) *cobra.Command { 21 + cmd := &cobra.Command{ 22 + Use: "chat", 23 + Short: "Start an interactive chat session", 24 + RunE: func(cmd *cobra.Command, args []string) error { 25 + sess, err := buildChatSession(cmd, deps) 26 + if err != nil { 27 + return fmt.Errorf("build chat session: %w", err) 28 + } 29 + defer sess.cleanup() 30 + return runREPL(sess) 31 + }, 32 + } 33 + 34 + cmd.Flags().String("provider", "", "Override LLM provider.") 35 + cmd.Flags().String("endpoint", "", "Override LLM endpoint.") 36 + cmd.Flags().String("model", "", "Override LLM model.") 37 + cmd.Flags().String("api-key", "", "Override API key.") 38 + cmd.Flags().String("profile", "", "Named LLM profile from config (e.g., 'kimi', 'gemini-alt', 'zhipu'). Overrides provider/model/api-key from the profile.") 39 + cmd.Flags().Duration("llm-request-timeout", 90*time.Second, "Per-LLM HTTP request timeout (0 uses provider default).") 40 + cmd.Flags().StringArray("skills-dir", nil, "Skills root directory (repeatable). Default: file_state_dir/skills") 41 + cmd.Flags().StringArray("skill", nil, "Skill(s) to load by name or id (repeatable).") 42 + cmd.Flags().Bool("skills-enabled", true, "Enable loading configured skills.") 43 + cmd.Flags().Int("max-steps", 15, "Max tool-call steps.") 44 + cmd.Flags().Int("parse-retries", 2, "Max JSON parse retries.") 45 + cmd.Flags().Int("max-token-budget", 0, "Max cumulative token budget (0 disables).") 46 + cmd.Flags().Int("tool-repeat-limit", 3, "Force final when the same successful tool call repeats this many times.") 47 + cmd.Flags().Duration("timeout", 30*time.Minute, "Overall timeout.") 48 + cmd.Flags().Bool("compact-mode", false, "Compact display mode: omit user/assistant name prefixes in prompts and output.") 49 + cmd.Flags().Bool("verbose", false, "Show info-level logs (default: only errors).") 50 + 51 + return cmd 52 + } 53 + 54 + func resolveChatFileCacheDir() (string, error) { 55 + wd, err := os.Getwd() 56 + if err != nil { 57 + return "", fmt.Errorf("resolve working directory for chat file_cache_dir: %w", err) 58 + } 59 + return filepath.Clean(wd), nil 60 + }
+102
cmd/mistermorph/chatcmd/commands.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "github.com/quailyquaily/mistermorph/internal/chatcommands" 7 + "github.com/quailyquaily/mistermorph/llm" 8 + "io" 9 + "path/filepath" 10 + ) 11 + 12 + // registerChatCommands binds all slash commands into the given registry. 13 + // Each handler receives the mutable session so it can update client/engine state 14 + // when necessary (e.g. /model). 15 + func registerChatCommands(reg *chatcommands.Registry, sess *chatSession, history *[]llm.Message) { 16 + writer := sess.writer 17 + 18 + reg.Register("/exit", func(ctx context.Context, args string) (*chatcommands.Result, error) { 19 + _, _ = fmt.Fprintln(writer, "Bye! 👋") 20 + return &chatcommands.Result{Quit: true}, nil 21 + }) 22 + 23 + reg.Register("/quit", func(ctx context.Context, args string) (*chatcommands.Result, error) { 24 + _, _ = fmt.Fprintln(writer, "Bye! 👋") 25 + return &chatcommands.Result{Quit: true}, nil 26 + }) 27 + 28 + reg.Register("/reset", func(ctx context.Context, args string) (*chatcommands.Result, error) { 29 + *history = nil 30 + return &chatcommands.Result{Reply: "Session reset."}, nil 31 + }) 32 + 33 + reg.Register("/memory", func(ctx context.Context, args string) (*chatcommands.Result, error) { 34 + handleMemory(writer, sess.memOrchestrator, sess.subjectID) 35 + return &chatcommands.Result{}, nil 36 + }) 37 + 38 + reg.Register("/help", chatcommands.HelpHandler(reg, "Available commands:")) 39 + 40 + reg.Register("/remember", func(ctx context.Context, args string) (*chatcommands.Result, error) { 41 + if args == "" { 42 + return &chatcommands.Result{Reply: "Usage: /remember <content>"}, nil 43 + } 44 + handleRemember(writer, "/remember "+args, sess.memManager, sess.subjectID) 45 + return &chatcommands.Result{}, nil 46 + }) 47 + 48 + reg.Register("/model", func(ctx context.Context, args string) (*chatcommands.Result, error) { 49 + text := "/model" 50 + if args != "" { 51 + text = "/model " + args 52 + } 53 + newClient, newCfg, handled := handleModelCommand(writer, text, sess.llmValues, sess.sessionStore, sess.buildClient) 54 + if handled { 55 + oldClient := sess.client 56 + oldCfg := sess.mainCfg 57 + oldEngine := sess.engine 58 + oldRegistry := sess.toolRegistry 59 + 60 + sess.client = newClient 61 + sess.mainCfg = newCfg 62 + if err := sess.rebuildRuntimeState(); err != nil { 63 + sess.client = oldClient 64 + sess.mainCfg = oldCfg 65 + sess.engine = oldEngine 66 + sess.toolRegistry = oldRegistry 67 + return nil, err 68 + } 69 + } 70 + return &chatcommands.Result{}, nil 71 + }) 72 + 73 + reg.Register("/init", func(ctx context.Context, args string) (*chatcommands.Result, error) { 74 + agentsPath := filepath.Join(sess.chatFileCacheDir, "AGENTS.md") 75 + if handleInitRead(writer, agentsPath) { 76 + return &chatcommands.Result{}, nil 77 + } 78 + newHistory, ok := handleAgentsGenerate(writer, "/init", sess.chatFileCacheDir, sess.timeout, sess.engine, sess.mainCfg.Model, *history) 79 + if ok { 80 + *history = newHistory 81 + } 82 + return &chatcommands.Result{}, nil 83 + }) 84 + 85 + reg.Register("/update", func(ctx context.Context, args string) (*chatcommands.Result, error) { 86 + newHistory, ok := handleAgentsGenerate(writer, "/update", sess.chatFileCacheDir, sess.timeout, sess.engine, sess.mainCfg.Model, *history) 87 + if ok { 88 + *history = newHistory 89 + } 90 + return &chatcommands.Result{}, nil 91 + }) 92 + } 93 + 94 + // handleExit prints the exit message. 95 + func handleExit(writer io.Writer) { 96 + _, _ = fmt.Fprintln(writer, "Bye! 👋") 97 + } 98 + 99 + // handleHelp prints the help text. 100 + func handleHelp(writer io.Writer) { 101 + _, _ = fmt.Fprintln(writer, "Commands: /exit, /quit, /reset, /memory, /remember <content>, /model, /init, /update, /help") 102 + }
+129
cmd/mistermorph/chatcmd/format.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/quailyquaily/mistermorph/agent" 9 + ) 10 + 11 + func formatChatOutput(final *agent.Final) string { 12 + if final == nil { 13 + return "" 14 + } 15 + switch output := final.Output.(type) { 16 + case string: 17 + return strings.TrimSpace(output) 18 + case nil: 19 + payload, _ := json.MarshalIndent(final, "", " ") 20 + return strings.TrimSpace(string(payload)) 21 + default: 22 + payload, err := json.MarshalIndent(output, "", " ") 23 + if err != nil { 24 + return strings.TrimSpace(fmt.Sprint(output)) 25 + } 26 + return strings.TrimSpace(string(payload)) 27 + } 28 + } 29 + 30 + func formatPlanProgressUpdate(runCtx *agent.Context, update agent.PlanStepUpdate) string { 31 + if runCtx == nil || runCtx.Plan == nil { 32 + return "" 33 + } 34 + if update.CompletedIndex < 0 && update.StartedIndex < 0 { 35 + return "" 36 + } 37 + total := len(runCtx.Plan.Steps) 38 + if total == 0 { 39 + return "" 40 + } 41 + 42 + if update.CompletedIndex >= 0 && update.CompletedIndex == total-1 && update.StartedIndex < 0 { 43 + return "" 44 + } 45 + 46 + var b strings.Builder 47 + b.WriteString("plan: ") 48 + 49 + if update.CompletedIndex >= 0 && update.CompletedStep != "" { 50 + b.WriteString(fmt.Sprintf("✓ %s", update.CompletedStep)) 51 + } 52 + 53 + if update.StartedIndex >= 0 && update.StartedStep != "" { 54 + if update.CompletedIndex >= 0 { 55 + b.WriteString(" → ") 56 + } 57 + b.WriteString(update.StartedStep) 58 + } 59 + 60 + if update.CompletedIndex >= 0 { 61 + b.WriteString(fmt.Sprintf(" [%d/%d]", update.CompletedIndex+1, total)) 62 + } else if update.StartedIndex >= 0 { 63 + b.WriteString(fmt.Sprintf(" [%d/%d]", update.StartedIndex+1, total)) 64 + } 65 + 66 + return b.String() 67 + } 68 + 69 + func stripMarkdownFences(content string) string { 70 + content = strings.TrimSpace(content) 71 + if strings.HasPrefix(content, "```markdown") { 72 + content = strings.TrimPrefix(content, "```markdown") 73 + content = strings.TrimSpace(content) 74 + if strings.HasSuffix(content, "```") { 75 + content = strings.TrimSuffix(content, "```") 76 + content = strings.TrimSpace(content) 77 + } 78 + return content 79 + } 80 + if strings.HasPrefix(content, "```") { 81 + idx := strings.Index(content, "\n") 82 + if idx > 0 { 83 + content = content[idx+1:] 84 + } else { 85 + content = strings.TrimPrefix(content, "```") 86 + } 87 + content = strings.TrimSpace(content) 88 + if strings.HasSuffix(content, "```") { 89 + content = strings.TrimSuffix(content, "```") 90 + content = strings.TrimSpace(content) 91 + } 92 + return content 93 + } 94 + return content 95 + } 96 + 97 + func truncateString(s string, maxLen int) string { 98 + if maxLen <= 3 { 99 + return s 100 + } 101 + runes := []rune(s) 102 + if len(runes) <= maxLen { 103 + return s 104 + } 105 + return string(runes[:maxLen-3]) + "..." 106 + } 107 + 108 + func stringDisplayWidth(s string) int { 109 + w := 0 110 + for _, r := range s { 111 + w += runeDisplayWidth(r) 112 + } 113 + return w 114 + } 115 + 116 + func runeDisplayWidth(r rune) int { 117 + if r < 0x20 || (r >= 0x7f && r < 0xa0) { 118 + return 0 119 + } 120 + if r >= 0x1100 && 121 + (r <= 0x115f || r == 0x2329 || r == 0x232a || (r >= 0x2e80 && r <= 0xa4cf && r != 0x303f) || 122 + (r >= 0xac00 && r <= 0xd7a3) || (r >= 0xf900 && r <= 0xfaff) || 123 + (r >= 0xfe10 && r <= 0xfe19) || (r >= 0xfe30 && r <= 0xfe6f) || 124 + (r >= 0xff00 && r <= 0xff60) || (r >= 0xffe0 && r <= 0xffe6) || 125 + (r >= 0x20000 && r <= 0x2fffd) || (r >= 0x30000 && r <= 0x3fffd)) { 126 + return 2 127 + } 128 + return 1 129 + }
+188
cmd/mistermorph/chatcmd/memory.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/hex" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "strings" 10 + 11 + "github.com/quailyquaily/mistermorph/agent" 12 + "github.com/quailyquaily/mistermorph/internal/memoryruntime" 13 + "github.com/quailyquaily/mistermorph/internal/statepaths" 14 + "github.com/quailyquaily/mistermorph/memory" 15 + ) 16 + 17 + func buildTurnSummary(userInput, assistantOutput string, steps []agent.Step) string { 18 + userInput = strings.TrimSpace(userInput) 19 + if userInput == "" { 20 + return "" 21 + } 22 + 23 + lower := strings.ToLower(userInput) 24 + if strings.HasPrefix(lower, "/remember") || strings.HasPrefix(lower, "/memory") { 25 + return "" 26 + } 27 + 28 + var toolNames []string 29 + for _, step := range steps { 30 + if step.Action != "" { 31 + toolNames = append(toolNames, step.Action) 32 + } 33 + } 34 + 35 + if len(toolNames) == 0 { 36 + return "" 37 + } 38 + 39 + summary := userInput 40 + if len(toolNames) > 0 { 41 + summary += fmt.Sprintf(" (tools: %s)", strings.Join(toolNames, ", ")) 42 + } 43 + 44 + const maxLen = 200 45 + if len(summary) > maxLen { 46 + summary = summary[:maxLen-3] + "..." 47 + } 48 + return summary 49 + } 50 + 51 + func cliMemorySubjectID(cwd string) string { 52 + h := sha256.Sum256([]byte(cwd)) 53 + return "cli_" + hex.EncodeToString(h[:])[:16] 54 + } 55 + 56 + func initChatMemoryRuntime(cwd string, logger *slog.Logger) (*memory.Manager, *memoryruntime.Orchestrator, *memoryruntime.ProjectionWorker, func(), error) { 57 + mgr := memory.NewManager(statepaths.MemoryDir(), 7) 58 + journal := mgr.NewJournal(memory.JournalOptions{}) 59 + 60 + projector := memory.NewProjector(mgr, journal, memory.ProjectorOptions{ 61 + DraftResolver: memoryruntime.NewDraftResolver(nil, ""), 62 + }) 63 + 64 + orchestrator, err := memoryruntime.New(mgr, journal, projector, memoryruntime.OrchestratorOptions{}) 65 + if err != nil { 66 + _ = journal.Close() 67 + return nil, nil, nil, nil, err 68 + } 69 + 70 + projectionWorker, err := memoryruntime.NewProjectionWorker(journal, projector, memoryruntime.ProjectionWorkerOptions{ 71 + Logger: logger, 72 + }) 73 + if err != nil { 74 + _ = journal.Close() 75 + return nil, nil, nil, nil, err 76 + } 77 + 78 + cleanup := func() { 79 + _ = journal.Close() 80 + } 81 + 82 + return mgr, orchestrator, projectionWorker, cleanup, nil 83 + } 84 + 85 + func autoUpdateMemory( 86 + writer io.Writer, 87 + logger *slog.Logger, 88 + memOrchestrator *memoryruntime.Orchestrator, 89 + memWorker *memoryruntime.ProjectionWorker, 90 + subjectID string, 91 + runID string, 92 + input, output string, 93 + steps []agent.Step, 94 + ) { 95 + if len(steps) == 0 || memOrchestrator == nil { 96 + return 97 + } 98 + summary := buildTurnSummary(input, output, steps) 99 + if summary == "" { 100 + return 101 + } 102 + _, recErr := memOrchestrator.Record(memoryruntime.RecordRequest{ 103 + TaskRunID: runID, 104 + SessionID: subjectID, 105 + SubjectID: subjectID, 106 + Channel: "cli", 107 + TaskText: input, 108 + FinalOutput: summary, 109 + SessionContext: memory.SessionContext{ 110 + ConversationID: subjectID, 111 + }, 112 + }) 113 + if recErr != nil { 114 + logger.Warn("chat_memory_record_failed", "error", recErr.Error()) 115 + } else { 116 + if memWorker != nil { 117 + memWorker.NotifyRecordAppended() 118 + } 119 + logger.Debug("chat_memory_recorded", "summary", summary) 120 + } 121 + } 122 + 123 + func handleRemember( 124 + writer io.Writer, 125 + input string, 126 + mgr *memory.Manager, 127 + subjectID string, 128 + ) { 129 + entry := input[len("/remember "):] 130 + if entry == "" { 131 + _, _ = fmt.Fprintln(writer, "Usage: /remember <content>") 132 + return 133 + } 134 + if mgr == nil { 135 + _, _ = fmt.Fprintln(writer, "Memory system not available.") 136 + return 137 + } 138 + updated, err := mgr.UpdateLongTerm(subjectID, memory.PromoteDraft{ 139 + GoalsProjects: []string{entry}, 140 + }) 141 + if err != nil { 142 + _, _ = fmt.Fprintf(writer, "error saving long-term memory: %v\n", err) 143 + return 144 + } 145 + if !updated { 146 + _, _ = fmt.Fprintln(writer, "No long-term memory added.") 147 + return 148 + } 149 + _, _ = fmt.Fprintln(writer, "Remembered.") 150 + } 151 + 152 + func handleMemory( 153 + writer io.Writer, 154 + memOrchestrator *memoryruntime.Orchestrator, 155 + subjectID string, 156 + ) { 157 + if memOrchestrator == nil { 158 + _, _ = fmt.Fprintln(writer, "No project memory yet.") 159 + return 160 + } 161 + memCtx, memErr := memOrchestrator.PrepareInjection(memoryruntime.PrepareInjectionRequest{ 162 + SubjectID: subjectID, 163 + RequestContext: memory.ContextPrivate, 164 + MaxItems: 50, 165 + }) 166 + if memErr != nil { 167 + _, _ = fmt.Fprintf(writer, "Error loading memory: %v\n", memErr) 168 + return 169 + } 170 + if strings.TrimSpace(memCtx) == "" { 171 + _, _ = fmt.Fprintln(writer, "No project memory yet.") 172 + return 173 + } 174 + _, _ = fmt.Fprintln(writer, "\n--- Project Memory ---") 175 + _, _ = fmt.Fprintln(writer, memCtx) 176 + _, _ = fmt.Fprintln(writer, "----------------------") 177 + } 178 + 179 + func prepareTurnMemoryContext(memOrchestrator *memoryruntime.Orchestrator, subjectID string) (string, error) { 180 + if memOrchestrator == nil || strings.TrimSpace(subjectID) == "" { 181 + return "", nil 182 + } 183 + return memOrchestrator.PrepareInjection(memoryruntime.PrepareInjectionRequest{ 184 + SubjectID: subjectID, 185 + RequestContext: memory.ContextPrivate, 186 + MaxItems: 20, 187 + }) 188 + }
+49
cmd/mistermorph/chatcmd/modelcmd.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + 7 + "github.com/quailyquaily/mistermorph/internal/llmconfig" 8 + "github.com/quailyquaily/mistermorph/internal/llmselect" 9 + "github.com/quailyquaily/mistermorph/internal/llmutil" 10 + "github.com/quailyquaily/mistermorph/llm" 11 + ) 12 + 13 + func handleModelCommand( 14 + writer io.Writer, 15 + input string, 16 + llmValues llmutil.RuntimeValues, 17 + sessionStore *llmselect.Store, 18 + buildClient func(route llmutil.ResolvedRoute, cfgOverride *llmconfig.ClientConfig) (llm.Client, error), 19 + ) (llm.Client, llmconfig.ClientConfig, bool) { 20 + prev := sessionStore.Get() 21 + output, handled, err := llmselect.ExecuteCommandText(llmValues, sessionStore, input) 22 + if !handled { 23 + return nil, llmconfig.ClientConfig{}, false 24 + } 25 + if err != nil { 26 + _, _ = fmt.Fprintf(writer, "error: %v\n", err) 27 + return nil, llmconfig.ClientConfig{}, false 28 + } 29 + _, _ = fmt.Fprintln(writer, output) 30 + 31 + sel := sessionStore.Get() 32 + if sel == prev { 33 + return nil, llmconfig.ClientConfig{}, false 34 + } 35 + 36 + newRoute, err := llmselect.ResolveMainRoute(llmValues, sel) 37 + if err != nil { 38 + _, _ = fmt.Fprintf(writer, "error resolving route: %v\n", err) 39 + return nil, llmconfig.ClientConfig{}, false 40 + } 41 + newCfg := newRoute.ClientConfig 42 + newClient, err := buildClient(newRoute, &newCfg) 43 + if err != nil { 44 + _, _ = fmt.Fprintf(writer, "error rebuilding client: %v\n", err) 45 + return nil, llmconfig.ClientConfig{}, false 46 + } 47 + _, _ = fmt.Fprintf(writer, "\033[90m[active model: %s]\033[0m\n", newCfg.Model) 48 + return newClient, newCfg, true 49 + }
+170
cmd/mistermorph/chatcmd/repl.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "os" 9 + "os/signal" 10 + "path/filepath" 11 + "strings" 12 + "time" 13 + 14 + "github.com/chzyer/readline" 15 + "github.com/quailyquaily/mistermorph/agent" 16 + "github.com/quailyquaily/mistermorph/internal/chatcommands" 17 + "github.com/quailyquaily/mistermorph/internal/llmstats" 18 + "github.com/quailyquaily/mistermorph/internal/outputfmt" 19 + "github.com/quailyquaily/mistermorph/llm" 20 + ) 21 + 22 + func runREPL(sess *chatSession) error { 23 + userPrompt := buildUserPrompt(sess.compactMode, sess.userName) 24 + 25 + autoComplete := readline.NewPrefixCompleter( 26 + readline.PcItem("/exit"), 27 + readline.PcItem("/quit"), 28 + readline.PcItem("/reset"), 29 + readline.PcItem("/memory"), 30 + readline.PcItem("/remember "), 31 + readline.PcItem("/init"), 32 + readline.PcItem("/update"), 33 + readline.PcItem("/model"), 34 + readline.PcItem("/help"), 35 + ) 36 + 37 + rl, err := readline.NewEx(&readline.Config{ 38 + Prompt: userPrompt, 39 + HistoryFile: filepath.Join(os.Getenv("HOME"), ".mistermorph_chat_history"), 40 + AutoComplete: autoComplete, 41 + Stdout: sess.cmd.OutOrStdout(), 42 + Stderr: sess.cmd.OutOrStderr(), 43 + }) 44 + if err != nil { 45 + return err 46 + } 47 + defer rl.Close() 48 + 49 + sess.setWriter(rl.Stdout()) 50 + writer := sess.currentWriter() 51 + 52 + printChatSessionHeader(writer, strings.TrimSpace(sess.mainCfg.Model), sess.chatFileCacheDir) 53 + 54 + reg := chatcommands.NewRegistry() 55 + history := make([]llm.Message, 0, 32) 56 + registerChatCommands(reg, sess, &history) 57 + 58 + turn := 0 59 + for { 60 + input, err := rl.Readline() 61 + if err != nil { 62 + if err == readline.ErrInterrupt { 63 + if len(input) == 0 { 64 + _, _ = fmt.Fprintln(writer, "\nBye! 👋") 65 + return nil 66 + } 67 + continue 68 + } 69 + if err == io.EOF { 70 + _, _ = fmt.Fprintln(writer) 71 + return nil 72 + } 73 + return err 74 + } 75 + input = strings.TrimSpace(input) 76 + if input == "" { 77 + continue 78 + } 79 + 80 + // Try dispatching as a slash command 81 + cmd, _ := chatcommands.ParseCommand(input) 82 + if cmd != "" { 83 + result, handled, err := reg.Dispatch(context.Background(), input) 84 + if err != nil { 85 + _, _ = fmt.Fprintf(writer, "error: %v\n", err) 86 + continue 87 + } 88 + if handled { 89 + if result != nil && result.Quit { 90 + return nil 91 + } 92 + if result != nil && result.Reply != "" { 93 + _, _ = fmt.Fprintln(writer, result.Reply) 94 + } 95 + continue 96 + } 97 + } 98 + 99 + // Not a command — run an agent turn 100 + turnCtx, turnCancel := context.WithCancel(context.Background()) 101 + go func() { 102 + <-time.After(sess.timeout) 103 + turnCancel() 104 + }() 105 + runID := llmstats.NewSyntheticRunID("chat") 106 + turnCtx = llmstats.WithRunID(turnCtx, runID) 107 + sess.startThinkingAnimation() 108 + sigCh := make(chan os.Signal, 1) 109 + signal.Notify(sigCh, os.Interrupt) 110 + go func() { 111 + select { 112 + case <-sigCh: 113 + turnCancel() 114 + case <-turnCtx.Done(): 115 + } 116 + signal.Stop(sigCh) 117 + }() 118 + 119 + memoryContext, memErr := prepareTurnMemoryContext(sess.memOrchestrator, sess.subjectID) 120 + if memErr != nil { 121 + sess.logger.Warn("chat_memory_injection_failed", "error", memErr.Error()) 122 + } 123 + 124 + final, runCtx, err := sess.engine.Run(turnCtx, input, agent.RunOptions{ 125 + Model: strings.TrimSpace(sess.mainCfg.Model), 126 + Scene: "chat.interactive", 127 + History: append([]llm.Message(nil), history...), 128 + MemoryContext: memoryContext, 129 + }) 130 + 131 + sess.stopThinkingAnimation() 132 + turnCancel() 133 + if err != nil { 134 + if errors.Is(err, context.Canceled) { 135 + _, _ = fmt.Fprintln(writer, "\n\033[33m⚡ Interrupted.\033[0m") 136 + continue 137 + } 138 + displayErr := strings.TrimSpace(outputfmt.FormatErrorForDisplay(err)) 139 + if displayErr == "" { 140 + displayErr = strings.TrimSpace(err.Error()) 141 + } 142 + _, _ = fmt.Fprintf(writer, "error: %s\n", displayErr) 143 + continue 144 + } 145 + 146 + output := formatChatOutput(final) 147 + if sess.compactMode { 148 + _, _ = fmt.Fprintf(writer, "%s\n", output) 149 + } else { 150 + _, _ = fmt.Fprintf(writer, "\033[48;5;208m\033[30m %s> \033[0m %s\n", sess.agentName, output) 151 + } 152 + 153 + history = append(history, 154 + llm.Message{Role: "user", Content: input}, 155 + llm.Message{Role: "assistant", Content: output}, 156 + ) 157 + 158 + sess.logger.Info("chat_turn_done", 159 + "turn", turn, 160 + "steps", len(runCtx.Steps), 161 + "llm_rounds", runCtx.Metrics.LLMRounds, 162 + "total_tokens", runCtx.Metrics.TotalTokens, 163 + ) 164 + 165 + // Auto-update memory if there were tool calls 166 + autoUpdateMemory(writer, sess.logger, sess.memOrchestrator, sess.memWorker, sess.subjectID, runID, input, output, runCtx.Steps) 167 + 168 + turn++ 169 + } 170 + }
+460
cmd/mistermorph/chatcmd/session.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "log/slog" 8 + "strings" 9 + "sync" 10 + "time" 11 + 12 + "github.com/quailyquaily/mistermorph/agent" 13 + "github.com/quailyquaily/mistermorph/internal/acpclient" 14 + "github.com/quailyquaily/mistermorph/internal/configutil" 15 + "github.com/quailyquaily/mistermorph/internal/llmconfig" 16 + "github.com/quailyquaily/mistermorph/internal/llmselect" 17 + "github.com/quailyquaily/mistermorph/internal/llmstats" 18 + "github.com/quailyquaily/mistermorph/internal/llmutil" 19 + "github.com/quailyquaily/mistermorph/internal/logutil" 20 + "github.com/quailyquaily/mistermorph/internal/memoryruntime" 21 + "github.com/quailyquaily/mistermorph/internal/personautil" 22 + "github.com/quailyquaily/mistermorph/internal/promptprofile" 23 + "github.com/quailyquaily/mistermorph/internal/skillsutil" 24 + "github.com/quailyquaily/mistermorph/internal/statepaths" 25 + "github.com/quailyquaily/mistermorph/internal/toolsutil" 26 + "github.com/quailyquaily/mistermorph/llm" 27 + "github.com/quailyquaily/mistermorph/memory" 28 + "github.com/quailyquaily/mistermorph/tools" 29 + "github.com/spf13/cobra" 30 + "github.com/spf13/viper" 31 + ) 32 + 33 + type chatSession struct { 34 + cmd *cobra.Command 35 + deps Dependencies 36 + logger *slog.Logger 37 + logOpts agent.LogOptions 38 + client llm.Client 39 + mainCfg llmconfig.ClientConfig 40 + engine *agent.Engine 41 + toolRegistry *tools.Registry 42 + runtimeToolsCfg toolsutil.RuntimeToolsRegisterConfig 43 + memManager *memory.Manager 44 + memOrchestrator *memoryruntime.Orchestrator 45 + memWorker *memoryruntime.ProjectionWorker 46 + memCleanup func() 47 + subjectID string 48 + compactMode bool 49 + userName string 50 + agentName string 51 + chatFileCacheDir string 52 + sessionStore *llmselect.Store 53 + llmValues llmutil.RuntimeValues 54 + buildClient func(llmutil.ResolvedRoute, *llmconfig.ClientConfig) (llm.Client, error) 55 + makeEngine func(*tools.Registry, llm.Client, string) *agent.Engine 56 + promptSpec agent.PromptSpec 57 + timeout time.Duration 58 + writer io.Writer 59 + uiMu sync.Mutex 60 + stopAnim func() 61 + setAnimMessage func(string) 62 + } 63 + 64 + func cloneToolRegistry(base *tools.Registry) *tools.Registry { 65 + reg := tools.NewRegistry() 66 + if base == nil { 67 + return reg 68 + } 69 + for _, t := range base.All() { 70 + reg.Register(t) 71 + } 72 + return reg 73 + } 74 + 75 + func buildChatToolRegistry(deps Dependencies) *tools.Registry { 76 + if deps.RegistryFromViper == nil { 77 + return tools.NewRegistry() 78 + } 79 + return cloneToolRegistry(deps.RegistryFromViper()) 80 + } 81 + 82 + func (s *chatSession) rebuildRuntimeState() error { 83 + currentRoute, err := llmselect.ResolveMainRoute(s.llmValues, s.sessionStore.Get()) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + reg := buildChatToolRegistry(s.deps) 89 + 90 + planRoute, err := llmutil.ResolveRoute(s.llmValues, llmutil.RoutePurposePlanCreate) 91 + if err != nil { 92 + return err 93 + } 94 + planClient := s.client 95 + if !planRoute.SameProfile(currentRoute) { 96 + planClient, err = s.buildClient(planRoute, nil) 97 + if err != nil { 98 + return err 99 + } 100 + } 101 + 102 + toolsutil.RegisterRuntimeTools(reg, s.runtimeToolsCfg, toolsutil.RuntimeToolLLMOptions{ 103 + DefaultClient: s.client, 104 + DefaultModel: strings.TrimSpace(s.mainCfg.Model), 105 + PlanCreateClient: planClient, 106 + PlanCreateModel: strings.TrimSpace(planRoute.ClientConfig.Model), 107 + }) 108 + 109 + s.toolRegistry = reg 110 + s.engine = s.makeEngine(reg, s.client, s.mainCfg.Model) 111 + return nil 112 + } 113 + 114 + func (s *chatSession) setWriter(writer io.Writer) { 115 + if s == nil { 116 + return 117 + } 118 + s.uiMu.Lock() 119 + s.writer = writer 120 + s.uiMu.Unlock() 121 + } 122 + 123 + func (s *chatSession) currentWriter() io.Writer { 124 + if s == nil { 125 + return io.Discard 126 + } 127 + s.uiMu.Lock() 128 + writer := s.writer 129 + cmd := s.cmd 130 + s.uiMu.Unlock() 131 + if writer != nil { 132 + return writer 133 + } 134 + if cmd != nil { 135 + return cmd.OutOrStdout() 136 + } 137 + return io.Discard 138 + } 139 + 140 + func (s *chatSession) startThinkingAnimation() { 141 + if s == nil { 142 + return 143 + } 144 + writer := s.currentWriter() 145 + stopAnim, setAnimMessage := thinkingAnimation(writer) 146 + s.uiMu.Lock() 147 + s.stopAnim = stopAnim 148 + s.setAnimMessage = setAnimMessage 149 + s.uiMu.Unlock() 150 + } 151 + 152 + func (s *chatSession) stopThinkingAnimation() { 153 + if s == nil { 154 + return 155 + } 156 + s.uiMu.Lock() 157 + stopAnim := s.stopAnim 158 + s.stopAnim = nil 159 + s.setAnimMessage = nil 160 + s.uiMu.Unlock() 161 + if stopAnim != nil { 162 + stopAnim() 163 + } 164 + } 165 + 166 + func (s *chatSession) setThinkingMessage(msg string) { 167 + if s == nil { 168 + return 169 + } 170 + s.uiMu.Lock() 171 + setAnimMessage := s.setAnimMessage 172 + s.uiMu.Unlock() 173 + if setAnimMessage != nil { 174 + setAnimMessage(msg) 175 + } 176 + } 177 + 178 + func buildChatSession(cmd *cobra.Command, deps Dependencies) (*chatSession, error) { 179 + timeout := configutil.FlagOrViperDuration(cmd, "timeout", "timeout") 180 + 181 + verbose, _ := cmd.Flags().GetBool("verbose") 182 + loggerCfg := logutil.LoggerConfigFromViper() 183 + if !verbose { 184 + loggerCfg.Level = "error" 185 + } 186 + logger, err := logutil.LoggerFromConfig(loggerCfg) 187 + if err != nil { 188 + return nil, err 189 + } 190 + slog.SetDefault(logger) 191 + logOpts := logutil.LogOptionsFromViper() 192 + 193 + chatFileCacheDir, err := resolveChatFileCacheDir() 194 + if err != nil { 195 + return nil, err 196 + } 197 + viper.Set("file_cache_dir", chatFileCacheDir) 198 + 199 + llmValues := llmutil.RuntimeValuesFromViper() 200 + mainRoute, err := llmutil.ResolveRoute(llmValues, llmutil.RoutePurposeMainLoop) 201 + if err != nil { 202 + return nil, err 203 + } 204 + 205 + // Support --profile flag to use a named LLM profile from config 206 + if cmd.Flags().Changed("profile") { 207 + profileName, _ := cmd.Flags().GetString("profile") 208 + profileName = strings.TrimSpace(profileName) 209 + if profileName != "" { 210 + mainRoute, err = llmutil.ResolveRouteWithProfileOverride(llmValues, llmutil.RoutePurposeMainLoop, profileName) 211 + if err != nil { 212 + return nil, fmt.Errorf("failed to resolve profile %q: %w", profileName, err) 213 + } 214 + } 215 + } 216 + 217 + mainCfg := mainRoute.ClientConfig 218 + if cmd.Flags().Changed("provider") { 219 + mainCfg.Provider = strings.TrimSpace(configutil.FlagOrViperString(cmd, "provider", "")) 220 + } 221 + if cmd.Flags().Changed("endpoint") { 222 + mainCfg.Endpoint = strings.TrimSpace(configutil.FlagOrViperString(cmd, "endpoint", "")) 223 + } 224 + if cmd.Flags().Changed("api-key") { 225 + mainCfg.APIKey = strings.TrimSpace(configutil.FlagOrViperString(cmd, "api-key", "")) 226 + } 227 + if cmd.Flags().Changed("model") { 228 + mainCfg.Model = strings.TrimSpace(configutil.FlagOrViperString(cmd, "model", "")) 229 + } 230 + if cmd.Flags().Changed("llm-request-timeout") { 231 + mainCfg.RequestTimeout = configutil.FlagOrViperDuration(cmd, "llm-request-timeout", "llm.request_timeout") 232 + } 233 + 234 + // Session-level model selection store (per-chat session, not global) 235 + sessionStore := llmselect.NewStore() 236 + if cmd.Flags().Changed("profile") { 237 + profileName, _ := cmd.Flags().GetString("profile") 238 + if strings.TrimSpace(profileName) != "" { 239 + sessionStore.SetProfile(profileName) 240 + } 241 + } 242 + 243 + buildClient := func(route llmutil.ResolvedRoute, cfgOverride *llmconfig.ClientConfig) (llm.Client, error) { 244 + return llmutil.BuildRouteClient( 245 + route, 246 + cfgOverride, 247 + llmutil.ClientFromConfigWithValues, 248 + func(client llm.Client, cfg llmconfig.ClientConfig, _ string) llm.Client { 249 + return llmstats.WrapRuntimeClient(client, cfg.Provider, cfg.Endpoint, cfg.Model, logger) 250 + }, 251 + logger, 252 + ) 253 + } 254 + 255 + client, err := buildClient(mainRoute, &mainCfg) 256 + if err != nil { 257 + return nil, err 258 + } 259 + 260 + reg := buildChatToolRegistry(deps) 261 + runtimeToolsCfg := toolsutil.LoadRuntimeToolsRegisterConfigFromViper() 262 + 263 + planClient := client 264 + planModel := strings.TrimSpace(mainCfg.Model) 265 + planRoute, err := llmutil.ResolveRoute(llmValues, llmutil.RoutePurposePlanCreate) 266 + if err != nil { 267 + return nil, err 268 + } 269 + if !planRoute.SameProfile(mainRoute) { 270 + planClient, err = llmutil.BuildRouteClient( 271 + planRoute, 272 + nil, 273 + llmutil.ClientFromConfigWithValues, 274 + func(client llm.Client, cfg llmconfig.ClientConfig, _ string) llm.Client { 275 + return llmstats.WrapRuntimeClient(client, cfg.Provider, cfg.Endpoint, cfg.Model, logger) 276 + }, 277 + logger, 278 + ) 279 + if err != nil { 280 + return nil, err 281 + } 282 + } 283 + planModel = strings.TrimSpace(planRoute.ClientConfig.Model) 284 + toolsutil.RegisterRuntimeTools(reg, runtimeToolsCfg, toolsutil.RuntimeToolLLMOptions{ 285 + DefaultClient: client, 286 + DefaultModel: strings.TrimSpace(mainCfg.Model), 287 + PlanCreateClient: planClient, 288 + PlanCreateModel: planModel, 289 + }) 290 + 291 + // Use a long-lived context for the memory projection worker so it survives 292 + // beyond buildChatSession(). The worker is stopped when the REPL exits via 293 + // sess.cleanup() which cancels this context. 294 + workerCtx, workerCancel := context.WithCancel(context.Background()) 295 + 296 + promptCtx, cancel := context.WithTimeout(context.Background(), timeout) 297 + defer cancel() 298 + 299 + promptSpec, _, err := skillsutil.PromptSpecWithSkills(promptCtx, logger, logOpts, "Interactive chat session", client, strings.TrimSpace(mainCfg.Model), skillsutil.SkillsConfigFromRunCmd(cmd)) 300 + if err != nil { 301 + return nil, err 302 + } 303 + promptprofile.ApplyPersonaIdentity(&promptSpec, logger) 304 + promptprofile.AppendLocalToolNotesBlock(&promptSpec, logger) 305 + promptprofile.AppendPlanCreateGuidanceBlock(&promptSpec, reg) 306 + promptprofile.AppendTodoWorkflowBlock(&promptSpec, reg) 307 + promptprofile.AppendGPT5PromptPatch(&promptSpec, strings.TrimSpace(mainCfg.Model), logger) 308 + 309 + // Inject chat working directory context into system prompt 310 + promptSpec.Blocks = append([]agent.PromptBlock{{ 311 + Content: fmt.Sprintf("## Chat Session Context\n\n"+ 312 + "You are running in an interactive chat session. The user's current working directory is:\n\n"+ 313 + " %s\n\n"+ 314 + "CRITICAL: All file operations (read_file, write_file, bash) MUST use paths relative to THIS directory by default. "+ 315 + "When the user asks you to create or modify files WITHOUT specifying a path, write them to this directory (NOT to file_state_dir or ~/.morph/). "+ 316 + "Only use file_state_dir or ~/.morph/ when the user explicitly asks for configuration files, memory, or state storage. "+ 317 + "You may use `bash` with `ls`, `find`, etc. to explore the directory structure when needed.\n\n"+ 318 + "## Built-in Chat Commands\n\n"+ 319 + "The user can type these special commands at any time:\n"+ 320 + "- `/exit` or `/quit` — exit the chat session\n"+ 321 + "- `/reset` — reset the current conversation (clear history, keep memory)\n"+ 322 + "- `/memory` — display the current project memory\n"+ 323 + "- `/remember <content>` — add a long-term memory item for the current project\n"+ 324 + "- `/init` — generate an AGENTS.md file for the current project (analyzes the codebase and creates a guide for AI assistants)\n"+ 325 + "- `/update` — regenerate AGENTS.md, overwriting the existing file (useful after major project changes)\n"+ 326 + "If the user asks about any of these commands, explain what they do.", chatFileCacheDir), 327 + }}, promptSpec.Blocks...) 328 + 329 + // Initialize memory runtime 330 + subjectID := cliMemorySubjectID(chatFileCacheDir) 331 + memManager, memOrchestrator, memWorker, memCleanup, err := initChatMemoryRuntime(chatFileCacheDir, logger) 332 + if err != nil { 333 + logger.Warn("chat_memory_init_failed", "error", err.Error()) 334 + } 335 + if memWorker != nil { 336 + memWorker.Start(workerCtx) 337 + } 338 + 339 + var opts []agent.Option 340 + opts = append(opts, agent.WithLogger(logger)) 341 + opts = append(opts, agent.WithLogOptions(logOpts)) 342 + 343 + if deps.GuardFromViper != nil { 344 + if g := deps.GuardFromViper(logger); g != nil { 345 + opts = append(opts, agent.WithGuard(g)) 346 + } 347 + } 348 + 349 + // Determine compact mode from flag or config. 350 + compactMode := configutil.FlagOrViperBool(cmd, "compact-mode", "chat.compact_mode") 351 + 352 + // Get system username for user prompt 353 + userName := buildUserName() 354 + 355 + // Load persona name for assistant display 356 + agentName := personautil.LoadAgentName(statepaths.FileStateDir()) 357 + if agentName == "" { 358 + agentName = "assistant" 359 + } 360 + 361 + var sess *chatSession 362 + 363 + // Add tool start callback to show what tools are being used 364 + opts = append(opts, agent.WithOnToolStart(func(runCtx *agent.Context, toolName string) { 365 + if sess == nil { 366 + return 367 + } 368 + writer := sess.currentWriter() 369 + msg := fmt.Sprintf("\x1b[90m used \x1b[36m%s\x1b[0m", toolName) 370 + _, _ = fmt.Fprintf(writer, "\r\033[K%s\n", msg) 371 + })) 372 + opts = append(opts, agent.WithPlanStepUpdate(func(runCtx *agent.Context, update agent.PlanStepUpdate) { 373 + if sess == nil { 374 + return 375 + } 376 + logger.Debug("plan_step_update_callback", "completedIndex", update.CompletedIndex, "startedIndex", update.StartedIndex, "startedStep", update.StartedStep, "reason", update.Reason) 377 + payload := formatPlanProgressUpdate(runCtx, update) 378 + if payload != "" { 379 + sess.setThinkingMessage(payload) 380 + } else if update.CompletedIndex >= 0 && update.CompletedStep != "" { 381 + sess.stopThinkingAnimation() 382 + writer := sess.currentWriter() 383 + total := 0 384 + if runCtx != nil && runCtx.Plan != nil { 385 + total = len(runCtx.Plan.Steps) 386 + } 387 + _, _ = fmt.Fprintf(writer, "\033[90mplan: ✓ %s", update.CompletedStep) 388 + if total > 0 { 389 + _, _ = fmt.Fprintf(writer, " [%d/%d]", update.CompletedIndex+1, total) 390 + } 391 + _, _ = fmt.Fprint(writer, "\033[0m\n") 392 + sess.startThinkingAnimation() 393 + } else { 394 + sess.setThinkingMessage("assistant is thinking...") 395 + } 396 + })) 397 + 398 + makeEngine := func(engReg *tools.Registry, engClient llm.Client, defaultModel string) *agent.Engine { 399 + return agent.New( 400 + engClient, 401 + engReg, 402 + agent.Config{ 403 + MaxSteps: configutil.FlagOrViperInt(cmd, "max-steps", "max_steps"), 404 + ParseRetries: configutil.FlagOrViperInt(cmd, "parse-retries", "parse_retries"), 405 + MaxTokenBudget: configutil.FlagOrViperInt(cmd, "max-token-budget", "max_token_budget"), 406 + ToolRepeatLimit: configutil.FlagOrViperInt(cmd, "tool-repeat-limit", "tool_repeat_limit"), 407 + DefaultModel: strings.TrimSpace(defaultModel), 408 + }, 409 + promptSpec, 410 + append(opts, 411 + agent.WithEngineToolsConfig(agent.EngineToolsConfig{ 412 + SpawnEnabled: viper.GetBool("tools.spawn.enabled"), 413 + ACPSpawnEnabled: viper.GetBool("tools.acp_spawn.enabled"), 414 + }), 415 + agent.WithACPAgents(acpclient.AgentsFromViper()), 416 + )..., 417 + ) 418 + } 419 + engine := makeEngine(reg, client, mainCfg.Model) 420 + 421 + sess = &chatSession{ 422 + cmd: cmd, 423 + deps: deps, 424 + logger: logger, 425 + logOpts: logOpts, 426 + client: client, 427 + mainCfg: mainCfg, 428 + engine: engine, 429 + toolRegistry: reg, 430 + runtimeToolsCfg: runtimeToolsCfg, 431 + memManager: memManager, 432 + memOrchestrator: memOrchestrator, 433 + memWorker: memWorker, 434 + memCleanup: func() { 435 + workerCancel() 436 + if memCleanup != nil { 437 + memCleanup() 438 + } 439 + }, 440 + subjectID: subjectID, 441 + compactMode: compactMode, 442 + userName: userName, 443 + agentName: agentName, 444 + chatFileCacheDir: chatFileCacheDir, 445 + sessionStore: sessionStore, 446 + llmValues: llmValues, 447 + buildClient: buildClient, 448 + makeEngine: makeEngine, 449 + promptSpec: promptSpec, 450 + timeout: timeout, 451 + } 452 + 453 + return sess, nil 454 + } 455 + 456 + func (s *chatSession) cleanup() { 457 + if s.memCleanup != nil { 458 + s.memCleanup() 459 + } 460 + }
+145
cmd/mistermorph/chatcmd/ui.go
··· 1 + package chatcmd 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "os" 7 + "os/user" 8 + "strings" 9 + "sync" 10 + "time" 11 + 12 + "golang.org/x/term" 13 + ) 14 + 15 + const chatBanner = ` 16 + 17 + ▄▄ ▄▄ ▄▄▄ ▄▄▄▄ ▄▄▄▄ ▄▄ ▄▄ 18 + ██▀▄▀██ ██▀██ ██▄█▄ ██▄█▀ ██▄██ 19 + ██ ██ ▀███▀ ██ ██ ██ ██ ██ 20 + ` 21 + 22 + func buildUserName() string { 23 + userName := "" 24 + if u, err := user.Current(); err == nil && u != nil { 25 + userName = strings.TrimSpace(u.Username) 26 + } 27 + if userName == "" { 28 + userName = strings.TrimSpace(os.Getenv("USER")) 29 + } 30 + if userName == "" { 31 + userName = "you" 32 + } 33 + return userName 34 + } 35 + 36 + func buildUserPrompt(compactMode bool, userName string) string { 37 + if compactMode { 38 + return "\033[32m• \033[0m" 39 + } 40 + return fmt.Sprintf("\033[42m\033[30m %s> \033[0m ", userName) 41 + } 42 + 43 + func thinkingAnimation(writer io.Writer) (stop func(), setMessage func(msg string)) { 44 + spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} 45 + ticker := time.NewTicker(80 * time.Millisecond) 46 + done := make(chan struct{}) 47 + msgMu := sync.RWMutex{} 48 + msg := "assistant is thinking..." 49 + var wg sync.WaitGroup 50 + 51 + var lastLinesMu sync.Mutex 52 + lastLines := 1 53 + 54 + calcLines := func(text string) int { 55 + width, _, _ := term.GetSize(int(os.Stdout.Fd())) 56 + if width <= 0 { 57 + width = 80 58 + } 59 + prefixWidth := 2 // spinner icon (1) + space (1) 60 + totalWidth := prefixWidth + stringDisplayWidth(text) 61 + lines := totalWidth / width 62 + if totalWidth%width != 0 { 63 + lines++ 64 + } 65 + if lines < 1 { 66 + lines = 1 67 + } 68 + return lines 69 + } 70 + 71 + buildClearSeq := func(n int) string { 72 + if n <= 1 { 73 + return "\r\033[K" 74 + } 75 + var b strings.Builder 76 + for i := 1; i < n; i++ { 77 + b.WriteString("\033[A") 78 + } 79 + b.WriteString("\r") 80 + for i := 0; i < n; i++ { 81 + b.WriteString("\033[2K") 82 + if i < n-1 { 83 + b.WriteString("\033[B") 84 + } 85 + } 86 + for i := 1; i < n; i++ { 87 + b.WriteString("\033[A") 88 + } 89 + b.WriteString("\r") 90 + return b.String() 91 + } 92 + 93 + wg.Add(1) 94 + go func() { 95 + defer wg.Done() 96 + i := 0 97 + for { 98 + select { 99 + case <-ticker.C: 100 + msgMu.RLock() 101 + currentMsg := msg 102 + msgMu.RUnlock() 103 + 104 + lastLinesMu.Lock() 105 + prevLines := lastLines 106 + lastLines = calcLines(currentMsg) 107 + lastLinesMu.Unlock() 108 + 109 + clearSeq := buildClearSeq(prevLines) 110 + _, _ = fmt.Fprintf(writer, "%s\033[36m%s\033[0m \033[90m%s\033[0m", clearSeq, spinner[i%len(spinner)], currentMsg) 111 + i++ 112 + case <-done: 113 + return 114 + } 115 + } 116 + }() 117 + stop = func() { 118 + close(done) 119 + ticker.Stop() 120 + wg.Wait() 121 + 122 + lastLinesMu.Lock() 123 + prevLines := lastLines 124 + lastLinesMu.Unlock() 125 + 126 + _, _ = fmt.Fprint(writer, buildClearSeq(prevLines)) 127 + } 128 + setMessage = func(newMsg string) { 129 + msgMu.Lock() 130 + msg = truncateString(newMsg, 80) 131 + msgMu.Unlock() 132 + } 133 + return stop, setMessage 134 + } 135 + 136 + func printChatSessionHeader(writer io.Writer, model string, fileCacheDir string) { 137 + _, _ = fmt.Fprint(writer, chatBanner) 138 + if model != "" { 139 + _, _ = fmt.Fprintf(writer, "model=%s\n", model) 140 + } 141 + if fileCacheDir != "" { 142 + _, _ = fmt.Fprintf(writer, "file_cache_dir=%s\n", fileCacheDir) 143 + } 144 + _, _ = fmt.Fprintln(writer, "\033[90mInteractive chat started. Press Ctrl+C or type /exit to quit.\033[0m") 145 + }
+5
cmd/mistermorph/root.go
··· 10 10 "time" 11 11 12 12 "github.com/quailyquaily/mistermorph/agent" 13 + "github.com/quailyquaily/mistermorph/cmd/mistermorph/chatcmd" 13 14 "github.com/quailyquaily/mistermorph/cmd/mistermorph/consolecmd" 14 15 "github.com/quailyquaily/mistermorph/cmd/mistermorph/larkcmd" 15 16 "github.com/quailyquaily/mistermorph/cmd/mistermorph/linecmd" ··· 89 90 telegramSkills := newSkillsRuntimeResolver() 90 91 91 92 cmd.AddCommand(runcmd.New(runcmd.Dependencies{ 93 + RegistryFromViper: registryResolver.Registry, 94 + GuardFromViper: guardResolver.Guard, 95 + })) 96 + cmd.AddCommand(chatcmd.New(chatcmd.Dependencies{ 92 97 RegistryFromViper: registryResolver.Registry, 93 98 GuardFromViper: guardResolver.Guard, 94 99 }))
+24
docs/configuration.md
··· 229 229 - `install [dir]` 230 230 - `--yes` 231 231 232 + `chat`: 233 + 234 + - `--profile` 235 + - `--provider` 236 + - `--endpoint` 237 + - `--model` 238 + - `--api-key` 239 + - `--llm-request-timeout` 240 + - `--compact-mode` 241 + - `--verbose` 242 + - `--skills-dir` (repeatable) 243 + - `--skill` (repeatable) 244 + - `--skills-enabled` 245 + - `--max-steps` 246 + - `--parse-retries` 247 + - `--max-token-budget` 248 + - `--tool-repeat-limit` 249 + - `--timeout` 250 + 232 251 ## Common Environment Variables 233 252 234 253 - `MISTER_MORPH_CONFIG` ··· 246 265 - `MISTER_MORPH_SLACK_BOT_TOKEN` 247 266 - `MISTER_MORPH_SLACK_APP_TOKEN` 248 267 - `MISTER_MORPH_FILE_CACHE_DIR` 268 + - `MISTER_MORPH_CHAT_COMPACT_MODE` 249 269 250 270 Provider-specific values use the same mapping. Examples: 251 271 ··· 297 317 - `console.base_path` 298 318 - `console.static_dir` 299 319 - `console.session_ttl` 320 + 321 + Chat: 322 + 323 + - `chat.compact_mode` — compact display mode: omit user/assistant name prefixes in prompts and output. 300 324 301 325 Auth profiles and secrets: 302 326
+1
go.mod
··· 31 31 github.com/adrg/xdg v0.5.3 // indirect 32 32 github.com/aws/aws-sdk-go v1.55.8 // indirect 33 33 github.com/bep/debounce v1.2.1 // indirect 34 + github.com/chzyer/readline v1.5.1 // indirect 34 35 github.com/cloudflare/circl v1.6.3 // indirect 35 36 github.com/coder/websocket v1.8.14 // indirect 36 37 github.com/cyphar/filepath-securejoin v0.6.1 // indirect
+5
go.sum
··· 23 23 github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= 24 24 github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= 25 25 github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 26 + github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= 27 + github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= 28 + github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= 29 + github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= 26 30 github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= 27 31 github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 28 32 github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= ··· 255 259 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 256 260 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 257 261 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 262 + golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 258 263 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 259 264 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 260 265 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+121
internal/chatcommands/dispatcher.go
··· 1 + // Package chatcommands provides a unified slash-command dispatcher that can be 2 + // reused by the TUI chat, Telegram, and Slack runtimes. 3 + package chatcommands 4 + 5 + import ( 6 + "context" 7 + "strings" 8 + "sync" 9 + ) 10 + 11 + // Result is the return value of a command handler. 12 + type Result struct { 13 + Reply string 14 + Quit bool 15 + } 16 + 17 + // Handler is the signature for a command handler. 18 + // The args string contains everything after the command word (already trimmed). 19 + // The returned *Result carries reply text and optional quit flag; an error signals a handler failure. 20 + type Handler func(ctx context.Context, args string) (*Result, error) 21 + 22 + // Registry maps command names (e.g. "/help") to their handlers. 23 + type Registry struct { 24 + mu sync.RWMutex 25 + handlers map[string]Handler 26 + } 27 + 28 + // NewRegistry creates an empty Registry. 29 + func NewRegistry() *Registry { 30 + return &Registry{ 31 + handlers: make(map[string]Handler), 32 + } 33 + } 34 + 35 + // Register binds a command name to a handler. The name is normalised with 36 + // NormalizeCommand before storage, so callers may pass "/help" or "/help@Bot". 37 + // Registering the same name twice overwrites the previous handler. 38 + func (r *Registry) Register(name string, h Handler) { 39 + name = NormalizeCommand(name) 40 + if name == "" { 41 + return 42 + } 43 + r.mu.Lock() 44 + defer r.mu.Unlock() 45 + r.handlers[name] = h 46 + } 47 + 48 + // Lookup returns the handler for a normalised command name, or nil. 49 + func (r *Registry) Lookup(name string) Handler { 50 + name = NormalizeCommand(name) 51 + if name == "" { 52 + return nil 53 + } 54 + r.mu.RLock() 55 + defer r.mu.RUnlock() 56 + return r.handlers[name] 57 + } 58 + 59 + // Dispatch parses text into a command word and arguments, looks up the 60 + // registered handler, and invokes it. If the text does not start with a 61 + // recognised command, result == nil, handled == false and err == nil. 62 + func (r *Registry) Dispatch(ctx context.Context, text string) (result *Result, handled bool, err error) { 63 + cmd, args := ParseCommand(text) 64 + if cmd == "" { 65 + return nil, false, nil 66 + } 67 + h := r.Lookup(cmd) 68 + if h == nil { 69 + return nil, false, nil 70 + } 71 + result, err = h(ctx, args) 72 + return result, true, err 73 + } 74 + 75 + // Names returns a sorted snapshot of all registered command names. 76 + func (r *Registry) Names() []string { 77 + r.mu.RLock() 78 + defer r.mu.RUnlock() 79 + out := make([]string, 0, len(r.handlers)) 80 + for name := range r.handlers { 81 + out = append(out, name) 82 + } 83 + // Simple bubble sort for deterministic output without importing sort. 84 + for i := 0; i < len(out); i++ { 85 + for j := i + 1; j < len(out); j++ { 86 + if out[i] > out[j] { 87 + out[i], out[j] = out[j], out[i] 88 + } 89 + } 90 + } 91 + return out 92 + } 93 + 94 + // ParseCommand splits a raw message into (cmd, args). If text does not start 95 + // with a "/" command, cmd is empty. The command word is NOT normalised here so 96 + // callers can choose whether to apply bot-mention stripping separately. 97 + func ParseCommand(text string) (cmd string, args string) { 98 + text = strings.TrimSpace(text) 99 + if text == "" { 100 + return "", "" 101 + } 102 + i := strings.IndexAny(text, " \n\t") 103 + if i == -1 { 104 + return text, "" 105 + } 106 + return text[:i], strings.TrimSpace(text[i:]) 107 + } 108 + 109 + // NormalizeCommand strips a trailing "@bot" suffix (used by Telegram and 110 + // sometimes Slack) and lower-cases the word. It returns "" for non-command 111 + // input (i.e. strings that do not start with "/"). 112 + func NormalizeCommand(word string) string { 113 + word = strings.TrimSpace(word) 114 + if word == "" || !strings.HasPrefix(word, "/") { 115 + return "" 116 + } 117 + if at := strings.IndexByte(word, '@'); at >= 0 { 118 + word = word[:at] 119 + } 120 + return strings.ToLower(word) 121 + }
+164
internal/chatcommands/dispatcher_test.go
··· 1 + package chatcommands 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + func TestParseCommand(t *testing.T) { 11 + cases := []struct { 12 + input string 13 + wantCmd string 14 + wantArg string 15 + }{ 16 + {"/help", "/help", ""}, 17 + {"/echo hello world", "/echo", "hello world"}, 18 + {" /model set foo ", "/model", "set foo"}, 19 + {"plain text", "plain", "text"}, 20 + {"", "", ""}, 21 + {"/quit", "/quit", ""}, 22 + {"/cmd\nwith newline", "/cmd", "with newline"}, 23 + } 24 + 25 + for _, c := range cases { 26 + cmd, args := ParseCommand(c.input) 27 + if cmd != c.wantCmd || args != c.wantArg { 28 + t.Errorf("ParseCommand(%q) = (%q, %q), want (%q, %q)", c.input, cmd, args, c.wantCmd, c.wantArg) 29 + } 30 + } 31 + } 32 + 33 + func TestNormalizeCommand(t *testing.T) { 34 + cases := []struct { 35 + input string 36 + want string 37 + }{ 38 + {"/help", "/help"}, 39 + {"/Help", "/help"}, 40 + {"/help@MyBot", "/help"}, 41 + {"/model@bot123", "/model"}, 42 + {"plain", ""}, 43 + {"", ""}, 44 + {" /start ", "/start"}, 45 + } 46 + 47 + for _, c := range cases { 48 + got := NormalizeCommand(c.input) 49 + if got != c.want { 50 + t.Errorf("NormalizeCommand(%q) = %q, want %q", c.input, got, c.want) 51 + } 52 + } 53 + } 54 + 55 + func TestRegistryRegisterAndDispatch(t *testing.T) { 56 + r := NewRegistry() 57 + 58 + called := false 59 + r.Register("/ping", func(ctx context.Context, args string) (*Result, error) { 60 + called = true 61 + return &Result{Reply: "pong: " + args}, nil 62 + }) 63 + 64 + res, handled, err := r.Dispatch(context.Background(), "/ping hello") 65 + if !handled { 66 + t.Fatal("expected handled") 67 + } 68 + if err != nil { 69 + t.Fatalf("unexpected error: %v", err) 70 + } 71 + if res == nil || res.Reply != "pong: hello" { 72 + t.Fatalf("unexpected reply: %v", res) 73 + } 74 + if !called { 75 + t.Fatal("handler not called") 76 + } 77 + 78 + _, handled, _ = r.Dispatch(context.Background(), "/unknown") 79 + if handled { 80 + t.Fatal("expected not handled for unknown command") 81 + } 82 + } 83 + 84 + func TestRegistryDispatchWithBotSuffix(t *testing.T) { 85 + r := NewRegistry() 86 + r.Register("/start", func(ctx context.Context, args string) (*Result, error) { 87 + return &Result{Reply: "started"}, nil 88 + }) 89 + 90 + res, handled, err := r.Dispatch(context.Background(), "/start@MyBot") 91 + if !handled || err != nil || res == nil || res.Reply != "started" { 92 + t.Fatalf("unexpected result: %v, %v, %v", res, handled, err) 93 + } 94 + } 95 + 96 + func TestRegistryHandlerError(t *testing.T) { 97 + r := NewRegistry() 98 + r.Register("/fail", func(ctx context.Context, args string) (*Result, error) { 99 + return nil, errors.New("boom") 100 + }) 101 + 102 + _, handled, err := r.Dispatch(context.Background(), "/fail") 103 + if !handled { 104 + t.Fatal("expected handled") 105 + } 106 + if err == nil || !strings.Contains(err.Error(), "boom") { 107 + t.Fatalf("expected boom error, got: %v", err) 108 + } 109 + } 110 + 111 + func TestRegistryNames(t *testing.T) { 112 + r := NewRegistry() 113 + r.Register("/zebra", nil) 114 + r.Register("/apple", nil) 115 + r.Register("/mango", nil) 116 + 117 + names := r.Names() 118 + want := "/apple,/mango,/zebra" 119 + got := strings.Join(names, ",") 120 + if got != want { 121 + t.Fatalf("Names() = %q, want %q", got, want) 122 + } 123 + } 124 + 125 + func TestHelpHandler(t *testing.T) { 126 + r := NewRegistry() 127 + r.Register("/help", nil) 128 + r.Register("/echo", nil) 129 + 130 + h := HelpHandler(r, "Commands:") 131 + res, err := h(context.Background(), "") 132 + if err != nil { 133 + t.Fatalf("unexpected error: %v", err) 134 + } 135 + if res == nil { 136 + t.Fatal("expected non-nil result") 137 + } 138 + reply := res.Reply 139 + if !strings.Contains(reply, "Commands:") { 140 + t.Fatalf("expected header in reply: %q", reply) 141 + } 142 + if !strings.Contains(reply, "/echo") || !strings.Contains(reply, "/help") { 143 + t.Fatalf("expected command list in reply: %q", reply) 144 + } 145 + } 146 + 147 + func TestEchoHandler(t *testing.T) { 148 + h := EchoHandler() 149 + res, err := h(context.Background(), "hello world") 150 + if err != nil { 151 + t.Fatalf("unexpected error: %v", err) 152 + } 153 + if res == nil || res.Reply != "hello world" { 154 + t.Fatalf("unexpected reply: %v", res) 155 + } 156 + 157 + res, err = h(context.Background(), "") 158 + if err != nil { 159 + t.Fatalf("unexpected error: %v", err) 160 + } 161 + if res == nil || !strings.Contains(res.Reply, "usage") { 162 + t.Fatalf("expected usage hint for empty args, got: %v", res) 163 + } 164 + }
+81
internal/chatcommands/handlers.go
··· 1 + package chatcommands 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + 7 + "github.com/quailyquaily/mistermorph/internal/llmselect" 8 + "github.com/quailyquaily/mistermorph/internal/llmutil" 9 + ) 10 + 11 + // HelpHandler returns a Handler that replies with a list of registered commands. 12 + // The optional header is printed before the command list. 13 + func HelpHandler(r *Registry, header string) Handler { 14 + return func(ctx context.Context, args string) (*Result, error) { 15 + names := r.Names() 16 + var b strings.Builder 17 + if header != "" { 18 + b.WriteString(header) 19 + b.WriteString("\n") 20 + } 21 + if len(names) == 0 { 22 + b.WriteString("No commands available.") 23 + return &Result{Reply: b.String()}, nil 24 + } 25 + for _, name := range names { 26 + if b.Len() > 0 && b.String()[b.Len()-1] != '\n' { 27 + b.WriteString("\n") 28 + } 29 + b.WriteString(" ") 30 + b.WriteString(name) 31 + } 32 + return &Result{Reply: b.String()}, nil 33 + } 34 + } 35 + 36 + // EchoHandler returns a Handler that echoes back its arguments. 37 + func EchoHandler() Handler { 38 + return func(ctx context.Context, args string) (*Result, error) { 39 + if args == "" { 40 + return &Result{Reply: "usage: /echo <msg>"}, nil 41 + } 42 + return &Result{Reply: args}, nil 43 + } 44 + } 45 + 46 + // ModelHandler wraps the llmselect package so that /model commands can be 47 + // handled uniformly across chat front-ends. 48 + // 49 + // The raw text passed to the handler should be the full user message (e.g. 50 + // "/model set foo" or "/model@Bot set foo"). The handler normalises the 51 + // command word internally. 52 + type ModelHandler struct { 53 + Values llmutil.RuntimeValues 54 + Store *llmselect.Store 55 + } 56 + 57 + // NewModelHandler creates a ModelHandler backed by the given runtime values and 58 + // selection store. If store is nil a fresh one is allocated. 59 + func NewModelHandler(values llmutil.RuntimeValues, store *llmselect.Store) *ModelHandler { 60 + if store == nil { 61 + store = llmselect.NewStore() 62 + } 63 + return &ModelHandler{Values: values, Store: store} 64 + } 65 + 66 + // Handle implements the Handler signature for /model commands. 67 + func (m *ModelHandler) Handle(ctx context.Context, text string) (*Result, error) { 68 + output, handled, err := llmselect.ExecuteCommandText(m.Values, m.Store, text) 69 + if !handled { 70 + return nil, nil 71 + } 72 + return &Result{Reply: output}, err 73 + } 74 + 75 + // AsHandler returns the model handler as a standard Handler closure so it can 76 + // be registered in a Registry. 77 + func (m *ModelHandler) AsHandler() Handler { 78 + return func(ctx context.Context, text string) (*Result, error) { 79 + return m.Handle(ctx, text) 80 + } 81 + }
+1
internal/configdefaults/defaults.go
··· 28 28 v.SetDefault("max_token_budget", 0) 29 29 v.SetDefault("tool_repeat_limit", 3) 30 30 v.SetDefault("timeout", 10*time.Minute) 31 + v.SetDefault("chat.compact_mode", false) 31 32 v.SetDefault("tools.plan_create.enabled", true) 32 33 v.SetDefault("tools.plan_create.max_steps", 6) 33 34
+9 -1
internal/llmutil/llmutil.go
··· 153 153 if provider == "openai_resp" && reasoningBudget != nil { 154 154 slog.Warn("llm_reasoning_budget_ignored", "provider", provider, "field", "llm.reasoning_budget_tokens") 155 155 } 156 + // uniaiapi doesn't recognize "openai_custom" as a provider name; 157 + // it's just an OpenAI-compatible endpoint with a custom base URL. 158 + // Map it to "openai" for the uniai provider while preserving the 159 + // original name for any provider-specific logic elsewhere. 160 + uniaiProviderName := provider 161 + if provider == "openai_custom" { 162 + uniaiProviderName = "openai" 163 + } 156 164 switch provider { 157 165 case "openai", "openai_resp", "openai_custom", "deepseek", "xai", "gemini", "azure", "anthropic", "bedrock", "susanoo", "cloudflare": 158 166 c := uniaiProvider.New(uniaiProvider.Config{ 159 - Provider: provider, 167 + Provider: uniaiProviderName, 160 168 Endpoint: strings.TrimSpace(cfg.Endpoint), 161 169 APIKey: strings.TrimSpace(cfg.APIKey), 162 170 Model: strings.TrimSpace(cfg.Model),