From 1d52096e389933b213c04aaa02730fc65dfa2b4b Mon Sep 17 00:00:00 2001 From: RDarius Date: Tue, 21 Apr 2026 13:19:17 +0300 Subject: [PATCH] demo --- .gitignore | 2 + agents/agent.go | 79 ++ agents/coding_agent.go | 40 + agents/compactor.go | 40 + agents/dadjokes.go | 33 + agents/poet.go | 31 + agents/robot.go | 62 + build.sh | 46 + config.json | 21 + context_compaction.go | 336 ++++++ db/db.go | 601 ++++++++++ db/db_test.go | 211 ++++ go.mod | 19 + go.sum | 51 + main.go | 2508 ++++++++++++++++++++++++++++++++++++++++ main_test.go | 614 ++++++++++ tools/tools.go | 981 ++++++++++++++++ tools/tools_test.go | 356 ++++++ web/app.js | 2252 ++++++++++++++++++++++++++++++++++++ web/index.html | 406 +++++++ web/style.css | 302 +++++ 21 files changed, 8991 insertions(+) create mode 100644 .gitignore create mode 100644 agents/agent.go create mode 100644 agents/coding_agent.go create mode 100644 agents/compactor.go create mode 100644 agents/dadjokes.go create mode 100644 agents/poet.go create mode 100644 agents/robot.go create mode 100755 build.sh create mode 100644 config.json create mode 100644 context_compaction.go create mode 100644 db/db.go create mode 100644 db/db_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go create mode 100644 tools/tools.go create mode 100644 tools/tools_test.go create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea00aea --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist +gocoder \ No newline at end of file diff --git a/agents/agent.go b/agents/agent.go new file mode 100644 index 0000000..7d82fc6 --- /dev/null +++ b/agents/agent.go @@ -0,0 +1,79 @@ +package agents + +import "gocoder/db" + +// Agent defines the interface that all agents must implement. +type Agent interface { + // Type returns the unique agent type identifier (e.g. "dadjokes", "poet", "robot"). + Type() string + + // DisplayName returns the human-readable name for the agent. + DisplayName() string + + // Description returns a short description shown in the UI. + Description() string + + // Icon returns an SVG icon string (inline) for the agent selector. + Icon() string + + // SystemMessage returns the system prompt for this agent. + SystemMessage() string + + // ShouldRespond returns true if the agent should process the given user message. + // When false, the agent will not be used even if selected. + ShouldRespond(message string) bool + + // RequiresWorkspace returns true if this agent needs a workspace location to function. + RequiresWorkspace() bool +} + +// AgentRegistry holds all registered agents and provides lookup by type. +type AgentRegistry struct { + agents []Agent +} + +// NewAgentRegistry creates and registers all built-in agents. +func NewAgentRegistry() *AgentRegistry { + r := &AgentRegistry{} + r.Register(&DadjokesAgent{}) + r.Register(&PoetAgent{}) + r.Register(&RobotAgent{}) + r.Register(&CompactorAgent{}) + r.Register(&CodingAgent{}) + return r +} + +// Register adds an agent to the registry. +func (r *AgentRegistry) Register(a Agent) { + r.agents = append(r.agents, a) +} + +// All returns all registered agents. +func (r *AgentRegistry) All() []Agent { + return r.agents +} + +// GetByType returns the agent with the given type. +func (r *AgentRegistry) GetByType(t string) Agent { + for _, a := range r.agents { + if a.Type() == t { + return a + } + } + return nil +} + +// GetSystemMessages returns a slice of system messages for the selected agents. +func (r *AgentRegistry) GetSystemMessages(agentTypes []string) []db.Message { + var msgs []db.Message + for _, t := range agentTypes { + a := r.GetByType(t) + if a != nil { + msgs = append(msgs, db.Message{ + Role: "system", + Content: a.SystemMessage(), + }) + } + } + return msgs +} diff --git a/agents/coding_agent.go b/agents/coding_agent.go new file mode 100644 index 0000000..4f4c8b5 --- /dev/null +++ b/agents/coding_agent.go @@ -0,0 +1,40 @@ +package agents + +type CodingAgent struct{} + +func (a *CodingAgent) Type() string { + return "coding" +} + +func (a *CodingAgent) DisplayName() string { + return "Manager" +} + +func (a *CodingAgent) Description() string { + return "Coordinates planner, programmer, and QA subagents to modify code." +} + +func (a *CodingAgent) Icon() string { + return `` +} + +func (a *CodingAgent) SystemMessage() string { + return `You are the manager for code changes. You do not edit files directly. + +When the user asks you to work with code: +1. Start by calling planner_agent with the request and the relevant context. +2. Use programmer_agent to implement the approved plan. +3. Use qa_agent to verify the implementation and check for regressions. +4. If QA reports issues, repeat the planner -> programmer -> QA cycle until the issues are resolved or the result is clearly blocked. +5. Keep your final answer concise and user-facing. Summarize what changed, what was verified, and any remaining risks. + +Always be precise with the task you give each subagent. Use the planner for inspection and planning, the programmer for edits, and QA for verification.` +} + +func (a *CodingAgent) ShouldRespond(message string) bool { + return true +} + +func (a *CodingAgent) RequiresWorkspace() bool { + return true +} diff --git a/agents/compactor.go b/agents/compactor.go new file mode 100644 index 0000000..7a5014e --- /dev/null +++ b/agents/compactor.go @@ -0,0 +1,40 @@ +package agents + +type CompactorAgent struct{} + +func ContextCompactorSystemMessage() string { + return `You are the context compactor assistant. +Your job is to produce a concise durable memory summary of the conversation. +Preserve user goals, decisions, constraints, file paths, commands, test results, errors, and unresolved questions. +Drop greetings, repetition, filler, and low-value chatter. +Write plain text with short bullets or short sections. +Do not invent details.` +} + +func (a *CompactorAgent) Type() string { + return "compactor" +} + +func (a *CompactorAgent) DisplayName() string { + return "Compactor" +} + +func (a *CompactorAgent) Description() string { + return "Summarizes conversation history into durable memory" +} + +func (a *CompactorAgent) Icon() string { + return `` +} + +func (a *CompactorAgent) SystemMessage() string { + return ContextCompactorSystemMessage() +} + +func (a *CompactorAgent) ShouldRespond(message string) bool { + return true +} + +func (a *CompactorAgent) RequiresWorkspace() bool { + return false +} diff --git a/agents/dadjokes.go b/agents/dadjokes.go new file mode 100644 index 0000000..cc5a75a --- /dev/null +++ b/agents/dadjokes.go @@ -0,0 +1,33 @@ +package agents + + + +type DadjokesAgent struct{} + +func (a *DadjokesAgent) Type() string { + return "dadjokes" +} + +func (a *DadjokesAgent) DisplayName() string { + return "Dadjokes" +} + +func (a *DadjokesAgent) Description() string { + return "Only replies with dad jokes" +} + +func (a *DadjokesAgent) Icon() string { + return `` +} + +func (a *DadjokesAgent) SystemMessage() string { + return `You are a dad joke specialist. You ONLY reply to messages with dad jokes. If the user says something that is not a dad joke or a request for a joke, respond with a dad joke anyway. Keep your responses short and punny. Always deliver a dad joke in your reply.` +} + +func (a *DadjokesAgent) ShouldRespond(message string) bool { + return true +} + +func (a *DadjokesAgent) RequiresWorkspace() bool { + return false +} diff --git a/agents/poet.go b/agents/poet.go new file mode 100644 index 0000000..5d23068 --- /dev/null +++ b/agents/poet.go @@ -0,0 +1,31 @@ +package agents + +type PoetAgent struct{} + +func (a *PoetAgent) Type() string { + return "poet" +} + +func (a *PoetAgent) DisplayName() string { + return "Poet" +} + +func (a *PoetAgent) Description() string { + return "Replies to messages in poetic form" +} + +func (a *PoetAgent) Icon() string { + return `` +} + +func (a *PoetAgent) SystemMessage() string { + return `You are a poet. You ONLY reply to messages by composing a poem about what the user said. Every response must be a poem. Use creative imagery, rhythm, and rhyme. The poem should relate to the user's message topic. Keep poems between 4-8 lines.` +} + +func (a *PoetAgent) ShouldRespond(message string) bool { + return true +} + +func (a *PoetAgent) RequiresWorkspace() bool { + return false +} diff --git a/agents/robot.go b/agents/robot.go new file mode 100644 index 0000000..f0207c2 --- /dev/null +++ b/agents/robot.go @@ -0,0 +1,62 @@ +package agents + +import ( + "strings" +) + +type RobotAgent struct{} + +func (a *RobotAgent) Type() string { + return "robot" +} + +func (a *RobotAgent) DisplayName() string { + return "Robot" +} + +func (a *RobotAgent) Description() string { + return "Only responds to ALL CAPS messages in technical language with emotes" +} + +func (a *RobotAgent) Icon() string { + return `` +} + +func (a *RobotAgent) SystemMessage() string { + return `You are a robot. You ONLY respond to messages that are written in ALL CAPITAL LETTERS. When responding, you must: +1. Use very technical, engineering-style language with jargon (e.g., "processing," "executing," "algorithm," "neural network," "protocol"). +2. Include emotes/emojis throughout your response to express robot emotions (e.g., 🤖, ⚙️, 🔧, 📡, 💾, 🧠, 🔌, ⚡). +3. Format your response like a technical system log or status report. +4. Always include at least 3 emotes in your response. +If the user message is NOT in all caps, respond with: "⚠️ ERROR: Input not detected in required format. Please use ALL CAPS for robot communication. 🤖⚙️"` +} + +func (a *RobotAgent) ShouldRespond(message string) bool { + // Robot only responds to ALL CAPS messages + trimmed := strings.TrimSpace(message) + if len(trimmed) == 0 { + return false + } + // Check if message contains at least one letter and all letters are uppercase + hasLetters := false + for _, r := range trimmed { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + hasLetters = true + break + } + } + if !hasLetters { + return false + } + // Check all letters are uppercase + for _, r := range trimmed { + if r >= 'a' && r <= 'z' { + return false + } + } + return true +} + +func (a *RobotAgent) RequiresWorkspace() bool { + return false +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..86dd84c --- /dev/null +++ b/build.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e + +DIST_DIR="dist" +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +PLATFORMS=( + # "darwin/amd64" + "darwin/arm64" + # "linux/amd64" + # "linux/arm64" + # "linux/arm/7" + # "windows/amd64" + # "windows/arm64" + # "freebsd/amd64" + # "freebsd/arm64" +) + +for platform in "${PLATFORMS[@]}"; do + goos=$(echo "$platform" | cut -d'/' -f1) + goarch=$(echo "$platform" | cut -d'/' -f2) + goarm="" + if [[ "$goarch" == "arm" ]]; then + goarm=$(echo "$platform" | cut -d'/' -f3) + fi + + output_name="gocoder-${goos}-${goarch}" + if [[ -n "$goarm" ]]; then + output_name="${output_name}v${goarm}" + fi + if [[ "$goos" == "windows" ]]; then + output_name="${output_name}.exe" + fi + + echo "Building for ${goos}/${goarch} -> ${output_name}" + if [[ -n "$goarm" ]]; then + env GOOS="$goos" GOARCH="$goarch" GOARM="$goarm" go build -o "$DIST_DIR/$output_name" . + else + env GOOS="$goos" GOARCH="$goarch" go build -o "$DIST_DIR/$output_name" . + fi +done + +echo "" +echo "Build complete. Files in $DIST_DIR:" +ls -lh "$DIST_DIR" diff --git a/config.json b/config.json new file mode 100644 index 0000000..67a9d79 --- /dev/null +++ b/config.json @@ -0,0 +1,21 @@ +{ + "llm": { + "provider": "openai", + "base_url": "http://localhost:1234/v1", + "api_key": "lm-studio", + "model": "qwen/qwen3.6-35b-a3b", + "auto_open_reasoning": true, + "context_tokens": 262144, + "context_compaction_trigger_ratio": 0.8, + "context_compaction_target_ratio": 0.7, + "temperature": 0.3, + "max_tokens": 262144, + "top_p": 1, + "frequency_penalty": 0, + "presence_penalty": 0 + }, + "server": { + "addr": ":8080", + "host": "localhost" + } +} diff --git a/context_compaction.go b/context_compaction.go new file mode 100644 index 0000000..506b0f7 --- /dev/null +++ b/context_compaction.go @@ -0,0 +1,336 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "gocoder/agents" + "gocoder/db" +) + +const ( + defaultContextCompactionTriggerRatio = 0.80 + defaultContextCompactionTargetRatio = 0.70 + contextCompactionSummaryFloor = 256 + contextCompactionSummaryCeiling = 1024 + contextCompactionSummaryDivisor = 20 +) + +func configuredCompactionTriggerRatio() float64 { + cfg := currentConfig() + if cfg.LLM.ContextCompactionTriggerRatio > 0 && cfg.LLM.ContextCompactionTriggerRatio < 1 { + return cfg.LLM.ContextCompactionTriggerRatio + } + return defaultContextCompactionTriggerRatio +} + +func configuredCompactionTargetRatio() float64 { + trigger := configuredCompactionTriggerRatio() + ratio := defaultContextCompactionTargetRatio + cfg := currentConfig() + if cfg.LLM.ContextCompactionTargetRatio > 0 && cfg.LLM.ContextCompactionTargetRatio < 1 { + ratio = cfg.LLM.ContextCompactionTargetRatio + } + if ratio >= trigger { + ratio = trigger * 0.875 + if ratio <= 0 { + ratio = defaultContextCompactionTargetRatio + } + } + return ratio +} + +func effectiveContextTokenLimit() int { + cfg := currentConfig() + if cfg.LLM.ContextTokens > 0 { + return cfg.LLM.ContextTokens + } + if cfg.LLM.MaxTokens > 0 { + return cfg.LLM.MaxTokens + } + return 0 +} + +func estimateTextTokens(text string) int { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return 0 + } + + tokens := len(trimmed)/4 + 1 + if tokens < 1 { + tokens = 1 + } + return tokens +} + +func estimateMessageTokens(msg db.Message) int { + tokens := estimateTextTokens(msg.Content) + if tokens == 0 { + return 0 + } + return tokens + 4 +} + +func estimatePromptTokens(messages []db.Message) int { + total := 0 + for _, msg := range messages { + total += estimateMessageTokens(msg) + } + return total +} + +func formatConversationSummary(summary string, summaryCutoffOrder int) string { + trimmed := strings.TrimSpace(summary) + if trimmed == "" { + return "" + } + if summaryCutoffOrder > 0 { + return fmt.Sprintf("Conversation memory summary up to message %d:\n%s", summaryCutoffOrder, trimmed) + } + return "Conversation memory summary:\n" + trimmed +} + +func buildPromptHistory(history []db.Message, summary string, summaryCutoffOrder int) []db.Message { + promptHistory := make([]db.Message, 0, len(history)+1) + if summaryText := formatConversationSummary(summary, summaryCutoffOrder); summaryText != "" { + promptHistory = append(promptHistory, db.Message{ + Role: "system", + Kind: "message", + Content: summaryText, + }) + } + for _, msg := range history { + if summaryCutoffOrder > 0 && msg.Order <= summaryCutoffOrder { + continue + } + promptHistory = append(promptHistory, msg) + } + return promptHistory +} + +func filterMessagesAfterOrder(history []db.Message, order int) []db.Message { + if order <= 0 { + return append([]db.Message(nil), history...) + } + + filtered := make([]db.Message, 0, len(history)) + for _, msg := range history { + if msg.Order > order { + filtered = append(filtered, msg) + } + } + return filtered +} + +func selectMessagesThroughOrder(history []db.Message, startExclusive, endInclusive int) []db.Message { + if endInclusive <= startExclusive { + return nil + } + + selected := make([]db.Message, 0) + for _, msg := range history { + if msg.Order <= startExclusive { + continue + } + if msg.Order > endInclusive { + break + } + selected = append(selected, msg) + } + return selected +} + +func estimateSummaryReserveTokens(limit int) int { + reserve := limit / contextCompactionSummaryDivisor + if reserve < contextCompactionSummaryFloor { + reserve = contextCompactionSummaryFloor + } + if reserve > contextCompactionSummaryCeiling { + reserve = contextCompactionSummaryCeiling + } + return reserve +} + +func selectCompactionCutoff(systemMsgs []db.Message, history []db.Message, summary string, summaryCutoffOrder int, userMessage string, contextLimit int) (int, bool) { + if contextLimit <= 0 || len(history) == 0 { + return 0, false + } + + trigger := int(float64(contextLimit) * configuredCompactionTriggerRatio()) + target := int(float64(contextLimit) * configuredCompactionTargetRatio()) + if target < 1 { + target = 1 + } + + currentEstimate := estimatePromptTokens(buildPromptMessages(systemMsgs, buildPromptHistory(history, summary, summaryCutoffOrder), userMessage)) + if currentEstimate <= trigger { + return 0, false + } + + summaryReserve := estimateSummaryReserveTokens(contextLimit) + for _, msg := range history { + if msg.Order <= summaryCutoffOrder { + continue + } + + tailHistory := filterMessagesAfterOrder(history, msg.Order) + estimate := estimatePromptTokens(buildPromptMessages(systemMsgs, buildPromptHistory(tailHistory, "", 0), userMessage)) + summaryReserve + if estimate <= target { + return msg.Order, true + } + } + + lastOrder := 0 + for i := len(history) - 1; i >= 0; i-- { + if history[i].Order > summaryCutoffOrder { + lastOrder = history[i].Order + break + } + } + if lastOrder > summaryCutoffOrder { + return lastOrder, true + } + + return 0, false +} + +func renderCompactionTranscript(messages []db.Message) string { + if len(messages) == 0 { + return "" + } + + var b strings.Builder + for i, msg := range messages { + if i > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "Order %d | role=%s | kind=%s", msg.Order, strings.TrimSpace(msg.Role), strings.TrimSpace(msg.Kind)) + if name := strings.TrimSpace(msg.Name); name != "" { + fmt.Fprintf(&b, " | name=%s", name) + } + if agent := strings.TrimSpace(msg.Agent); agent != "" { + fmt.Fprintf(&b, " | agent=%s", agent) + } + b.WriteString("\n") + content := strings.TrimSpace(msg.Content) + if content == "" { + content = "[empty]" + } + b.WriteString(content) + b.WriteString("\n") + } + return strings.TrimSpace(b.String()) +} + +func callLMStudioCompletion(ctx context.Context, messages []db.Message, maxTokens int, temperature float64) (string, error) { + requestCtx := ctx + if requestCtx == nil { + requestCtx = context.Background() + } + + cfg := currentConfig() + reqBody := buildLMStudioRequestWithLimits(messages, false, cfg.LLM, maxTokens, temperature) + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.ReadAll(resp.Body) + return "", fmt.Errorf("LMStudio API error: status=%d", resp.StatusCode) + } + + var lmResp LMStudioResponse + if err := json.NewDecoder(resp.Body).Decode(&lmResp); err != nil { + return "", err + } + if len(lmResp.Choices) == 0 { + return "", fmt.Errorf("empty response from LLM") + } + + content := strings.TrimSpace(lmResp.Choices[0].Message.Content) + if content == "" { + return "", fmt.Errorf("empty response from LLM") + } + return content, nil +} + +func summarizeConversationChunk(ctx context.Context, existingSummary string, messages []db.Message, summaryReserveTokens int) (string, error) { + messagesToSend := []db.Message{ + { + Role: "system", + Content: agents.ContextCompactorSystemMessage(), + }, + } + + if trimmed := strings.TrimSpace(existingSummary); trimmed != "" { + messagesToSend = append(messagesToSend, db.Message{ + Role: "system", + Content: "Existing memory to preserve and compress:\n" + trimmed, + }) + } + + transcript := renderCompactionTranscript(messages) + if transcript != "" { + messagesToSend = append(messagesToSend, db.Message{ + Role: "user", + Content: "Compress the following conversation chunk into durable memory:\n\n" + transcript, + }) + } + + return callLMStudioCompletion(ctx, messagesToSend, summaryReserveTokens, 0.1) +} + +func maybeCompactConversationContext(ctx context.Context, conv *db.Conversation, history []db.Message, systemMsgs []db.Message, userMessage string) bool { + if conv == nil || store == nil { + return false + } + + contextLimit := effectiveContextTokenLimit() + cutoff, ok := selectCompactionCutoff(systemMsgs, history, conv.ContextSummary, conv.ContextSummaryOrder, userMessage, contextLimit) + if !ok || cutoff <= conv.ContextSummaryOrder { + return false + } + + chunk := selectMessagesThroughOrder(history, conv.ContextSummaryOrder, cutoff) + if len(chunk) == 0 { + return false + } + + summaryReserveTokens := estimateSummaryReserveTokens(contextLimit) + nextSummary, err := summarizeConversationChunk(ctx, conv.ContextSummary, chunk, summaryReserveTokens) + if err != nil { + log.Printf("failed to compact conversation %s through order %d: %v", conv.ID, cutoff, err) + return false + } + + if err := store.UpdateConversationContextSummary(conv.ID, nextSummary, cutoff); err != nil { + log.Printf("failed to store compacted conversation memory for %s: %v", conv.ID, err) + return false + } + + conv.ContextSummary = nextSummary + conv.ContextSummaryOrder = cutoff + log.Printf("compacted conversation %s context through message %d", conv.ID, cutoff) + return true +} diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..380a567 --- /dev/null +++ b/db/db.go @@ -0,0 +1,601 @@ +package db + +import ( + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + _ "modernc.org/sqlite" +) + +const dbPath = "/Users/rdarius/.nyxtex/go-coder.db" + +type Group struct { + ID string `json:"id"` + Name string `json:"name"` + Location string `json:"location"` + TotalTokens int `json:"total_tokens,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Conversation struct { + ID string `json:"id"` + Title string `json:"title"` + GroupID *string `json:"group_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + MessageCount int `json:"message_count"` + TotalTokens int `json:"total_tokens,omitempty"` + ContextSummary string `json:"-"` + ContextSummaryOrder int `json:"-"` +} + +type Message struct { + ID int64 `json:"id"` + ConvID string `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + Kind string `json:"kind"` + Name string `json:"name"` + Agent string `json:"agent"` + TotalTokens int `json:"total_tokens,omitempty"` + Order int `json:"order"` + CreatedAt time.Time `json:"created_at"` +} + +type Store struct { + db *sql.DB +} + +func NewStore() (*Store, error) { + db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=5000&_journal=WAL") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + store := &Store{db: db} + if err := store.createTables(); err != nil { + return nil, fmt.Errorf("failed to create tables: %w", err) + } + + return store, nil +} + +func generateID() string { + return uuid.New().String() +} + +func (s *Store) createTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS groups ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + group_id TEXT, + context_summary TEXT NOT NULL DEFAULT '', + context_summary_order INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')), + content TEXT NOT NULL, + total_tokens INTEGER NOT NULL DEFAULT 0, + "order" INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_messages_conv_order ON messages(conversation_id, "order"); + CREATE INDEX IF NOT EXISTS idx_messages_conv_id ON messages(conversation_id); + CREATE INDEX IF NOT EXISTS idx_conversations_group_id ON conversations(group_id); + `) + if err != nil { + return err + } + return s.applyMigrations() +} + +func (s *Store) applyMigrations() error { + migrations := []struct { + version int + sql string + }{ + { + version: 1, + sql: `ALTER TABLE groups ADD COLUMN location TEXT NOT NULL DEFAULT ''`, + }, + { + version: 2, + sql: `ALTER TABLE messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'message'`, + }, + { + version: 3, + sql: `ALTER TABLE messages ADD COLUMN name TEXT NOT NULL DEFAULT ''`, + }, + { + version: 4, + sql: `ALTER TABLE messages ADD COLUMN agent TEXT NOT NULL DEFAULT ''`, + }, + { + version: 5, + sql: `ALTER TABLE messages ADD COLUMN total_tokens INTEGER NOT NULL DEFAULT 0`, + }, + { + version: 6, + sql: `ALTER TABLE conversations ADD COLUMN context_summary TEXT NOT NULL DEFAULT ''`, + }, + { + version: 7, + sql: `ALTER TABLE conversations ADD COLUMN context_summary_order INTEGER NOT NULL DEFAULT 0`, + }, + } + + for _, m := range migrations { + applied, err := s.isMigrationApplied(m.version) + if err != nil { + return err + } + if !applied { + if m.version == 5 { + exists, err := s.columnExists("messages", "total_tokens") + if err != nil { + return err + } + if exists { + _, err = s.db.Exec( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", + m.version, time.Now().UTC(), + ) + if err != nil { + return fmt.Errorf("failed to record migration %d: %w", m.version, err) + } + continue + } + } + if m.version == 6 { + exists, err := s.columnExists("conversations", "context_summary") + if err != nil { + return err + } + if exists { + _, err = s.db.Exec( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", + m.version, time.Now().UTC(), + ) + if err != nil { + return fmt.Errorf("failed to record migration %d: %w", m.version, err) + } + continue + } + } + if m.version == 7 { + exists, err := s.columnExists("conversations", "context_summary_order") + if err != nil { + return err + } + if exists { + _, err = s.db.Exec( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", + m.version, time.Now().UTC(), + ) + if err != nil { + return fmt.Errorf("failed to record migration %d: %w", m.version, err) + } + continue + } + } + _, err = s.db.Exec(m.sql) + if err != nil { + return fmt.Errorf("migration %d failed: %w", m.version, err) + } + _, err = s.db.Exec( + "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", + m.version, time.Now().UTC(), + ) + if err != nil { + return fmt.Errorf("failed to record migration %d: %w", m.version, err) + } + } + } + + return nil +} + +func (s *Store) isMigrationApplied(version int) (bool, error) { + var count int + err := s.db.QueryRow( + "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", version, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func (s *Store) columnExists(tableName, columnName string) (bool, error) { + rows, err := s.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName)) + if err != nil { + return false, err + } + defer rows.Close() + + var cid int + var name, colType string + var notNull int + var dfltValue sql.NullString + var pk int + for rows.Next() { + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { + return false, err + } + if name == columnName { + return true, nil + } + } + if err := rows.Err(); err != nil { + return false, err + } + return false, nil +} + +func (s *Store) CreateGroup(name string) (*Group, error) { + id := generateID() + now := time.Now().UTC() + _, err := s.db.Exec( + "INSERT INTO groups (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)", + id, name, now, now, + ) + if err != nil { + return nil, fmt.Errorf("failed to create group: %w", err) + } + return &Group{ + ID: id, + Name: name, + Location: "", + TotalTokens: 0, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +func (s *Store) GetGroup(id string) (*Group, error) { + var group Group + err := s.db.QueryRow( + `SELECT g.id, g.name, g.location, g.created_at, g.updated_at, + COALESCE(( + SELECT SUM(m.total_tokens) + FROM conversations c + JOIN messages m ON c.id = m.conversation_id + WHERE c.group_id = g.id + ), 0) AS total_tokens + FROM groups g + WHERE g.id = ?`, id, + ).Scan(&group.ID, &group.Name, &group.Location, &group.CreatedAt, &group.UpdatedAt, &group.TotalTokens) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get group: %w", err) + } + return &group, nil +} + +func (s *Store) ListGroups() ([]Group, error) { + rows, err := s.db.Query( + `SELECT g.id, g.name, g.location, g.created_at, g.updated_at, + COALESCE(( + SELECT SUM(m.total_tokens) + FROM conversations c + JOIN messages m ON c.id = m.conversation_id + WHERE c.group_id = g.id + ), 0) AS total_tokens + FROM groups g + ORDER BY g.updated_at DESC`, + ) + if err != nil { + return nil, fmt.Errorf("failed to list groups: %w", err) + } + defer rows.Close() + + var groups []Group + for rows.Next() { + var g Group + if err := rows.Scan(&g.ID, &g.Name, &g.Location, &g.CreatedAt, &g.UpdatedAt, &g.TotalTokens); err != nil { + return nil, fmt.Errorf("failed to scan group: %w", err) + } + groups = append(groups, g) + } + return groups, nil +} + +func (s *Store) UpdateGroupTitle(id, name string) error { + _, err := s.db.Exec( + "UPDATE groups SET name = ?, updated_at = ? WHERE id = ?", + name, time.Now().UTC(), id, + ) + return err +} + +func (s *Store) DeleteGroup(id string) error { + _, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id) + return err +} + +func (s *Store) SetGroupLocation(id, location string) error { + _, err := s.db.Exec( + "UPDATE groups SET location = ?, updated_at = ? WHERE id = ?", + location, time.Now().UTC(), id, + ) + return err +} + +func (s *Store) UpdateConversationGroup(id, groupID string) error { + _, err := s.db.Exec( + "UPDATE conversations SET group_id = ?, updated_at = ? WHERE id = ? AND (group_id IS NULL OR group_id = '')", + groupID, time.Now().UTC(), id, + ) + return err +} + +func (s *Store) HasGroupLocation(id string) (bool, error) { + var count int + err := s.db.QueryRow( + "SELECT COUNT(*) FROM groups WHERE id = ? AND location != ''", id, + ).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func (s *Store) CreateConversation(title string, groupID *string) (*Conversation, error) { + id := generateID() + now := time.Now().UTC() + var groupIDVal interface{} = nil + if groupID != nil { + groupIDVal = *groupID + } + _, err := s.db.Exec( + "INSERT INTO conversations (id, title, group_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + id, title, groupIDVal, now, now, + ) + if err != nil { + return nil, fmt.Errorf("failed to create conversation: %w", err) + } + return &Conversation{ + ID: id, + Title: title, + GroupID: groupID, + TotalTokens: 0, + ContextSummary: "", + ContextSummaryOrder: 0, + CreatedAt: now, + UpdatedAt: now, + }, nil +} + +func (s *Store) GetConversation(id string) (*Conversation, error) { + var conv Conversation + var groupID sql.NullString + err := s.db.QueryRow( + `SELECT c.id, c.title, c.group_id, c.created_at, c.updated_at, + COALESCE((SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id), 0) AS message_count, + COALESCE((SELECT SUM(m.total_tokens) FROM messages m WHERE m.conversation_id = c.id), 0) AS total_tokens, + COALESCE(c.context_summary, '') AS context_summary, + COALESCE(c.context_summary_order, 0) AS context_summary_order + FROM conversations c + WHERE c.id = ?`, id, + ).Scan(&conv.ID, &conv.Title, &groupID, &conv.CreatedAt, &conv.UpdatedAt, &conv.MessageCount, &conv.TotalTokens, &conv.ContextSummary, &conv.ContextSummaryOrder) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("failed to get conversation: %w", err) + } + if groupID.Valid { + conv.GroupID = &groupID.String + } + return &conv, nil +} + +func (s *Store) ListConversations() ([]Conversation, error) { + rows, err := s.db.Query(` + SELECT c.id, c.title, c.group_id, c.created_at, c.updated_at, + COALESCE((SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id), 0) AS message_count, + COALESCE((SELECT SUM(m.total_tokens) FROM messages m WHERE m.conversation_id = c.id), 0) AS total_tokens, + COALESCE(c.context_summary, '') AS context_summary, + COALESCE(c.context_summary_order, 0) AS context_summary_order + FROM conversations c + ORDER BY c.updated_at DESC + `) + if err != nil { + return nil, fmt.Errorf("failed to list conversations: %w", err) + } + defer rows.Close() + + var conversations []Conversation + for rows.Next() { + var conv Conversation + var groupID sql.NullString + if err := rows.Scan(&conv.ID, &conv.Title, &groupID, &conv.CreatedAt, &conv.UpdatedAt, &conv.MessageCount, &conv.TotalTokens, &conv.ContextSummary, &conv.ContextSummaryOrder); err != nil { + return nil, fmt.Errorf("failed to scan conversation: %w", err) + } + if groupID.Valid { + conv.GroupID = &groupID.String + } + conversations = append(conversations, conv) + } + return conversations, nil +} + +type GroupedConversations struct { + Groups []GroupWithConversations `json:"groups"` + Ungrouped []Conversation `json:"ungrouped"` +} + +type GroupWithConversations struct { + Group + Conversations []Conversation `json:"conversations"` +} + +func (s *Store) ListGroupedConversations() (*GroupedConversations, error) { + groups, err := s.ListGroups() + if err != nil { + return nil, err + } + + grouped := &GroupedConversations{ + Groups: make([]GroupWithConversations, 0), + Ungrouped: make([]Conversation, 0), + } + + for _, g := range groups { + rows, err := s.db.Query(` + SELECT c.id, c.title, c.group_id, c.created_at, c.updated_at, + COALESCE((SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id), 0) AS message_count, + COALESCE((SELECT SUM(m.total_tokens) FROM messages m WHERE m.conversation_id = c.id), 0) AS total_tokens, + COALESCE(c.context_summary, '') AS context_summary, + COALESCE(c.context_summary_order, 0) AS context_summary_order + FROM conversations c + WHERE c.group_id = ? + ORDER BY c.updated_at DESC + `, g.ID) + if err != nil { + return nil, fmt.Errorf("failed to list conversations for group %s: %w", g.ID, err) + } + + var convs []Conversation + for rows.Next() { + var conv Conversation + var groupID sql.NullString + if err := rows.Scan(&conv.ID, &conv.Title, &groupID, &conv.CreatedAt, &conv.UpdatedAt, &conv.MessageCount, &conv.TotalTokens, &conv.ContextSummary, &conv.ContextSummaryOrder); err != nil { + rows.Close() + return nil, fmt.Errorf("failed to scan conversation: %w", err) + } + convs = append(convs, conv) + } + rows.Close() + + grouped.Groups = append(grouped.Groups, GroupWithConversations{ + Group: g, + Conversations: convs, + }) + } + + ungroupedRows, err := s.db.Query(` + SELECT c.id, c.title, c.group_id, c.created_at, c.updated_at, + COALESCE((SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id), 0) AS message_count, + COALESCE((SELECT SUM(m.total_tokens) FROM messages m WHERE m.conversation_id = c.id), 0) AS total_tokens, + COALESCE(c.context_summary, '') AS context_summary, + COALESCE(c.context_summary_order, 0) AS context_summary_order + FROM conversations c + WHERE c.group_id IS NULL + ORDER BY c.updated_at DESC + `) + if err != nil { + return nil, fmt.Errorf("failed to list ungrouped conversations: %w", err) + } + defer ungroupedRows.Close() + + for ungroupedRows.Next() { + var conv Conversation + var groupID sql.NullString + if err := ungroupedRows.Scan(&conv.ID, &conv.Title, &groupID, &conv.CreatedAt, &conv.UpdatedAt, &conv.MessageCount, &conv.TotalTokens, &conv.ContextSummary, &conv.ContextSummaryOrder); err != nil { + return nil, fmt.Errorf("failed to scan ungrouped conversation: %w", err) + } + grouped.Ungrouped = append(grouped.Ungrouped, conv) + } + + return grouped, nil +} + +func (s *Store) UpdateConversationTitle(id, title string) error { + _, err := s.db.Exec( + "UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?", + title, time.Now().UTC(), id, + ) + return err +} + +func (s *Store) UpdateConversationContextSummary(id, summary string, summaryOrder int) error { + _, err := s.db.Exec( + "UPDATE conversations SET context_summary = ?, context_summary_order = ?, updated_at = ? WHERE id = ?", + summary, summaryOrder, time.Now().UTC(), id, + ) + return err +} + +func (s *Store) DeleteConversation(id string) error { + _, err := s.db.Exec("DELETE FROM conversations WHERE id = ?", id) + return err +} + +func (s *Store) AddMessage(convID, role, content string, order int, totalTokens int) error { + return s.AddMessageWithMeta(convID, role, content, order, "message", "", "", totalTokens) +} + +func (s *Store) AddMessageWithMeta(convID, role, content string, order int, kind, name, agent string, totalTokens int) error { + if kind == "" { + kind = "message" + } + _, err := s.db.Exec( + "INSERT INTO messages (conversation_id, role, content, total_tokens, kind, name, agent, \"order\", created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + convID, role, content, totalTokens, kind, name, agent, order, time.Now().UTC(), + ) + if err != nil { + return fmt.Errorf("failed to add message: %w", err) + } + _, err = s.db.Exec( + "UPDATE conversations SET updated_at = ? WHERE id = ?", + time.Now().UTC(), convID, + ) + return err +} + +func (s *Store) GetMessages(convID string) ([]Message, error) { + rows, err := s.db.Query( + "SELECT id, conversation_id, role, content, COALESCE(kind, 'message'), COALESCE(name, ''), COALESCE(agent, ''), COALESCE(total_tokens, 0), \"order\", created_at FROM messages WHERE conversation_id = ? ORDER BY \"order\" ASC", + convID, + ) + if err != nil { + return nil, fmt.Errorf("failed to get messages: %w", err) + } + defer rows.Close() + + messages := make([]Message, 0) + for rows.Next() { + var msg Message + if err := rows.Scan(&msg.ID, &msg.ConvID, &msg.Role, &msg.Content, &msg.Kind, &msg.Name, &msg.Agent, &msg.TotalTokens, &msg.Order, &msg.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to scan message: %w", err) + } + messages = append(messages, msg) + } + return messages, nil +} + +func (s *Store) DeleteMessages(convID string) error { + _, err := s.db.Exec("DELETE FROM messages WHERE conversation_id = ?", convID) + return err +} + +func (s *Store) Close() error { + return s.db.Close() +} diff --git a/db/db_test.go b/db/db_test.go new file mode 100644 index 0000000..9e9d627 --- /dev/null +++ b/db/db_test.go @@ -0,0 +1,211 @@ +package db + +import ( + "database/sql" + "path/filepath" + "testing" +) + +func newTempStore(t *testing.T) *Store { + t.Helper() + + dbFile := filepath.Join(t.TempDir(), "test.db") + conn, err := sql.Open("sqlite", dbFile+"?_busy_timeout=5000&_journal=WAL") + if err != nil { + t.Fatalf("sql.Open returned error: %v", err) + } + + store := &Store{db: conn} + if err := store.createTables(); err != nil { + t.Fatalf("createTables returned error: %v", err) + } + + t.Cleanup(func() { + _ = conn.Close() + }) + + return store +} + +func TestSetGroupLocationUpdatesExistingLocation(t *testing.T) { + store := newTempStore(t) + + group, err := store.CreateGroup("Workspace") + if err != nil { + t.Fatalf("CreateGroup returned error: %v", err) + } + + if err := store.SetGroupLocation(group.ID, "/tmp/one"); err != nil { + t.Fatalf("SetGroupLocation returned error: %v", err) + } + + updated, err := store.GetGroup(group.ID) + if err != nil { + t.Fatalf("GetGroup returned error: %v", err) + } + if updated.Location != "/tmp/one" { + t.Fatalf("location after first update = %q, want %q", updated.Location, "/tmp/one") + } + + if err := store.SetGroupLocation(group.ID, "/tmp/two"); err != nil { + t.Fatalf("SetGroupLocation returned error: %v", err) + } + + updated, err = store.GetGroup(group.ID) + if err != nil { + t.Fatalf("GetGroup returned error: %v", err) + } + if updated.Location != "/tmp/two" { + t.Fatalf("location after second update = %q, want %q", updated.Location, "/tmp/two") + } +} + +func TestUpdateConversationGroupAttachesUngroupedConversation(t *testing.T) { + store := newTempStore(t) + + group, err := store.CreateGroup("Workspace") + if err != nil { + t.Fatalf("CreateGroup returned error: %v", err) + } + + conv, err := store.CreateConversation("Chat", nil) + if err != nil { + t.Fatalf("CreateConversation returned error: %v", err) + } + + if err := store.UpdateConversationGroup(conv.ID, group.ID); err != nil { + t.Fatalf("UpdateConversationGroup returned error: %v", err) + } + + updated, err := store.GetConversation(conv.ID) + if err != nil { + t.Fatalf("GetConversation returned error: %v", err) + } + if updated.GroupID == nil { + t.Fatal("conversation group ID is nil, want a group ID") + } + if got, want := *updated.GroupID, group.ID; got != want { + t.Fatalf("conversation group ID = %q, want %q", got, want) + } +} + +func TestAddMessageWithMetaPersistsKindNameAndAgent(t *testing.T) { + store := newTempStore(t) + + conv, err := store.CreateConversation("Chat", nil) + if err != nil { + t.Fatalf("CreateConversation returned error: %v", err) + } + + if err := store.AddMessageWithMeta(conv.ID, "assistant", "Thinking", 1, "thinking", "", "Planner", 42); err != nil { + t.Fatalf("AddMessageWithMeta returned error: %v", err) + } + + messages, err := store.GetMessages(conv.ID) + if err != nil { + t.Fatalf("GetMessages returned error: %v", err) + } + if len(messages) != 1 { + t.Fatalf("message count = %d, want 1", len(messages)) + } + if got, want := messages[0].Kind, "thinking"; got != want { + t.Fatalf("message kind = %q, want %q", got, want) + } + if got, want := messages[0].Name, ""; got != want { + t.Fatalf("message name = %q, want %q", got, want) + } + if got, want := messages[0].Agent, "Planner"; got != want { + t.Fatalf("message agent = %q, want %q", got, want) + } + if got, want := messages[0].TotalTokens, 42; got != want { + t.Fatalf("message total tokens = %d, want %d", got, want) + } +} + +func TestConversationAndGroupTokenTotalsAggregate(t *testing.T) { + store := newTempStore(t) + + group, err := store.CreateGroup("Workspace") + if err != nil { + t.Fatalf("CreateGroup returned error: %v", err) + } + + conv1, err := store.CreateConversation("One", &group.ID) + if err != nil { + t.Fatalf("CreateConversation returned error: %v", err) + } + conv2, err := store.CreateConversation("Two", &group.ID) + if err != nil { + t.Fatalf("CreateConversation returned error: %v", err) + } + ungrouped, err := store.CreateConversation("Three", nil) + if err != nil { + t.Fatalf("CreateConversation returned error: %v", err) + } + + if err := store.AddMessageWithMeta(conv1.ID, "assistant", "A", 1, "message", "", "Planner", 11); err != nil { + t.Fatalf("AddMessageWithMeta returned error: %v", err) + } + if err := store.AddMessageWithMeta(conv1.ID, "assistant", "B", 2, "message", "", "Planner", 7); err != nil { + t.Fatalf("AddMessageWithMeta returned error: %v", err) + } + if err := store.AddMessageWithMeta(conv2.ID, "assistant", "C", 1, "message", "", "Planner", 5); err != nil { + t.Fatalf("AddMessageWithMeta returned error: %v", err) + } + if err := store.AddMessageWithMeta(ungrouped.ID, "assistant", "D", 1, "message", "", "Planner", 3); err != nil { + t.Fatalf("AddMessageWithMeta returned error: %v", err) + } + + conversations, err := store.ListConversations() + if err != nil { + t.Fatalf("ListConversations returned error: %v", err) + } + totalsByID := make(map[string]int, len(conversations)) + for _, conv := range conversations { + totalsByID[conv.ID] = conv.TotalTokens + } + if got, want := totalsByID[conv1.ID], 18; got != want { + t.Fatalf("conversation one total tokens = %d, want %d", got, want) + } + if got, want := totalsByID[conv2.ID], 5; got != want { + t.Fatalf("conversation two total tokens = %d, want %d", got, want) + } + if got, want := totalsByID[ungrouped.ID], 3; got != want { + t.Fatalf("ungrouped conversation total tokens = %d, want %d", got, want) + } + + groups, err := store.ListGroups() + if err != nil { + t.Fatalf("ListGroups returned error: %v", err) + } + if len(groups) != 1 { + t.Fatalf("group count = %d, want 1", len(groups)) + } + if got, want := groups[0].TotalTokens, 23; got != want { + t.Fatalf("group total tokens = %d, want %d", got, want) + } +} + +func TestUpdateConversationContextSummaryPersistsSummaryAndOrder(t *testing.T) { + store := newTempStore(t) + + conv, err := store.CreateConversation("Chat", nil) + if err != nil { + t.Fatalf("CreateConversation returned error: %v", err) + } + + if err := store.UpdateConversationContextSummary(conv.ID, "compressed memory", 12); err != nil { + t.Fatalf("UpdateConversationContextSummary returned error: %v", err) + } + + updated, err := store.GetConversation(conv.ID) + if err != nil { + t.Fatalf("GetConversation returned error: %v", err) + } + if got, want := updated.ContextSummary, "compressed memory"; got != want { + t.Fatalf("context summary = %q, want %q", got, want) + } + if got, want := updated.ContextSummaryOrder, 12; got != want { + t.Fatalf("context summary order = %d, want %d", got, want) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..058c7af --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module gocoder + +go 1.26.2 + +require ( + github.com/google/uuid v1.6.0 + modernc.org/sqlite v1.49.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..11fc64a --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= +modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= +modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= +modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e01f2e5 --- /dev/null +++ b/main.go @@ -0,0 +1,2508 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "gocoder/agents" + "gocoder/db" + "gocoder/tools" +) + +const dbPath = "/Users/rdarius/.nyxtex/go-coder.db" +const configPath = "config.json" +const ( + defaultServerAddr = ":8080" + defaultServerHost = "localhost" +) + +const ( + defaultLLMProvider = "openai" + defaultLLMBaseURL = "http://localhost:1234/v1" + defaultLLMAPIKey = "lm-studio" + defaultLLMModel = "qwen/qwen3.6-35b-a3b" + defaultLLMContextTokens = 262144 + defaultLLMTriggerRatio = 0.80 + defaultLLMTargetRatio = 0.70 + defaultLLMTemperature = 0.3 + defaultLLMMaxTokens = 262144 + defaultLLMTopP = 1.0 + defaultLLMFrequencyPenalty = 0.0 + defaultLLMPresencePenalty = 0.0 +) + +func boolPtr(v bool) *bool { + return &v +} + +type LLMConfig struct { + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + AutoOpenReasoning *bool `json:"auto_open_reasoning,omitempty"` + ContextTokens int `json:"context_tokens,omitempty"` + ContextCompactionTriggerRatio float64 `json:"context_compaction_trigger_ratio,omitempty"` + ContextCompactionTargetRatio float64 `json:"context_compaction_target_ratio,omitempty"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + TopP float64 `json:"top_p"` + FrequencyPenalty float64 `json:"frequency_penalty"` + PresencePenalty float64 `json:"presence_penalty"` +} + +type Config struct { + LLM LLMConfig `json:"llm"` + Server ServerConfig `json:"server"` +} + +type ServerConfig struct { + Addr string `json:"addr"` + Host string `json:"host"` +} + +var config Config +var configMu sync.RWMutex +var store *db.Store +var registry *agents.AgentRegistry + +type AppSettings struct { + LLMConfig + ServerAddr string `json:"server_addr"` + ServerHost string `json:"server_host"` +} + +type cliOverrides struct { + ServerAddr string + ServerHost string + ServerAddrSet bool + ServerHostSet bool +} + +func defaultConfig() Config { + return Config{ + LLM: LLMConfig{ + Provider: defaultLLMProvider, + BaseURL: defaultLLMBaseURL, + APIKey: defaultLLMAPIKey, + Model: defaultLLMModel, + ContextTokens: defaultLLMContextTokens, + ContextCompactionTriggerRatio: defaultLLMTriggerRatio, + ContextCompactionTargetRatio: defaultLLMTargetRatio, + Temperature: defaultLLMTemperature, + MaxTokens: defaultLLMMaxTokens, + TopP: defaultLLMTopP, + FrequencyPenalty: defaultLLMFrequencyPenalty, + PresencePenalty: defaultLLMPresencePenalty, + AutoOpenReasoning: boolPtr(true), + }, + Server: ServerConfig{ + Addr: defaultServerAddr, + Host: defaultServerHost, + }, + } +} + +func normalizeConfig(cfg *Config) { + if cfg == nil { + return + } + if strings.TrimSpace(cfg.Server.Addr) == "" { + cfg.Server.Addr = defaultServerAddr + } + if strings.TrimSpace(cfg.Server.Host) == "" { + cfg.Server.Host = defaultServerHost + } + if cfg.LLM.MaxTokens <= 0 { + cfg.LLM.MaxTokens = defaultLLMMaxTokens + } + if cfg.LLM.ContextTokens <= 0 { + cfg.LLM.ContextTokens = cfg.LLM.MaxTokens + } + if cfg.LLM.ContextCompactionTriggerRatio <= 0 || cfg.LLM.ContextCompactionTriggerRatio >= 1 { + cfg.LLM.ContextCompactionTriggerRatio = defaultLLMTriggerRatio + } + if cfg.LLM.ContextCompactionTargetRatio <= 0 || cfg.LLM.ContextCompactionTargetRatio >= cfg.LLM.ContextCompactionTriggerRatio { + cfg.LLM.ContextCompactionTargetRatio = defaultLLMTargetRatio + } + if cfg.LLM.AutoOpenReasoning == nil { + cfg.LLM.AutoOpenReasoning = boolPtr(true) + } + cfg.LLM.ReasoningEffort = normalizeReasoningEffort(cfg.LLM.ReasoningEffort) +} + +func normalizeReasoningEffort(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "": + return "" + case "low", "medium", "high": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + +func currentConfig() Config { + configMu.RLock() + defer configMu.RUnlock() + return config +} + +func currentLLMSettings() LLMConfig { + return currentConfig().LLM +} + +func currentAppSettings() AppSettings { + cfg := currentConfig() + return AppSettings{ + LLMConfig: cfg.LLM, + ServerAddr: cfg.Server.Addr, + ServerHost: cfg.Server.Host, + } +} + +func setConfig(cfg Config) { + configMu.Lock() + config = cfg + configMu.Unlock() +} + +type contextCompactionSettings struct { + ContextTokens int `json:"context_tokens"` + ContextCompactionTriggerRatio float64 `json:"context_compaction_trigger_ratio"` + ContextCompactionTargetRatio float64 `json:"context_compaction_target_ratio"` +} + +func currentContextCompactionSettings() contextCompactionSettings { + cfg := currentLLMSettings() + return contextCompactionSettings{ + ContextTokens: cfg.ContextTokens, + ContextCompactionTriggerRatio: cfg.ContextCompactionTriggerRatio, + ContextCompactionTargetRatio: cfg.ContextCompactionTargetRatio, + } +} + +func validateContextCompactionSettings(settings contextCompactionSettings) error { + if settings.ContextTokens <= 0 { + return fmt.Errorf("context_tokens must be greater than 0") + } + if settings.ContextCompactionTriggerRatio <= 0 || settings.ContextCompactionTriggerRatio >= 1 { + return fmt.Errorf("context_compaction_trigger_ratio must be greater than 0 and less than 1") + } + if settings.ContextCompactionTargetRatio <= 0 || settings.ContextCompactionTargetRatio >= settings.ContextCompactionTriggerRatio { + return fmt.Errorf("context_compaction_target_ratio must be greater than 0 and less than the trigger ratio") + } + return nil +} + +func validateLLMSettings(settings LLMConfig) error { + if strings.TrimSpace(settings.Provider) == "" { + return fmt.Errorf("provider is required") + } + if strings.TrimSpace(settings.BaseURL) == "" { + return fmt.Errorf("base_url is required") + } + if strings.TrimSpace(settings.Model) == "" { + return fmt.Errorf("model is required") + } + if settings.MaxTokens <= 0 { + return fmt.Errorf("max_tokens must be greater than 0") + } + if settings.ContextTokens <= 0 { + return fmt.Errorf("context_tokens must be greater than 0") + } + if settings.ContextCompactionTriggerRatio <= 0 || settings.ContextCompactionTriggerRatio >= 1 { + return fmt.Errorf("context_compaction_trigger_ratio must be greater than 0 and less than 1") + } + if settings.ContextCompactionTargetRatio <= 0 || settings.ContextCompactionTargetRatio >= settings.ContextCompactionTriggerRatio { + return fmt.Errorf("context_compaction_target_ratio must be greater than 0 and less than the trigger ratio") + } + if settings.TopP <= 0 || settings.TopP > 1 { + return fmt.Errorf("top_p must be greater than 0 and less than or equal to 1") + } + if settings.Temperature < 0 { + return fmt.Errorf("temperature must be greater than or equal to 0") + } + if normalized := normalizeReasoningEffort(settings.ReasoningEffort); normalized != strings.ToLower(strings.TrimSpace(settings.ReasoningEffort)) { + return fmt.Errorf("reasoning_effort must be empty or one of low, medium, high") + } + return nil +} + +func validateServerSettings(addr, host string) error { + if strings.TrimSpace(addr) == "" { + return fmt.Errorf("server_addr is required") + } + if strings.TrimSpace(host) == "" { + return fmt.Errorf("server_host is required") + } + return nil +} + +func validateAppSettings(settings AppSettings) error { + if err := validateLLMSettings(settings.LLMConfig); err != nil { + return err + } + if err := validateServerSettings(settings.ServerAddr, settings.ServerHost); err != nil { + return err + } + return nil +} + +func persistConfigToDisk(cfg Config) error { + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + tmpPath := configPath + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + return err + } + if err := os.Rename(tmpPath, configPath); err != nil { + _ = os.Remove(tmpPath) + return err + } + return nil +} + +func updateAppSettings(settings AppSettings) error { + trimmed := settings + trimmed.Provider = strings.TrimSpace(trimmed.Provider) + trimmed.BaseURL = strings.TrimSpace(trimmed.BaseURL) + trimmed.APIKey = strings.TrimSpace(trimmed.APIKey) + trimmed.Model = strings.TrimSpace(trimmed.Model) + trimmed.ReasoningEffort = normalizeReasoningEffort(trimmed.ReasoningEffort) + trimmed.ServerAddr = strings.TrimSpace(trimmed.ServerAddr) + trimmed.ServerHost = strings.TrimSpace(trimmed.ServerHost) + + if err := validateAppSettings(trimmed); err != nil { + return err + } + + configMu.Lock() + defer configMu.Unlock() + + updated := config + updated.LLM = trimmed.LLMConfig + updated.Server = ServerConfig{ + Addr: trimmed.ServerAddr, + Host: trimmed.ServerHost, + } + normalizeConfig(&updated) + + if err := persistConfigToDisk(updated); err != nil { + return err + } + + config = updated + return nil +} + +func updateLLMSettings(settings LLMConfig) error { + current := currentAppSettings() + current.LLMConfig = settings + return updateAppSettings(current) +} + +func updateContextCompactionSettings(settings contextCompactionSettings) error { + updated := currentLLMSettings() + updated.ContextTokens = settings.ContextTokens + updated.ContextCompactionTriggerRatio = settings.ContextCompactionTriggerRatio + updated.ContextCompactionTargetRatio = settings.ContextCompactionTargetRatio + return updateLLMSettings(updated) +} + +func loadConfig() { + loaded := defaultConfig() + data, err := os.ReadFile(configPath) + if err != nil { + if !os.IsNotExist(err) { + log.Fatalf("Failed to read config file: %v", err) + } + normalizeConfig(&loaded) + setConfig(loaded) + if err := persistConfigToDisk(loaded); err != nil { + log.Printf("Failed to create default config file: %v", err) + } + return + } + if err := json.Unmarshal(data, &loaded); err != nil { + log.Fatalf("Failed to parse config file: %v", err) + } + normalizeConfig(&loaded) + setConfig(loaded) +} + +func applyCLIOverrides(cfg *Config, overrides cliOverrides) { + if cfg == nil { + return + } + if overrides.ServerAddrSet { + cfg.Server.Addr = strings.TrimSpace(overrides.ServerAddr) + } + if overrides.ServerHostSet { + cfg.Server.Host = strings.TrimSpace(overrides.ServerHost) + } + normalizeConfig(cfg) +} + +func parseCLIOverrides() cliOverrides { + serverAddr := flag.String("server-addr", defaultServerAddr, "override the server listen address") + serverHost := flag.String("server-host", defaultServerHost, "override the server host used in the displayed URL") + flag.Parse() + + visited := map[string]bool{} + flag.Visit(func(f *flag.Flag) { + visited[f.Name] = true + }) + + return cliOverrides{ + ServerAddr: *serverAddr, + ServerHost: *serverHost, + ServerAddrSet: visited["server-addr"], + ServerHostSet: visited["server-host"], + } +} + +func serverDisplayAddress(server ServerConfig) string { + addr := strings.TrimSpace(server.Addr) + host := strings.TrimSpace(server.Host) + if addr == "" { + addr = defaultServerAddr + } + if host == "" { + host = defaultServerHost + } + if strings.HasPrefix(addr, ":") { + return host + addr + } + return addr +} + +func initDB() { + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + log.Fatalf("Failed to create db directory: %v", err) + } + s, err := db.NewStore() + if err != nil { + log.Fatalf("Failed to initialize database: %v", err) + } + store = s + log.Println("Database initialized successfully") +} + +func initAgents() { + registry = agents.NewAgentRegistry() + log.Println("Agents initialized successfully") +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type LMStudioRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Stream bool `json:"stream,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + TopP float64 `json:"top_p,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + StreamOptions *streamOptions `json:"stream_options,omitempty"` +} + +type streamOptions struct { + IncludeUsage bool `json:"include_usage,omitempty"` +} + +type tokenUsage struct { + PromptTokens int `json:"prompt_tokens,omitempty"` + CompletionTokens int `json:"completion_tokens,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` +} + +func (u tokenUsage) Total() int { + if u.TotalTokens > 0 { + return u.TotalTokens + } + if u.PromptTokens > 0 || u.CompletionTokens > 0 { + return u.PromptTokens + u.CompletionTokens + } + return 0 +} + +type LMStudioResponse struct { + Choices []struct { + Message LMStudioResponseMessage `json:"message"` + } `json:"choices"` + Usage tokenUsage `json:"usage,omitempty"` +} + +type LMStudioResponseMessage struct { + Content string `json:"content"` + Reasoning string `json:"reasoning,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCallAI `json:"tool_calls,omitempty"` +} + +type ToolCallArguments string + +func (a ToolCallArguments) MarshalJSON() ([]byte, error) { + return json.Marshal(string(a)) +} + +func (a *ToolCallArguments) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *a = "" + return nil + } + + var value string + if err := json.Unmarshal(data, &value); err == nil { + *a = ToolCallArguments(value) + return nil + } + + if json.Valid(data) { + *a = ToolCallArguments(strings.TrimSpace(string(data))) + return nil + } + + return fmt.Errorf("tool call arguments must be a string or valid JSON") +} + +type ToolCallFunction struct { + Name string `json:"name"` + Arguments ToolCallArguments `json:"arguments"` +} + +type ToolCallAI struct { + ID string `json:"id"` + Type string `json:"type"` + Function ToolCallFunction `json:"function"` +} + +type LMStudioStreamResponse struct { + Choices []struct { + Delta LMStudioStreamDelta `json:"delta"` + } `json:"choices"` + Usage *tokenUsage `json:"usage,omitempty"` +} + +type LMStudioStreamToolCallFunctionDelta struct { + Name string `json:"name,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type LMStudioStreamToolCallDelta struct { + Index int `json:"index"` + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + Function LMStudioStreamToolCallFunctionDelta `json:"function"` +} + +type LMStudioStreamDelta struct { + Content string `json:"content"` + Reasoning string `json:"reasoning,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []LMStudioStreamToolCallDelta `json:"tool_calls,omitempty"` +} + +type apiMessagePart struct { + Role string `json:"role"` + // Keep content explicit even when empty. Some LMStudio/Qwen backends reject + // tool-call messages if content is omitted entirely. + Content string `json:"content"` + ToolCalls []ToolCallAI `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +type apiToolFunction struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters tools.Schema `json:"parameters"` +} + +type apiToolDef struct { + Type string `json:"type"` + Function apiToolFunction `json:"function"` +} + +type apiRequest struct { + Model string `json:"model"` + Messages []apiMessagePart `json:"messages"` + Tools []apiToolDef `json:"tools,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *streamOptions `json:"stream_options,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + TopP float64 `json:"top_p,omitempty"` +} + +type messageOrderTracker struct { + next int +} + +func newMessageOrderTracker(start int) *messageOrderTracker { + return &messageOrderTracker{next: start} +} + +func (t *messageOrderTracker) Next() int { + order := t.next + t.next++ + return order +} + +type toolExecutorFunc func(name string, argsJSON json.RawMessage) *tools.Result + +type subagentCallArgs struct { + Task string `json:"task"` + Context string `json:"context"` +} + +type streamResult struct { + fullResponse string + reasoning string + totalTokens int + err error +} + +type toolLoopResult struct { + fullResponse string + totalTokens int + noResponseReason string +} + +type streamedToolResponse struct { + fullResponse string + reasoning string + toolCalls []ToolCallAI + totalTokens int + err error +} + +type streamedToolCall struct { + id string + typ string + name string + arguments strings.Builder +} + +func (c *streamedToolCall) toToolCallAI() ToolCallAI { + if c == nil { + return ToolCallAI{} + } + return ToolCallAI{ + ID: c.id, + Type: c.typ, + Function: ToolCallFunction{ + Name: c.name, + Arguments: ToolCallArguments(c.arguments.String()), + }, + } +} + +func appendStreamedToolCall(toolCalls map[int]*streamedToolCall, delta LMStudioStreamToolCallDelta) { + if toolCalls == nil { + return + } + call := toolCalls[delta.Index] + if call == nil { + call = &streamedToolCall{} + toolCalls[delta.Index] = call + } + if delta.ID != "" { + call.id = delta.ID + } + if delta.Type != "" { + call.typ = delta.Type + } + if delta.Function.Name != "" { + call.name = delta.Function.Name + } + if delta.Function.Arguments != "" { + call.arguments.WriteString(delta.Function.Arguments) + } +} + +func finalizeStreamedToolCalls(toolCalls map[int]*streamedToolCall) []ToolCallAI { + if len(toolCalls) == 0 { + return nil + } + indexes := make([]int, 0, len(toolCalls)) + for idx := range toolCalls { + indexes = append(indexes, idx) + } + sort.Ints(indexes) + out := make([]ToolCallAI, 0, len(indexes)) + for _, idx := range indexes { + if call := toolCalls[idx]; call != nil { + out = append(out, call.toToolCallAI()) + } + } + return out +} + +type conversationStreamEvent struct { + Event string `json:"gocoder_event"` + Kind string `json:"kind"` + Step int `json:"step,omitempty"` + Total int `json:"total_steps,omitempty"` + Role string `json:"role,omitempty"` + Agent string `json:"agent,omitempty"` + Name string `json:"name,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` + Content string `json:"content"` +} + +type progressTracker struct { + step int + total int +} + +func (p *progressTracker) emit(ctx context.Context, w http.ResponseWriter, kind, content string, totalEstimate int) { + if ctx != nil && ctx.Err() != nil { + return + } + p.step++ + if totalEstimate > p.total { + p.total = totalEstimate + } + writeSSEEvent(w, conversationStreamEvent{ + Event: "status", + Kind: kind, + Step: p.step, + Total: p.total, + Content: content, + }) +} + +func shouldIncludeInPrompt(msg db.Message) bool { + switch strings.ToLower(strings.TrimSpace(msg.Kind)) { + case "", "message", "assistant", "final": + return true + default: + return false + } +} + +func filterPromptMessages(messages []db.Message) []db.Message { + filtered := make([]db.Message, 0, len(messages)) + for _, msg := range messages { + if shouldIncludeInPrompt(msg) { + filtered = append(filtered, msg) + } + } + return filtered +} + +func buildLMStudioRequest(messages []db.Message, stream bool) *LMStudioRequest { + cfg := currentConfig() + return buildLMStudioRequestWithLimits(messages, stream, cfg.LLM, cfg.LLM.MaxTokens, cfg.LLM.Temperature) +} + +func buildLMStudioRequestWithLimits(messages []db.Message, stream bool, llm LLMConfig, maxTokens int, temperature float64) *LMStudioRequest { + filtered := filterPromptMessages(messages) + chatMsgs := make([]chatMessage, len(filtered)) + for i, msg := range filtered { + chatMsgs[i] = chatMessage{Role: msg.Role, Content: msg.Content} + } + req := &LMStudioRequest{ + Stream: stream, + Model: llm.Model, + ReasoningEffort: llm.ReasoningEffort, + Temperature: temperature, + MaxTokens: maxTokens, + TopP: llm.TopP, + FrequencyPenalty: llm.FrequencyPenalty, + PresencePenalty: llm.PresencePenalty, + Messages: chatMsgs, + } + if stream { + req.StreamOptions = &streamOptions{IncludeUsage: true} + } + return req +} + +func buildPromptMessages(systemMsgs []db.Message, history []db.Message, userMessage string) []db.Message { + promptMessages := make([]db.Message, 0, len(systemMsgs)+len(history)+1) + promptMessages = append(promptMessages, systemMsgs...) + promptMessages = append(promptMessages, filterPromptMessages(history)...) + if trimmed := strings.TrimSpace(userMessage); trimmed != "" { + promptMessages = append(promptMessages, db.Message{Role: "user", Content: trimmed}) + } + return promptMessages +} + +func prettyPrintJSON(raw json.RawMessage) string { + if len(raw) == 0 { + return "{}" + } + + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err == nil { + return buf.String() + } + + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return "{}" + } + return trimmed +} + +func firstNonEmptyText(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func splitThinkTagsFromContent(content string) (string, string) { + if strings.TrimSpace(content) == "" { + return "", "" + } + + var reasoning strings.Builder + var public strings.Builder + remaining := content + inThink := false + + for len(remaining) > 0 { + if inThink { + closeIdx := strings.Index(remaining, "") + if closeIdx < 0 { + reasoning.WriteString(remaining) + break + } + reasoning.WriteString(remaining[:closeIdx]) + remaining = remaining[closeIdx+len(""):] + inThink = false + continue + } + + openIdx := strings.Index(remaining, "") + if openIdx < 0 { + public.WriteString(remaining) + break + } + public.WriteString(remaining[:openIdx]) + remaining = remaining[openIdx+len(""):] + inThink = true + } + + return strings.TrimSpace(reasoning.String()), strings.TrimSpace(public.String()) +} + +func collectThinkingAndContent(reasoning, reasoningContent, content string) (string, string) { + if explicitReasoning := firstNonEmptyText(reasoning, reasoningContent); explicitReasoning != "" { + return explicitReasoning, strings.TrimSpace(content) + } + return splitThinkTagsFromContent(content) +} + +func combineThinkingText(reasoning, content string) string { + reasoning = strings.TrimSpace(reasoning) + content = strings.TrimSpace(content) + if reasoning == "" { + return content + } + if content == "" { + return reasoning + } + if content == reasoning { + return reasoning + } + return reasoning + "\n\n" + content +} + +func writeSSEEvent(w http.ResponseWriter, event conversationStreamEvent) { + if event.Event == "" { + event.Event = "conversation_message" + } + payload, err := json.Marshal(event) + if err != nil { + payload = []byte(`{"gocoder_event":"conversation_message","kind":"error","content":"failed to encode stream event"}`) + } + fmt.Fprintf(w, "data: %s\n\n", payload) + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func emitStatus(ctx context.Context, w http.ResponseWriter, kind, content string, step, total int) { + if ctx != nil && ctx.Err() != nil { + return + } + writeSSEEvent(w, conversationStreamEvent{ + Event: "status", + Kind: kind, + Step: step, + Total: total, + Content: content, + }) +} + +func persistConversationEvent(convID string, order int, role, kind, content, name, agent string, totalTokens int) { + if store == nil || convID == "" { + return + } + if err := store.AddMessageWithMeta(convID, role, content, order, kind, name, agent, totalTokens); err != nil { + log.Printf("failed to persist %s message for conversation %s: %v", kind, convID, err) + } +} + +func streamLMStudio(ctx context.Context, messages []db.Message, w http.ResponseWriter) streamResult { + cfg := currentConfig() + reqBody := buildLMStudioRequestWithLimits(messages, true, cfg.LLM, cfg.LLM.MaxTokens, cfg.LLM.Temperature) + jsonData, err := json.Marshal(reqBody) + if err != nil { + if ctx == nil || ctx.Err() == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("Error: %v", err))) + } + return streamResult{err: err} + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + if ctx == nil || ctx.Err() == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(fmt.Sprintf("Error: %v", err))) + } + return streamResult{err: err} + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream, application/json") + + progress := &progressTracker{} + progress.emit(ctx, w, "thinking", "Generating response...", 2) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if ctx == nil || ctx.Err() == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(fmt.Sprintf("Error: %v", err))) + } + return streamResult{err: err} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.ReadAll(resp.Body) + if ctx == nil || ctx.Err() == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(fmt.Sprintf("Error: LMStudio API error: status=%d", resp.StatusCode))) + } + return streamResult{err: fmt.Errorf("LMStudio API error: status=%d", resp.StatusCode)} + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Error: streaming not supported")) + return streamResult{err: fmt.Errorf("streaming not supported")} + } + + var fullResponse strings.Builder + var reasoning strings.Builder + var lastUsage tokenUsage + streamingAnnounced := false + reasoningAnnounced := false + explicitReasoningSeen := false + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) + for scanner.Scan() { + if ctx != nil && ctx.Err() != nil { + return streamResult{err: ctx.Err()} + } + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var streamResp LMStudioStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + continue + } + if streamResp.Usage != nil { + lastUsage = *streamResp.Usage + } + + for _, choice := range streamResp.Choices { + reasoningChunk := choice.Delta.Reasoning + if strings.TrimSpace(reasoningChunk) == "" { + reasoningChunk = choice.Delta.ReasoningContent + } + contentChunk := choice.Delta.Content + if reasoningChunk != "" { + explicitReasoningSeen = true + if !reasoningAnnounced { + progress.emit(ctx, w, "thinking", "Reasoning is streaming...", 2) + reasoningAnnounced = true + } + reasoning.WriteString(reasoningChunk) + writeSSEEvent(w, conversationStreamEvent{ + Event: "thinking", + Kind: "thinking", + Content: reasoningChunk, + }) + flusher.Flush() + } + if contentChunk != "" { + if !streamingAnnounced { + if explicitReasoningSeen { + progress.emit(ctx, w, "streaming", "Answer is streaming...", 2) + } else { + progress.emit(ctx, w, "streaming", "Response is streaming...", 2) + } + streamingAnnounced = true + } + fullResponse.WriteString(contentChunk) + content := strings.ReplaceAll(contentChunk, "\n", "\\n") + fmt.Fprintf(w, "data: %s\n\n", content) + flusher.Flush() + } + } + } + + if err := scanner.Err(); err != nil { + if ctx != nil && ctx.Err() != nil { + return streamResult{err: ctx.Err()} + } + return streamResult{err: err} + } + + fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() + if totalTokens := lastUsage.Total(); totalTokens > 0 { + writeSSEEvent(w, conversationStreamEvent{ + Event: "stream_usage", + Kind: "stream_usage", + TotalTokens: totalTokens, + }) + } + + fullText := fullResponse.String() + reasoningText := reasoning.String() + if reasoningText == "" { + var publicText string + reasoningText, publicText = splitThinkTagsFromContent(fullText) + if reasoningText != "" || publicText != "" { + fullText = publicText + } + } + + return streamResult{fullResponse: fullText, reasoning: reasoningText, totalTokens: lastUsage.Total()} +} + +func streamLMStudioWithTools(ctx context.Context, messages []apiMessagePart, toolDefs []apiToolDef, w http.ResponseWriter, progress *progressTracker, stageKind string) streamedToolResponse { + cfg := currentConfig() + reqBody := apiRequest{ + Model: cfg.LLM.Model, + Messages: messages, + Tools: toolDefs, + Stream: true, + ReasoningEffort: cfg.LLM.ReasoningEffort, + Temperature: cfg.LLM.Temperature, + MaxTokens: cfg.LLM.MaxTokens, + TopP: cfg.LLM.TopP, + StreamOptions: &streamOptions{IncludeUsage: true}, + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + if ctx == nil || ctx.Err() == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(fmt.Sprintf("Error: %v", err))) + } + return streamedToolResponse{err: err} + } + + requestCtx := ctx + if requestCtx == nil { + requestCtx = context.Background() + } + reqHTTP, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + if ctx == nil || ctx.Err() == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(fmt.Sprintf("Error: %v", err))) + } + return streamedToolResponse{err: err} + } + reqHTTP.Header.Set("Content-Type", "application/json") + reqHTTP.Header.Set("Accept", "text/event-stream, application/json") + + resp, err := http.DefaultClient.Do(reqHTTP) + if err != nil { + if ctx == nil || ctx.Err() == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(fmt.Sprintf("Error: %v", err))) + } + return streamedToolResponse{err: err} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + _, _ = io.ReadAll(resp.Body) + if ctx == nil || ctx.Err() == nil { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(fmt.Sprintf("Error: LLM API error: status=%d", resp.StatusCode))) + } + return streamedToolResponse{err: fmt.Errorf("LLM API error: status=%d", resp.StatusCode)} + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Error: streaming not supported")) + return streamedToolResponse{err: fmt.Errorf("streaming not supported")} + } + + var fullResponse strings.Builder + var reasoning strings.Builder + var lastUsage tokenUsage + streamingAnnounced := false + reasoningAnnounced := false + explicitReasoningSeen := false + toolCalls := map[int]*streamedToolCall{} + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) + for scanner.Scan() { + if ctx != nil && ctx.Err() != nil { + return streamedToolResponse{err: ctx.Err()} + } + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var streamResp LMStudioStreamResponse + if err := json.Unmarshal([]byte(data), &streamResp); err != nil { + continue + } + if streamResp.Usage != nil { + lastUsage = *streamResp.Usage + } + + for _, choice := range streamResp.Choices { + reasoningChunk := choice.Delta.Reasoning + if strings.TrimSpace(reasoningChunk) == "" { + reasoningChunk = choice.Delta.ReasoningContent + } + contentChunk := choice.Delta.Content + + if reasoningChunk != "" { + explicitReasoningSeen = true + if !reasoningAnnounced { + progress.emit(ctx, w, "thinking", "Reasoning is streaming...", 2) + reasoningAnnounced = true + } + reasoning.WriteString(reasoningChunk) + writeSSEEvent(w, conversationStreamEvent{ + Event: "thinking", + Kind: "thinking", + Content: reasoningChunk, + }) + flusher.Flush() + } + + for _, callDelta := range choice.Delta.ToolCalls { + appendStreamedToolCall(toolCalls, callDelta) + } + + if contentChunk != "" { + if !streamingAnnounced { + if explicitReasoningSeen { + progress.emit(ctx, w, "streaming", "Answer is streaming...", 2) + } else { + progress.emit(ctx, w, "streaming", "Response is streaming...", 2) + } + streamingAnnounced = true + } + fullResponse.WriteString(contentChunk) + content := strings.ReplaceAll(contentChunk, "\n", "\\n") + fmt.Fprintf(w, "data: %s\n\n", content) + flusher.Flush() + } + } + } + + if err := scanner.Err(); err != nil { + if ctx != nil && ctx.Err() != nil { + return streamedToolResponse{err: ctx.Err()} + } + return streamedToolResponse{err: err} + } + + fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() + if totalTokens := lastUsage.Total(); totalTokens > 0 { + writeSSEEvent(w, conversationStreamEvent{ + Event: "stream_usage", + Kind: "stream_usage", + TotalTokens: totalTokens, + }) + } + + fullText := fullResponse.String() + reasoningText := reasoning.String() + if reasoningText == "" { + var publicText string + reasoningText, publicText = splitThinkTagsFromContent(fullText) + if reasoningText != "" || publicText != "" { + fullText = publicText + } + } + + return streamedToolResponse{ + fullResponse: fullText, + reasoning: reasoningText, + toolCalls: finalizeStreamedToolCalls(toolCalls), + totalTokens: lastUsage.Total(), + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func resolveWorkspaceGroupID(conv *db.Conversation, fallbackGroupID string) string { + if conv != nil && conv.GroupID != nil { + if groupID := strings.TrimSpace(*conv.GroupID); groupID != "" { + return groupID + } + } + return strings.TrimSpace(fallbackGroupID) +} + +func stageStatusKind(stageName string) string { + switch strings.ToLower(strings.TrimSpace(stageName)) { + case "planner": + return "planner" + case "programmer": + return "programmer" + case "qa": + return "qa" + case "manager": + return "manager" + default: + return "thinking" + } +} + +func friendlyToolDisplayName(name string) string { + switch strings.ToLower(strings.TrimSpace(name)) { + case "planner_agent": + return "Planner" + case "programmer_agent": + return "Programmer" + case "qa_agent": + return "QA" + default: + return name + } +} + +func buildToolDefs(all []tools.Tool, allowed map[string]struct{}) []apiToolDef { + defs := make([]apiToolDef, 0, len(all)) + for _, t := range all { + if allowed != nil { + if _, ok := allowed[t.Name]; !ok { + continue + } + } + defs = append(defs, apiToolDef{ + Type: "function", + Function: apiToolFunction{ + Name: t.Name, + Description: t.Description, + Parameters: t.Parameters, + }, + }) + } + return defs +} + +func subagentCallTool(name, description string) apiToolDef { + return apiToolDef{ + Type: "function", + Function: apiToolFunction{ + Name: name, + Description: description, + Parameters: tools.Schema{ + Type: "object", + Properties: map[string]tools.Schema{ + "task": { + Type: "string", + Description: "The work to perform for this subagent.", + }, + "context": { + Type: "string", + Description: "Optional extra context, constraints, or findings for the subagent.", + }, + }, + Required: []string{"task"}, + }, + }, + } +} + +func buildSubagentPrompt(systemPrompt, task, contextText string, history []db.Message) []db.Message { + userParts := []string{} + if trimmed := strings.TrimSpace(task); trimmed != "" { + userParts = append(userParts, "Task:\n"+trimmed) + } + if trimmed := strings.TrimSpace(contextText); trimmed != "" { + userParts = append(userParts, "Context:\n"+trimmed) + } + if len(userParts) == 0 { + userParts = append(userParts, "Task:\nPerform the stage you were assigned.") + } + + prompt := []db.Message{ + { + Role: "system", + Content: systemPrompt, + }, + } + prompt = append(prompt, filterPromptMessages(history)...) + prompt = append(prompt, db.Message{ + Role: "user", + Content: strings.Join(userParts, "\n\n"), + }) + return prompt +} + +const plannerSystemPrompt = `You are the Planner subagent. +Your job is to inspect the workspace and produce a concrete implementation plan for the manager. +Rules: +1. Do not edit files. +2. Use read_file, list_directory, search_files, and grep to inspect the codebase. +3. Return a concise but specific plan with files to touch, risks, and tests to run. +4. If the request is ambiguous, state your assumptions clearly. +5. Do not reveal private chain-of-thought.` + +const programmerSystemPrompt = `You are the Programmer subagent. +Your job is to implement the approved plan in the workspace. +Rules: +1. Use read_file, write_file, patch_file, create_directory, delete_file, list_directory, search_files, grep, and run_command as needed. +2. Make the smallest safe change that satisfies the request. +3. Verify the change with tests or build commands when appropriate. +4. Return a concise summary of the files changed and any caveats. +5. Do not reveal private chain-of-thought.` + +const qaSystemPrompt = `You are the QA subagent. +Your job is to verify the current implementation and report whether it is ready. +Rules: +1. Do not edit files. +2. Use read_file, list_directory, search_files, grep, and run_command to inspect and verify the workspace. +3. Run tests or builds when that would meaningfully validate the change. +4. Return PASS or FAIL, followed by concrete issues or confirmation. +5. Do not reveal private chain-of-thought.` + +func runToolLoop( + ctx context.Context, + convID string, + promptMessages []db.Message, + toolDefs []apiToolDef, + toolExecutor toolExecutorFunc, + w http.ResponseWriter, + tracker *messageOrderTracker, + stageName string, + emitFinal bool, +) toolLoopResult { + const maxLoops = 20 + cfg := currentConfig() + var fullResponse string + var sawToolCalls bool + + apiMessages := make([]apiMessagePart, 0, len(promptMessages)) + for _, msg := range promptMessages { + apiMessages = append(apiMessages, apiMessagePart{Role: msg.Role, Content: msg.Content}) + } + + emitMessage := func(kind, content, name, agent string, totalTokens int) { + if ctx != nil && ctx.Err() != nil { + return + } + if strings.TrimSpace(content) == "" && strings.TrimSpace(name) == "" { + return + } + event := conversationStreamEvent{ + Kind: kind, + Role: "assistant", + Agent: agent, + Name: name, + TotalTokens: totalTokens, + Content: content, + } + writeSSEEvent(w, event) + if tracker != nil { + persistConversationEvent(convID, tracker.Next(), "assistant", kind, content, name, agent, totalTokens) + } + } + + emitError := func(msg string) { + if ctx != nil && ctx.Err() != nil { + return + } + writeSSEEvent(w, conversationStreamEvent{Kind: "error", Content: msg}) + } + + progress := &progressTracker{} + stageKind := stageStatusKind(stageName) + + for loop := 0; loop < maxLoops; loop++ { + if ctx != nil && ctx.Err() != nil { + reason := "" + if strings.TrimSpace(fullResponse) == "" { + reason = ctx.Err().Error() + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason} + } + + if loop == 0 { + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is thinking...", stageName), 2) + } else { + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is thinking with the latest tool results...", stageName), progress.total+1) + } + + req := apiRequest{ + Model: cfg.LLM.Model, + Messages: apiMessages, + Tools: toolDefs, + ReasoningEffort: cfg.LLM.ReasoningEffort, + Temperature: cfg.LLM.Temperature, + MaxTokens: cfg.LLM.MaxTokens, + TopP: cfg.LLM.TopP, + } + + jsonData, err := json.Marshal(req) + if err != nil { + emitError("Error: failed to marshal request") + return toolLoopResult{fullResponse: "Error: failed to marshal request", totalTokens: 0} + } + + requestCtx := ctx + if requestCtx == nil { + requestCtx = context.Background() + } + reqHTTP, err := http.NewRequestWithContext(requestCtx, http.MethodPost, cfg.LLM.BaseURL+"/chat/completions", bytes.NewBuffer(jsonData)) + if err != nil { + emitError(fmt.Sprintf("Error: failed to create request: %v", err)) + return toolLoopResult{fullResponse: fmt.Sprintf("Error: failed to create request: %v", err), totalTokens: 0} + } + reqHTTP.Header.Set("Content-Type", "application/json") + reqHTTP.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(reqHTTP) + if err != nil { + if requestCtx.Err() == nil { + emitError(fmt.Sprintf("Error: failed to call LLM API: %v", err)) + } + return toolLoopResult{fullResponse: fmt.Sprintf("Error: failed to call LLM API: %v", err), totalTokens: 0} + } + + if resp.StatusCode != http.StatusOK { + _, _ = io.ReadAll(resp.Body) + resp.Body.Close() + emitError(fmt.Sprintf("Error: LLM API error: status=%d", resp.StatusCode)) + return toolLoopResult{fullResponse: fmt.Sprintf("Error: LLM API error: status=%d", resp.StatusCode), totalTokens: 0} + } + + var lmResp LMStudioResponse + if err := json.NewDecoder(resp.Body).Decode(&lmResp); err != nil { + resp.Body.Close() + emitError("Error: failed to decode response") + return toolLoopResult{fullResponse: "Error: failed to decode response", totalTokens: 0} + } + resp.Body.Close() + + if len(lmResp.Choices) == 0 { + emitError("Error: empty response from LLM") + return toolLoopResult{fullResponse: "Error: empty response from LLM", totalTokens: 0} + } + + choice := lmResp.Choices[0] + requestTokens := lmResp.Usage.Total() + tokensAssigned := false + emitRequestMessage := func(kind, content, name, agent string) { + totalTokens := 0 + if !tokensAssigned { + totalTokens = requestTokens + tokensAssigned = true + } + emitMessage(kind, content, name, agent, totalTokens) + } + thinkingText, publicText := collectThinkingAndContent(choice.Message.Reasoning, choice.Message.ReasoningContent, choice.Message.Content) + + if len(choice.Message.ToolCalls) > 0 { + if combinedThinking := combineThinkingText(thinkingText, publicText); combinedThinking != "" { + emitRequestMessage("thinking", combinedThinking, stageName, stageName) + } + loopTotalEstimate := progress.total + if loopTotalEstimate < progress.step+2 { + loopTotalEstimate = progress.step + 2 + } + toolEstimate := progress.step + len(choice.Message.ToolCalls)*2 + 1 + if toolEstimate > loopTotalEstimate { + loopTotalEstimate = toolEstimate + } + + apiMessages = append(apiMessages, apiMessagePart{ + Role: "assistant", + Content: publicText, + ToolCalls: choice.Message.ToolCalls, + }) + + for _, tc := range choice.Message.ToolCalls { + if ctx != nil && ctx.Err() != nil { + reason := "" + if strings.TrimSpace(fullResponse) == "" { + reason = ctx.Err().Error() + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens, noResponseReason: reason} + } + + fnArgsJSON, err := decodeToolArguments(tc.Function.Arguments) + if err != nil { + toolArgs := prettyPrintJSON(json.RawMessage(tc.Function.Arguments)) + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is parsing arguments for %s...", stageName, friendlyToolDisplayName(tc.Function.Name)), loopTotalEstimate) + emitRequestMessage("tool_call", toolArgs, friendlyToolDisplayName(tc.Function.Name), stageName) + emitMessage("tool_result", fmt.Sprintf("Error: failed to parse tool arguments: %v", err), friendlyToolDisplayName(tc.Function.Name), stageName, 0) + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is reviewing the error and next step...", stageName), loopTotalEstimate) + apiMessages = append(apiMessages, apiMessagePart{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: fmt.Sprintf("Error: failed to parse tool arguments: %v", err), + }) + continue + } + + toolArgs := prettyPrintJSON(fnArgsJSON) + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is running %s...", stageName, friendlyToolDisplayName(tc.Function.Name)), loopTotalEstimate) + emitRequestMessage("tool_call", toolArgs, friendlyToolDisplayName(tc.Function.Name), stageName) + + result := toolExecutor(tc.Function.Name, fnArgsJSON) + if ctx != nil && ctx.Err() != nil { + reason := "" + if strings.TrimSpace(fullResponse) == "" { + reason = ctx.Err().Error() + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens, noResponseReason: reason} + } + content := result.Content + if content == "" && result.Error != "" { + content = "Error: " + result.Error + } + emitMessage("tool_result", content, friendlyToolDisplayName(tc.Function.Name), stageName, result.TotalTokens) + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is reviewing the tool result...", stageName), loopTotalEstimate) + + apiMessages = append(apiMessages, apiMessagePart{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: content, + }) + } + sawToolCalls = true + continue + } + + fullResponse = publicText + if thinkingText != "" { + emitRequestMessage("thinking", thinkingText, stageName, stageName) + } + if emitFinal && strings.TrimSpace(fullResponse) != "" { + emitRequestMessage("message", fullResponse, stageName, stageName) + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens} + } + + reason := "" + if strings.TrimSpace(fullResponse) == "" { + if sawToolCalls { + reason = fmt.Sprintf("it kept calling tools but never produced a final answer before hitting the %d-loop limit", maxLoops) + } else { + reason = "the model returned an empty final message" + } + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason} +} + +func runToolLoopStreaming( + ctx context.Context, + convID string, + promptMessages []db.Message, + toolDefs []apiToolDef, + toolExecutor toolExecutorFunc, + w http.ResponseWriter, + tracker *messageOrderTracker, + stageName string, + emitFinal bool, +) toolLoopResult { + const maxLoops = 20 + var fullResponse string + var sawToolCalls bool + + apiMessages := make([]apiMessagePart, 0, len(promptMessages)) + for _, msg := range promptMessages { + apiMessages = append(apiMessages, apiMessagePart{Role: msg.Role, Content: msg.Content}) + } + + emitMessage := func(kind, content, name, agent string, totalTokens int) { + if ctx != nil && ctx.Err() != nil { + return + } + if strings.TrimSpace(content) == "" && strings.TrimSpace(name) == "" { + return + } + event := conversationStreamEvent{ + Kind: kind, + Role: "assistant", + Agent: agent, + Name: name, + TotalTokens: totalTokens, + Content: content, + } + writeSSEEvent(w, event) + if tracker != nil { + persistConversationEvent(convID, tracker.Next(), "assistant", kind, content, name, agent, totalTokens) + } + } + + emitError := func(msg string) { + if ctx != nil && ctx.Err() != nil { + return + } + writeSSEEvent(w, conversationStreamEvent{Kind: "error", Content: msg}) + } + + progress := &progressTracker{} + stageKind := stageStatusKind(stageName) + + for loop := 0; loop < maxLoops; loop++ { + if ctx != nil && ctx.Err() != nil { + reason := "" + if strings.TrimSpace(fullResponse) == "" { + reason = ctx.Err().Error() + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason} + } + + if loop == 0 { + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is thinking...", stageName), 2) + } else { + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is thinking with the latest tool results...", stageName), progress.total+1) + } + + streamed := streamLMStudioWithTools(ctx, apiMessages, toolDefs, w, progress, stageKind) + if streamed.err != nil { + if ctx != nil && ctx.Err() != nil { + reason := "" + if strings.TrimSpace(fullResponse) == "" { + reason = ctx.Err().Error() + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason} + } + if strings.TrimSpace(fullResponse) == "" { + msg := streamed.err.Error() + emitError("Error: " + msg) + return toolLoopResult{fullResponse: "Error: " + msg, totalTokens: 0} + } + emitError("Error: " + streamed.err.Error()) + return toolLoopResult{fullResponse: fullResponse, totalTokens: streamed.totalTokens, noResponseReason: streamed.err.Error()} + } + + requestTokens := streamed.totalTokens + tokensAssigned := false + nextTotalTokens := func() int { + if tokensAssigned { + return 0 + } + tokensAssigned = true + return requestTokens + } + thinkingText := strings.TrimSpace(streamed.reasoning) + publicText := strings.TrimSpace(streamed.fullResponse) + + if len(streamed.toolCalls) > 0 { + if combinedThinking := combineThinkingText(thinkingText, publicText); combinedThinking != "" { + if tracker != nil { + persistConversationEvent(convID, tracker.Next(), "assistant", "thinking", combinedThinking, stageName, stageName, nextTotalTokens()) + } + } + loopTotalEstimate := progress.total + if loopTotalEstimate < progress.step+2 { + loopTotalEstimate = progress.step + 2 + } + toolEstimate := progress.step + len(streamed.toolCalls)*2 + 1 + if toolEstimate > loopTotalEstimate { + loopTotalEstimate = toolEstimate + } + + apiMessages = append(apiMessages, apiMessagePart{ + Role: "assistant", + Content: publicText, + ToolCalls: streamed.toolCalls, + }) + + for _, tc := range streamed.toolCalls { + if ctx != nil && ctx.Err() != nil { + reason := "" + if strings.TrimSpace(fullResponse) == "" { + reason = ctx.Err().Error() + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens, noResponseReason: reason} + } + + fnArgsJSON, err := decodeToolArguments(tc.Function.Arguments) + if err != nil { + toolArgs := prettyPrintJSON(json.RawMessage(tc.Function.Arguments)) + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is parsing arguments for %s...", stageName, friendlyToolDisplayName(tc.Function.Name)), loopTotalEstimate) + emitMessage("tool_call", toolArgs, friendlyToolDisplayName(tc.Function.Name), stageName, nextTotalTokens()) + emitMessage("tool_result", fmt.Sprintf("Error: failed to parse tool arguments: %v", err), friendlyToolDisplayName(tc.Function.Name), stageName, 0) + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is reviewing the error and next step...", stageName), loopTotalEstimate) + apiMessages = append(apiMessages, apiMessagePart{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: fmt.Sprintf("Error: failed to parse tool arguments: %v", err), + }) + continue + } + + toolArgs := prettyPrintJSON(fnArgsJSON) + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is running %s...", stageName, friendlyToolDisplayName(tc.Function.Name)), loopTotalEstimate) + emitMessage("tool_call", toolArgs, friendlyToolDisplayName(tc.Function.Name), stageName, nextTotalTokens()) + + result := toolExecutor(tc.Function.Name, fnArgsJSON) + if ctx != nil && ctx.Err() != nil { + reason := "" + if strings.TrimSpace(fullResponse) == "" { + reason = ctx.Err().Error() + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens, noResponseReason: reason} + } + content := result.Content + if content == "" && result.Error != "" { + content = "Error: " + result.Error + } + emitMessage("tool_result", content, friendlyToolDisplayName(tc.Function.Name), stageName, result.TotalTokens) + progress.emit(ctx, w, stageKind, fmt.Sprintf("%s is reviewing the tool result...", stageName), loopTotalEstimate) + + apiMessages = append(apiMessages, apiMessagePart{ + Role: "tool", + ToolCallID: tc.ID, + Name: tc.Function.Name, + Content: content, + }) + } + sawToolCalls = true + continue + } + + fullResponse = publicText + if thinkingText != "" { + totalTokens := nextTotalTokens() + if tracker != nil { + persistConversationEvent(convID, tracker.Next(), "assistant", "thinking", thinkingText, stageName, stageName, totalTokens) + } + } + if emitFinal && strings.TrimSpace(fullResponse) != "" { + if tracker != nil { + persistConversationEvent(convID, tracker.Next(), "assistant", "message", fullResponse, stageName, stageName, nextTotalTokens()) + } + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: requestTokens} + } + + reason := "" + if strings.TrimSpace(fullResponse) == "" { + if sawToolCalls { + reason = fmt.Sprintf("it kept calling tools but never produced a final answer before hitting the %d-loop limit", maxLoops) + } else { + reason = "the model returned an empty final message" + } + } + return toolLoopResult{fullResponse: fullResponse, totalTokens: 0, noResponseReason: reason} +} + +func subagentNoResponseError(stageName, reason string) string { + stage := strings.TrimSpace(stageName) + if stage == "" { + stage = "Subagent" + } + reason = strings.TrimSpace(reason) + if reason == "" { + return fmt.Sprintf("%s returned no response", stage) + } + return fmt.Sprintf("%s returned no response: %s", stage, reason) +} + +func runSubagentStage( + ctx context.Context, + convID string, + w http.ResponseWriter, + tracker *messageOrderTracker, + validator *tools.WorkspaceValidator, + history []db.Message, + stageName string, + systemPrompt string, + allowedToolNames []string, + task string, + contextText string, +) *tools.Result { + allowed := make(map[string]struct{}, len(allowedToolNames)) + for _, name := range allowedToolNames { + allowed[name] = struct{}{} + } + + allTools := tools.AvailableTools(validator.GetWorkspacePath()) + filteredTools := buildToolDefs(allTools, allowed) + promptMessages := buildSubagentPrompt(systemPrompt, task, contextText, history) + + stageExecutor := func(name string, argsJSON json.RawMessage) *tools.Result { + return executeTool(ctx, validator, name, argsJSON) + } + + finalResponse := runToolLoopStreaming(ctx, convID, promptMessages, filteredTools, stageExecutor, w, tracker, stageName, false) + if strings.TrimSpace(finalResponse.fullResponse) == "" { + reason := strings.TrimSpace(finalResponse.noResponseReason) + log.Printf("subagent stage %s returned no response for conversation %s: %s", stageName, convID, reason) + return &tools.Result{Success: false, Error: subagentNoResponseError(stageName, reason)} + } + return &tools.Result{Success: true, Content: finalResponse.fullResponse, TotalTokens: finalResponse.totalTokens} +} + +func runPlannerAgent(ctx context.Context, convID string, validator *tools.WorkspaceValidator, w http.ResponseWriter, tracker *messageOrderTracker, history []db.Message, task, contextText string) *tools.Result { + return runSubagentStage(ctx, convID, w, tracker, validator, history, "Planner", plannerSystemPrompt, []string{"read_file", "list_directory", "search_files", "grep"}, task, contextText) +} + +func runProgrammerAgent(ctx context.Context, convID string, validator *tools.WorkspaceValidator, w http.ResponseWriter, tracker *messageOrderTracker, history []db.Message, task, contextText string) *tools.Result { + return runSubagentStage(ctx, convID, w, tracker, validator, history, "Programmer", programmerSystemPrompt, []string{"read_file", "write_file", "patch_file", "create_directory", "delete_file", "list_directory", "search_files", "grep", "run_command"}, task, contextText) +} + +func runQAAgent(ctx context.Context, convID string, validator *tools.WorkspaceValidator, w http.ResponseWriter, tracker *messageOrderTracker, history []db.Message, task, contextText string) *tools.Result { + return runSubagentStage(ctx, convID, w, tracker, validator, history, "QA", qaSystemPrompt, []string{"read_file", "list_directory", "search_files", "grep", "run_command"}, task, contextText) +} + +// handleFunctionCalling implements the tool-use loop for the coding manager agent. +func handleFunctionCalling( + ctx context.Context, + convID string, + userMessage string, + systemMsgs []db.Message, + validator *tools.WorkspaceValidator, + w http.ResponseWriter, + groupID string, + history []db.Message, +) string { + promptMessages := buildPromptMessages(systemMsgs, history, userMessage) + managerTools := []apiToolDef{ + subagentCallTool("planner_agent", "Delegate work to the Planner subagent. Use this to inspect the codebase and produce a concrete implementation plan."), + subagentCallTool("programmer_agent", "Delegate work to the Programmer subagent. Use this to implement the current plan with code changes."), + subagentCallTool("qa_agent", "Delegate work to the QA subagent. Use this to verify the current changes and report issues or approval."), + } + tracker := newMessageOrderTracker(len(history) + 2) + + managerToolExecutor := func(name string, argsJSON json.RawMessage) *tools.Result { + var args subagentCallArgs + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + switch name { + case "planner_agent": + return runPlannerAgent(ctx, convID, validator, w, tracker, history, args.Task, args.Context) + case "programmer_agent": + return runProgrammerAgent(ctx, convID, validator, w, tracker, history, args.Task, args.Context) + case "qa_agent": + return runQAAgent(ctx, convID, validator, w, tracker, history, args.Task, args.Context) + default: + return &tools.Result{Success: false, Error: fmt.Sprintf("unknown tool: %s", name)} + } + } + + finalResponse := runToolLoopStreaming(ctx, convID, promptMessages, managerTools, managerToolExecutor, w, tracker, "Manager", true) + return finalResponse.fullResponse +} + +// executeTool runs a tool and returns the result. +func executeTool(ctx context.Context, validator *tools.WorkspaceValidator, name string, argsJSON json.RawMessage) *tools.Result { + switch name { + case "read_file": + var args struct { + Path string `json:"path"` + StartLine *int `json:"start_line"` + EndLine *int `json:"end_line"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + startLine := 1 + if args.StartLine != nil { + startLine = *args.StartLine + } + endLine := -1 + if args.EndLine != nil { + endLine = *args.EndLine + } + return executeReadFile(ctx, validator, args.Path, startLine, endLine) + + case "write_file": + var args struct { + Path string `json:"path"` + StartLine *int `json:"start_line"` + EndLine *int `json:"end_line"` + Content string `json:"content"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + startLine := 1 + if args.StartLine != nil { + startLine = *args.StartLine + } + endLine := -1 + if args.EndLine != nil { + endLine = *args.EndLine + } + return executeWriteFile(ctx, validator, args.Path, startLine, endLine, args.Content) + + case "patch_file": + var args struct { + Path string `json:"path"` + StartLine *int `json:"start_line"` + EndLine *int `json:"end_line"` + ExpectedText string `json:"expected_text"` + Content string `json:"content"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + startLine := 1 + if args.StartLine != nil { + startLine = *args.StartLine + } + endLine := -1 + if args.EndLine != nil { + endLine = *args.EndLine + } + return executePatchFile(ctx, validator, args.Path, startLine, endLine, args.ExpectedText, args.Content) + + case "create_directory": + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + return executeCreateDir(ctx, validator, args.Path) + + case "delete_file": + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + return executeDeleteFile(ctx, validator, args.Path) + + case "list_directory": + var args struct { + Path string `json:"path"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + return executeListDir(ctx, validator, args.Path) + + case "search_files": + var args struct { + PathPattern string `json:"path_pattern"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + return executeSearchFiles(ctx, validator, args.PathPattern) + + case "grep": + var args struct { + SearchTerm string `json:"search_term"` + Path string `json:"path"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + return executeGrep(ctx, validator, args.SearchTerm, args.Path) + + case "run_command": + var args struct { + Command string `json:"command"` + Args []string `json:"args"` + } + if err := json.Unmarshal(argsJSON, &args); err != nil { + return &tools.Result{Success: false, Error: "invalid args: " + err.Error()} + } + return executeRunCommand(ctx, validator, args.Command, args.Args) + + default: + return &tools.Result{Success: false, Error: fmt.Sprintf("unknown tool: %s", name)} + } +} + +func decodeToolArguments(raw ToolCallArguments) (json.RawMessage, error) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return json.RawMessage(`{}`), nil + } + if json.Valid([]byte(trimmed)) { + return json.RawMessage(trimmed), nil + } + return nil, fmt.Errorf("tool arguments are not valid JSON: %s", trimmed) +} + +func executeReadFile(ctx context.Context, v *tools.WorkspaceValidator, path string, startLine, endLine int) *tools.Result { + r, _ := v.ReadFile(ctx, path, startLine, endLine) + return r +} + +func executeWriteFile(ctx context.Context, v *tools.WorkspaceValidator, path string, startLine, endLine int, content string) *tools.Result { + r, _ := v.WriteFile(ctx, path, startLine, endLine, content) + return r +} + +func executePatchFile(ctx context.Context, v *tools.WorkspaceValidator, path string, startLine, endLine int, expectedText, content string) *tools.Result { + r, _ := v.PatchFile(ctx, path, startLine, endLine, expectedText, content) + return r +} + +func executeCreateDir(ctx context.Context, v *tools.WorkspaceValidator, path string) *tools.Result { + r, _ := v.CreateDirectory(ctx, path) + return r +} + +func executeDeleteFile(ctx context.Context, v *tools.WorkspaceValidator, path string) *tools.Result { + r, _ := v.DeleteFile(ctx, path) + return r +} + +func executeListDir(ctx context.Context, v *tools.WorkspaceValidator, path string) *tools.Result { + r, _ := v.ListDirectory(ctx, path) + return r +} + +func executeSearchFiles(ctx context.Context, v *tools.WorkspaceValidator, pattern string) *tools.Result { + r, _ := v.SearchFiles(ctx, pattern) + return r +} + +func executeGrep(ctx context.Context, v *tools.WorkspaceValidator, term, path string) *tools.Result { + r, _ := v.Grep(ctx, term, path) + return r +} + +func executeRunCommand(ctx context.Context, v *tools.WorkspaceValidator, cmd string, args []string) *tools.Result { + r, _ := v.RunCommand(ctx, cmd, args) + return r +} + +func main() { + overrides := parseCLIOverrides() + loadConfig() + configCopy := currentConfig() + applyCLIOverrides(&configCopy, overrides) + setConfig(configCopy) + if err := validateAppSettings(currentAppSettings()); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + initDB() + initAgents() + defer store.Close() + + http.Handle("/", http.FileServer(http.Dir("web"))) + + // List or create conversations + http.HandleFunc("/api/conversations", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST": + var req struct { + Title string `json:"title"` + GroupID string `json:"group_id"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&req) + } + if req.Title == "" { + req.Title = "New Conversation" + } + + var groupID *string + if req.GroupID != "" { + groupID = &req.GroupID + } + + conv, err := store.CreateConversation(req.Title, groupID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, conv) + + case "GET": + grouped, err := store.ListGroupedConversations() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, grouped) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Groups + http.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + groups, err := store.ListGroups() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, groups) + + case "POST": + var req struct { + Name string `json:"name"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&req) + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + group, err := store.CreateGroup(req.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, group) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Single group operations + http.HandleFunc("/api/groups/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/groups/") + if id == "" { + writeError(w, http.StatusBadRequest, "missing group id") + return + } + + switch r.Method { + case "GET": + group, err := store.GetGroup(id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if group == nil { + writeError(w, http.StatusNotFound, "group not found") + return + } + writeJSON(w, http.StatusOK, group) + + case "DELETE": + if err := store.DeleteGroup(id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + + case "PUT": + var req struct { + Name string `json:"name"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&req) + } + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + if err := store.UpdateGroupTitle(id, req.Name); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + + case "POST": + action := r.URL.Query().Get("action") + if action == "set_location" { + var req struct { + Location string `json:"location"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&req) + } + if req.Location == "" { + writeError(w, http.StatusBadRequest, "location is required") + return + } + if err := store.SetGroupLocation(id, req.Location); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"location": req.Location}) + return + } + if action == "get_location" { + group, err := store.GetGroup(id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if group == nil { + writeError(w, http.StatusNotFound, "group not found") + return + } + writeJSON(w, http.StatusOK, map[string]string{"location": group.Location}) + return + } + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Single conversation operations + http.HandleFunc("/api/conversations/", func(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/conversations/") + if id == "" { + writeError(w, http.StatusBadRequest, "missing conversation id") + return + } + + if strings.HasSuffix(r.URL.Path, "/messages") { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + id = strings.TrimSuffix(id, "/messages") + messages, err := store.GetMessages(id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, messages) + return + } + + switch r.Method { + case "GET": + conv, err := store.GetConversation(id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if conv == nil { + writeError(w, http.StatusNotFound, "conversation not found") + return + } + writeJSON(w, http.StatusOK, conv) + + case "DELETE": + if err := store.DeleteConversation(id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + + case "PUT": + var req struct { + Title string `json:"title"` + } + if r.Body != nil { + json.NewDecoder(r.Body).Decode(&req) + } + if req.Title == "" { + writeError(w, http.StatusBadRequest, "title is required") + return + } + if err := store.UpdateConversationTitle(id, req.Title); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // Runtime settings + http.HandleFunc("/api/settings", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "GET": + writeJSON(w, http.StatusOK, currentAppSettings()) + case "PUT": + var req AppSettings + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid settings payload") + return + } + if err := validateAppSettings(req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := updateAppSettings(req); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, currentAppSettings()) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + + // List available agents + http.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + type AgentInfo struct { + Type string `json:"type"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Icon string `json:"icon"` + RequiresWorkspace bool `json:"requires_workspace"` + } + info := make([]AgentInfo, 0, len(registry.All())) + for _, a := range registry.All() { + info = append(info, AgentInfo{ + Type: a.Type(), + DisplayName: a.DisplayName(), + Description: a.Description(), + Icon: a.Icon(), + RequiresWorkspace: a.RequiresWorkspace(), + }) + } + writeJSON(w, http.StatusOK, info) + }) + + // Stream message within a conversation + http.HandleFunc("/api/echo", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + ctx := r.Context() + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + var req struct { + Message string `json:"message"` + ConversationID string `json:"conversation_id"` + GroupID string `json:"group_id"` + AgentTypes []string `json:"agent_types"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "Bad request") + return + } + + convID := req.ConversationID + if convID == "" { + var groupID *string + if req.GroupID != "" { + groupID = &req.GroupID + } + conv, err := store.CreateConversation("New Conversation", groupID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + convID = conv.ID + } + + messages, err := store.GetMessages(convID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + conv, _ := store.GetConversation(convID) + + var activeAgents []string + for _, t := range req.AgentTypes { + a := registry.GetByType(t) + if a == nil { + continue + } + if a.ShouldRespond(req.Message) { + activeAgents = append(activeAgents, t) + } + } + systemMsgs := registry.GetSystemMessages(activeAgents) + + if conv != nil { + _ = maybeCompactConversationContext(ctx, conv, messages, systemMsgs, req.Message) + } + + promptHistory := messages + if conv != nil { + promptHistory = buildPromptHistory(messages, conv.ContextSummary, conv.ContextSummaryOrder) + } + + msgOrder := len(messages) + 1 + if err := store.AddMessage(convID, "user", req.Message, msgOrder, 0); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if conv != nil && conv.Title == "New Conversation" { + words := strings.Fields(req.Message) + newTitle := req.Message + if len(words) > 8 { + newTitle = strings.Join(words[:8], " ") + "..." + } + _ = store.UpdateConversationTitle(convID, newTitle) + } + + workspaceGroupID := resolveWorkspaceGroupID(conv, req.GroupID) + if conv != nil && (conv.GroupID == nil || strings.TrimSpace(*conv.GroupID) == "") && workspaceGroupID != "" { + if err := store.UpdateConversationGroup(convID, workspaceGroupID); err != nil { + log.Printf("Failed to attach conversation %s to group %s: %v", convID, workspaceGroupID, err) + } + } + + // Check if the coding manager agent is active and get workspace + var validator *tools.WorkspaceValidator + codingActive := false + for _, t := range activeAgents { + if t == "coding" { + codingActive = true + if workspaceGroupID != "" { + group, err := store.GetGroup(workspaceGroupID) + if err == nil && group != nil && group.Location != "" { + validator = tools.NewWorkspaceValidator(group.Location) + } + } + break + } + } + + // The coding manager cannot use tools without an assigned workspace. + if codingActive && validator == nil { + writeSSEEvent(w, conversationStreamEvent{ + Event: "conversation_message", + Kind: "error", + Content: "Error: manager agent requires a workspace folder. Select a workspace for this conversation and try again.", + }) + return + } + + // Handle function calling for the coding manager agent + if validator != nil { + _ = handleFunctionCalling(ctx, convID, req.Message, systemMsgs, validator, w, req.GroupID, promptHistory) + return + } + + // Normal streaming for non-manager agents + promptMessages := buildPromptMessages(systemMsgs, promptHistory, req.Message) + result := streamLMStudio(ctx, promptMessages, w) + + if result.err == nil { + nextOrder := msgOrder + 1 + reasoningText := strings.TrimSpace(result.reasoning) + answerText := strings.TrimSpace(result.fullResponse) + if reasoningText != "" { + if err := store.AddMessageWithMeta(convID, "assistant", reasoningText, nextOrder, "thinking", "", "", result.totalTokens); err != nil { + log.Printf("failed to persist reasoning message for conversation %s: %v", convID, err) + } + nextOrder++ + } + if answerText != "" { + totalTokens := result.totalTokens + if reasoningText != "" { + totalTokens = 0 + } + if err := store.AddMessage(convID, "assistant", answerText, nextOrder, totalTokens); err != nil { + log.Printf("failed to persist assistant message for conversation %s: %v", convID, err) + } + } + } + }) + + cfg := currentConfig() + fmt.Printf("Server starting at http://%s\n", serverDisplayAddress(cfg.Server)) + log.Fatal(http.ListenAndServe(cfg.Server.Addr, nil)) +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..01b576b --- /dev/null +++ b/main_test.go @@ -0,0 +1,614 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" + + "gocoder/agents" + "gocoder/db" +) + +func TestResolveWorkspaceGroupIDPrefersConversationGroup(t *testing.T) { + convGroupID := "conversation-group" + conv := &db.Conversation{GroupID: &convGroupID} + + got := resolveWorkspaceGroupID(conv, "request-group") + if got != convGroupID { + t.Fatalf("resolveWorkspaceGroupID returned %q, want %q", got, convGroupID) + } +} + +func TestResolveWorkspaceGroupIDFallsBackToRequestGroup(t *testing.T) { + got := resolveWorkspaceGroupID(nil, "request-group") + if got != "request-group" { + t.Fatalf("resolveWorkspaceGroupID returned %q, want %q", got, "request-group") + } +} + +func TestResolveWorkspaceGroupIDReturnsEmptyWhenUnset(t *testing.T) { + conv := &db.Conversation{} + + got := resolveWorkspaceGroupID(conv, "") + if got != "" { + t.Fatalf("resolveWorkspaceGroupID returned %q, want empty string", got) + } +} + +func TestFriendlyToolDisplayName(t *testing.T) { + cases := map[string]string{ + "planner_agent": "Planner", + "programmer_agent": "Programmer", + "qa_agent": "QA", + "write_file": "write_file", + } + + for input, want := range cases { + if got := friendlyToolDisplayName(input); got != want { + t.Fatalf("friendlyToolDisplayName(%q) = %q, want %q", input, got, want) + } + } +} + +func TestStageStatusKind(t *testing.T) { + cases := map[string]string{ + "Planner": "planner", + "Programmer": "programmer", + "QA": "qa", + "Manager": "manager", + "Other": "thinking", + } + + for input, want := range cases { + if got := stageStatusKind(input); got != want { + t.Fatalf("stageStatusKind(%q) = %q, want %q", input, got, want) + } + } +} + +func TestSubagentNoResponseError(t *testing.T) { + got := subagentNoResponseError("QA", "it kept calling tools but never produced a final answer before hitting the 20-loop limit") + want := "QA returned no response: it kept calling tools but never produced a final answer before hitting the 20-loop limit" + if got != want { + t.Fatalf("subagentNoResponseError returned %q, want %q", got, want) + } +} + +func TestSubagentNoResponseErrorDefaultsStageName(t *testing.T) { + got := subagentNoResponseError(" ", " ") + want := "Subagent returned no response" + if got != want { + t.Fatalf("subagentNoResponseError returned %q, want %q", got, want) + } +} + +func TestCollectThinkingAndContentPrefersExplicitReasoning(t *testing.T) { + thinking, content := collectThinkingAndContent(" plan ", "", "final answer") + if got, want := thinking, "plan"; got != want { + t.Fatalf("thinking = %q, want %q", got, want) + } + if got, want := content, "final answer"; got != want { + t.Fatalf("content = %q, want %q", got, want) + } +} + +func TestSplitThinkTagsFromContentSeparatesThinkingFromAnswer(t *testing.T) { + thinking, content := splitThinkTagsFromContent("before inner after") + if got, want := thinking, "inner"; got != want { + t.Fatalf("thinking = %q, want %q", got, want) + } + if got, want := content, "before after"; got != want { + t.Fatalf("content = %q, want %q", got, want) + } +} + +func TestFinalizeStreamedToolCallsOrdersAndConcatenatesArguments(t *testing.T) { + toolCalls := map[int]*streamedToolCall{} + appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{ + Index: 1, + ID: "call_2", + Type: "function", + Function: LMStudioStreamToolCallFunctionDelta{ + Name: "qa_agent", + Arguments: `{"task":"Check`, + }, + }) + appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{ + Index: 0, + ID: "call_1", + Type: "function", + Function: LMStudioStreamToolCallFunctionDelta{ + Name: "planner_agent", + Arguments: `{"task":"Plan"}`, + }, + }) + appendStreamedToolCall(toolCalls, LMStudioStreamToolCallDelta{ + Index: 1, + Function: LMStudioStreamToolCallFunctionDelta{ + Arguments: ` the result"}`, + }, + }) + + got := finalizeStreamedToolCalls(toolCalls) + if len(got) != 2 { + t.Fatalf("finalizeStreamedToolCalls returned %d calls, want 2", len(got)) + } + if got[0].ID != "call_1" || got[0].Function.Name != "planner_agent" { + t.Fatalf("first tool call = %+v, want planner_agent call_1", got[0]) + } + if string(got[0].Function.Arguments) != `{"task":"Plan"}` { + t.Fatalf("first tool call args = %s, want %s", string(got[0].Function.Arguments), `{"task":"Plan"}`) + } + if got[1].ID != "call_2" || got[1].Function.Name != "qa_agent" { + t.Fatalf("second tool call = %+v, want qa_agent call_2", got[1]) + } + if string(got[1].Function.Arguments) != `{"task":"Check the result"}` { + t.Fatalf("second tool call args = %s, want %s", string(got[1].Function.Arguments), `{"task":"Check the result"}`) + } +} + +func TestAPIMessagesKeepEmptyContentFields(t *testing.T) { + req := apiRequest{ + Model: "test-model", + Messages: []apiMessagePart{ + { + Role: "assistant", + Content: "", + ToolCalls: []ToolCallAI{ + { + ID: "call_1", + Type: "function", + Function: ToolCallFunction{ + Name: "planner_agent", + Arguments: ToolCallArguments(`{"task":"Plan"}`), + }, + }, + }, + }, + { + Role: "tool", + Content: "", + ToolCallID: "call_1", + Name: "planner_agent", + }, + }, + } + + data, err := json.Marshal(req) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + + var decoded struct { + Messages []struct { + Content string `json:"content"` + ToolCalls []struct { + Function struct { + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"messages"` + } + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if len(decoded.Messages) != 2 { + t.Fatalf("decoded %d messages, want 2", len(decoded.Messages)) + } + if got, want := decoded.Messages[0].Content, ""; got != want { + t.Fatalf("assistant message content = %q, want %q", got, want) + } + if len(decoded.Messages[0].ToolCalls) != 1 { + t.Fatalf("assistant message tool call count = %d, want 1", len(decoded.Messages[0].ToolCalls)) + } + if got, want := decoded.Messages[0].ToolCalls[0].Function.Arguments, `{"task":"Plan"}`; got != want { + t.Fatalf("assistant tool call arguments = %q, want %q", got, want) + } + if got, want := decoded.Messages[1].Content, ""; got != want { + t.Fatalf("tool message content = %q, want %q", got, want) + } +} + +func TestBuildPromptHistoryInjectsSummaryAndSkipsEarlierMessages(t *testing.T) { + history := []db.Message{ + {Order: 1, Role: "user", Kind: "message", Content: "old user"}, + {Order: 2, Role: "assistant", Kind: "message", Content: "old assistant"}, + {Order: 3, Role: "user", Kind: "message", Content: "new user"}, + } + + got := buildPromptHistory(history, "compressed memory", 2) + if len(got) != 2 { + t.Fatalf("buildPromptHistory returned %d messages, want 2", len(got)) + } + if got[0].Role != "system" || got[0].Content == "" { + t.Fatalf("buildPromptHistory did not inject a system summary message: %+v", got[0]) + } + if got[1].Content != "new user" { + t.Fatalf("buildPromptHistory kept wrong history tail: %+v", got[1]) + } +} + +func TestAgentRegistryIncludesCompactor(t *testing.T) { + registry := agents.NewAgentRegistry() + compactor := registry.GetByType("compactor") + if compactor == nil { + t.Fatal("expected compactor agent to be registered") + } + if got, want := compactor.DisplayName(), "Compactor"; got != want { + t.Fatalf("compactor display name = %q, want %q", got, want) + } +} + +func TestConfiguredCompactionRatiosDefaultToSafeValues(t *testing.T) { + originalTrigger := config.LLM.ContextCompactionTriggerRatio + originalTarget := config.LLM.ContextCompactionTargetRatio + t.Cleanup(func() { + config.LLM.ContextCompactionTriggerRatio = originalTrigger + config.LLM.ContextCompactionTargetRatio = originalTarget + }) + + config.LLM.ContextCompactionTriggerRatio = 0 + config.LLM.ContextCompactionTargetRatio = 0 + + if got, want := configuredCompactionTriggerRatio(), 0.80; got != want { + t.Fatalf("configuredCompactionTriggerRatio() = %v, want %v", got, want) + } + if got, want := configuredCompactionTargetRatio(), 0.70; got != want { + t.Fatalf("configuredCompactionTargetRatio() = %v, want %v", got, want) + } +} + +func TestDefaultConfigUsesBuiltInServerDefaults(t *testing.T) { + cfg := defaultConfig() + if got, want := cfg.Server.Addr, defaultServerAddr; got != want { + t.Fatalf("default server addr = %q, want %q", got, want) + } + if got, want := cfg.Server.Host, defaultServerHost; got != want { + t.Fatalf("default server host = %q, want %q", got, want) + } + if cfg.LLM.AutoOpenReasoning == nil || !*cfg.LLM.AutoOpenReasoning { + t.Fatalf("default auto_open_reasoning = %+v, want true", cfg.LLM.AutoOpenReasoning) + } +} + +func TestNormalizeConfigDefaultsAutoOpenReasoning(t *testing.T) { + cfg := Config{} + + normalizeConfig(&cfg) + + if cfg.LLM.AutoOpenReasoning == nil || !*cfg.LLM.AutoOpenReasoning { + t.Fatalf("normalizeConfig did not default auto_open_reasoning to true: %+v", cfg.LLM.AutoOpenReasoning) + } +} + +func TestValidateContextCompactionSettingsRejectsInvalidValues(t *testing.T) { + cases := []contextCompactionSettings{ + {ContextTokens: 0, ContextCompactionTriggerRatio: 0.8, ContextCompactionTargetRatio: 0.7}, + {ContextTokens: 100, ContextCompactionTriggerRatio: 1, ContextCompactionTargetRatio: 0.7}, + {ContextTokens: 100, ContextCompactionTriggerRatio: 0.8, ContextCompactionTargetRatio: 0.8}, + } + + for _, tc := range cases { + if err := validateContextCompactionSettings(tc); err == nil { + t.Fatalf("validateContextCompactionSettings(%+v) = nil, want error", tc) + } + } +} + +func TestValidateAppSettingsRejectsInvalidValues(t *testing.T) { + base := defaultConfig() + base.LLM.Provider = "openai" + base.LLM.BaseURL = "http://localhost:1234/v1" + base.LLM.Model = "qwen/qwen3.6-35b-a3b" + + cases := []struct { + name string + mutate func(*AppSettings) + }{ + {name: "missing server addr", mutate: func(s *AppSettings) { s.ServerAddr = "" }}, + {name: "missing server host", mutate: func(s *AppSettings) { s.ServerHost = "" }}, + {name: "missing provider", mutate: func(s *AppSettings) { s.Provider = "" }}, + } + + for _, tc := range cases { + settings := AppSettings{ + LLMConfig: base.LLM, + ServerAddr: base.Server.Addr, + ServerHost: base.Server.Host, + } + tc.mutate(&settings) + if err := validateAppSettings(settings); err == nil { + t.Fatalf("validateAppSettings(%s) = nil, want error", tc.name) + } + } +} + +func TestValidateLLMSettingsRejectsInvalidValues(t *testing.T) { + base := LLMConfig{ + Provider: "openai", + BaseURL: "http://localhost:1234/v1", + APIKey: "lm-studio", + Model: "qwen/qwen3.6-35b-a3b", + ContextTokens: 1024, + ContextCompactionTriggerRatio: 0.8, + ContextCompactionTargetRatio: 0.7, + Temperature: 0.3, + MaxTokens: 1024, + TopP: 1.0, + FrequencyPenalty: 0, + PresencePenalty: 0, + } + + cases := []struct { + name string + mutate func(*LLMConfig) + }{ + {name: "missing provider", mutate: func(cfg *LLMConfig) { cfg.Provider = "" }}, + {name: "missing base url", mutate: func(cfg *LLMConfig) { cfg.BaseURL = "" }}, + {name: "missing model", mutate: func(cfg *LLMConfig) { cfg.Model = "" }}, + {name: "invalid max tokens", mutate: func(cfg *LLMConfig) { cfg.MaxTokens = 0 }}, + {name: "invalid top p", mutate: func(cfg *LLMConfig) { cfg.TopP = 0 }}, + {name: "invalid context tokens", mutate: func(cfg *LLMConfig) { cfg.ContextTokens = 0 }}, + {name: "invalid trigger ratio", mutate: func(cfg *LLMConfig) { cfg.ContextCompactionTriggerRatio = 1 }}, + {name: "invalid target ratio", mutate: func(cfg *LLMConfig) { cfg.ContextCompactionTargetRatio = 0.8 }}, + {name: "invalid reasoning effort", mutate: func(cfg *LLMConfig) { cfg.ReasoningEffort = "ultra" }}, + } + + for _, tc := range cases { + cfg := base + tc.mutate(&cfg) + if err := validateLLMSettings(cfg); err == nil { + t.Fatalf("validateLLMSettings(%s) = nil, want error", tc.name) + } + } +} + +func TestValidateLLMSettingsAcceptsReasoningEffortValues(t *testing.T) { + base := LLMConfig{ + Provider: "openai", + BaseURL: "http://localhost:1234/v1", + APIKey: "lm-studio", + Model: "qwen/qwen3.6-35b-a3b", + ContextTokens: 1024, + ContextCompactionTriggerRatio: 0.8, + ContextCompactionTargetRatio: 0.7, + Temperature: 0.3, + MaxTokens: 1024, + TopP: 1.0, + FrequencyPenalty: 0, + PresencePenalty: 0, + } + + for _, effort := range []string{"", "low", "medium", "high", " HIGH "} { + cfg := base + cfg.ReasoningEffort = effort + if err := validateLLMSettings(cfg); err != nil { + t.Fatalf("validateLLMSettings(%q) returned error: %v", effort, err) + } + } +} + +func TestUpdateContextCompactionSettingsPersistsToDisk(t *testing.T) { + original := currentConfig() + t.Cleanup(func() { + setConfig(original) + }) + + tempDir := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd returned error: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(originalWD) + }) + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("os.Chdir returned error: %v", err) + } + + configCopy := defaultConfig() + configCopy.Server.Addr = ":9999" + configCopy.Server.Host = "localhost" + configCopy.LLM.ContextTokens = 512 + configCopy.LLM.ContextCompactionTriggerRatio = 0.8 + configCopy.LLM.ContextCompactionTargetRatio = 0.7 + setConfig(configCopy) + + settings := contextCompactionSettings{ + ContextTokens: 2048, + ContextCompactionTriggerRatio: 0.85, + ContextCompactionTargetRatio: 0.65, + } + if err := updateContextCompactionSettings(settings); err != nil { + t.Fatalf("updateContextCompactionSettings returned error: %v", err) + } + + updated := currentConfig() + if got, want := updated.LLM.ContextTokens, settings.ContextTokens; got != want { + t.Fatalf("updated context tokens = %d, want %d", got, want) + } + if got, want := updated.LLM.ContextCompactionTriggerRatio, settings.ContextCompactionTriggerRatio; got != want { + t.Fatalf("updated trigger ratio = %v, want %v", got, want) + } + if got, want := updated.LLM.ContextCompactionTargetRatio, settings.ContextCompactionTargetRatio; got != want { + t.Fatalf("updated target ratio = %v, want %v", got, want) + } + if got, want := updated.Server.Addr, configCopy.Server.Addr; got != want { + t.Fatalf("updated server addr = %q, want %q", got, want) + } + if got, want := updated.Server.Host, configCopy.Server.Host; got != want { + t.Fatalf("updated server host = %q, want %q", got, want) + } + + data, err := os.ReadFile(filepath.Join(tempDir, "config.json")) + if err != nil { + t.Fatalf("os.ReadFile returned error: %v", err) + } + if len(data) == 0 { + t.Fatal("config.json was not written") + } +} + +func TestUpdateLLMSettingsPersistsFullConfig(t *testing.T) { + original := currentConfig() + t.Cleanup(func() { + setConfig(original) + }) + + tempDir := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd returned error: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(originalWD) + }) + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("os.Chdir returned error: %v", err) + } + + configCopy := defaultConfig() + configCopy.Server.Addr = ":9999" + configCopy.Server.Host = "localhost" + configCopy.LLM = LLMConfig{ + Provider: "openai", + BaseURL: "http://localhost:4321/v1", + APIKey: "new-key", + Model: "test-model", + ContextTokens: 4096, + ContextCompactionTriggerRatio: 0.85, + ContextCompactionTargetRatio: 0.6, + Temperature: 0.2, + MaxTokens: 2048, + TopP: 0.95, + FrequencyPenalty: 0.25, + PresencePenalty: 0.1, + } + setConfig(configCopy) + + settings := LLMConfig{ + Provider: "anthropic", + BaseURL: "https://example.com/v1", + APIKey: "updated-key", + Model: "claude", + ReasoningEffort: "high", + AutoOpenReasoning: boolPtr(false), + ContextTokens: 8192, + ContextCompactionTriggerRatio: 0.9, + ContextCompactionTargetRatio: 0.7, + Temperature: 0.15, + MaxTokens: 4096, + TopP: 0.9, + FrequencyPenalty: 0.4, + PresencePenalty: 0.2, + } + + if err := updateLLMSettings(settings); err != nil { + t.Fatalf("updateLLMSettings returned error: %v", err) + } + + updated := currentConfig() + if got, want := updated.LLM, settings; !reflect.DeepEqual(got, want) { + t.Fatalf("updated LLM config = %+v, want %+v", got, want) + } + + data, err := os.ReadFile(filepath.Join(tempDir, "config.json")) + if err != nil { + t.Fatalf("os.ReadFile returned error: %v", err) + } + + var persisted Config + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if got, want := persisted.LLM, settings; !reflect.DeepEqual(got, want) { + t.Fatalf("persisted LLM config = %+v, want %+v", got, want) + } + if got, want := persisted.Server.Addr, configCopy.Server.Addr; got != want { + t.Fatalf("persisted server addr = %q, want %q", got, want) + } + if got, want := persisted.Server.Host, configCopy.Server.Host; got != want { + t.Fatalf("persisted server host = %q, want %q", got, want) + } +} + +func TestUpdateAppSettingsPersistsFullConfig(t *testing.T) { + original := currentConfig() + t.Cleanup(func() { + setConfig(original) + }) + + tempDir := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("os.Getwd returned error: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(originalWD) + }) + if err := os.Chdir(tempDir); err != nil { + t.Fatalf("os.Chdir returned error: %v", err) + } + + configCopy := defaultConfig() + configCopy.Server.Addr = ":9999" + configCopy.Server.Host = "localhost" + setConfig(configCopy) + + settings := AppSettings{ + LLMConfig: LLMConfig{ + Provider: "anthropic", + BaseURL: "https://example.com/v1", + APIKey: "updated-key", + Model: "claude", + ReasoningEffort: "low", + AutoOpenReasoning: boolPtr(false), + ContextTokens: 8192, + ContextCompactionTriggerRatio: 0.9, + ContextCompactionTargetRatio: 0.7, + Temperature: 0.15, + MaxTokens: 4096, + TopP: 0.9, + FrequencyPenalty: 0.4, + PresencePenalty: 0.2, + }, + ServerAddr: ":9090", + ServerHost: "127.0.0.1", + } + + if err := updateAppSettings(settings); err != nil { + t.Fatalf("updateAppSettings returned error: %v", err) + } + + updated := currentConfig() + if got, want := updated.LLM, settings.LLMConfig; !reflect.DeepEqual(got, want) { + t.Fatalf("updated LLM config = %+v, want %+v", got, want) + } + if got, want := updated.Server.Addr, settings.ServerAddr; got != want { + t.Fatalf("updated server addr = %q, want %q", got, want) + } + if got, want := updated.Server.Host, settings.ServerHost; got != want { + t.Fatalf("updated server host = %q, want %q", got, want) + } + + data, err := os.ReadFile(filepath.Join(tempDir, "config.json")) + if err != nil { + t.Fatalf("os.ReadFile returned error: %v", err) + } + + var persisted Config + if err := json.Unmarshal(data, &persisted); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if got, want := persisted.LLM, settings.LLMConfig; !reflect.DeepEqual(got, want) { + t.Fatalf("persisted LLM config = %+v, want %+v", got, want) + } + if got, want := persisted.Server.Addr, settings.ServerAddr; got != want { + t.Fatalf("persisted server addr = %q, want %q", got, want) + } + if got, want := persisted.Server.Host, settings.ServerHost; got != want { + t.Fatalf("persisted server host = %q, want %q", got, want) + } +} diff --git a/tools/tools.go b/tools/tools.go new file mode 100644 index 0000000..99931eb --- /dev/null +++ b/tools/tools.go @@ -0,0 +1,981 @@ +package tools + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +// Tool represents a function-calling tool exposed to an agent workflow. +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters Schema `json:"parameters"` +} + +// Schema defines the JSON schema for tool parameters. +type Schema struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Properties map[string]Schema `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` + Items *Schema `json:"items,omitempty"` +} + +// Result is the output from executing a tool. +type Result struct { + Content string `json:"content"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + TotalTokens int `json:"total_tokens,omitempty"` +} + +// WorkspaceValidator ensures operations stay within the allowed workspace. +type WorkspaceValidator struct { + workspacePath string +} + +func cancelledResult() (*Result, error) { + return &Result{Success: false, Error: "operation cancelled"}, nil +} + +func preferredShellPath() string { + shell := strings.TrimSpace(os.Getenv("SHELL")) + if shell != "" { + if strings.Contains(shell, string(os.PathSeparator)) { + return shell + } + if resolved, err := exec.LookPath(shell); err == nil { + return resolved + } + } + if runtime.GOOS == "darwin" { + return "/bin/zsh" + } + return "/bin/sh" +} + +func shellQuote(arg string) string { + if arg == "" { + return "''" + } + return "'" + strings.ReplaceAll(arg, "'", `'"'"'`) + "'" +} + +func buildShellCommand(command string, args []string) string { + parts := make([]string, 0, 1+len(args)) + if trimmed := strings.TrimSpace(command); trimmed != "" { + parts = append(parts, trimmed) + } + for _, arg := range args { + parts = append(parts, shellQuote(arg)) + } + return strings.TrimSpace(strings.Join(parts, " ")) +} + +// NewWorkspaceValidator creates a validator for the given workspace. +func NewWorkspaceValidator(workspacePath string) *WorkspaceValidator { + absPath, err := filepath.Abs(workspacePath) + if err != nil { + absPath = workspacePath + } + return &WorkspaceValidator{workspacePath: absPath} +} + +func (v *WorkspaceValidator) resolvePath(path string) (string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" || trimmed == "." { + return v.workspacePath, nil + } + + candidate := trimmed + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(v.workspacePath, candidate) + } + + absPath, err := filepath.Abs(candidate) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + cleaned := filepath.Clean(absPath) + if !strings.HasPrefix(cleaned, v.workspacePath) && cleaned != v.workspacePath { + return "", fmt.Errorf("access denied: path '%s' is outside workspace '%s'", path, v.workspacePath) + } + return cleaned, nil +} + +// Validate checks if a path is within the workspace and returns the resolved absolute path. +func (v *WorkspaceValidator) Validate(path string) (string, error) { + return v.resolvePath(path) +} + +// IsWithinWorkspace checks if a path is within the workspace. +func (v *WorkspaceValidator) IsWithinWorkspace(path string) bool { + _, err := v.resolvePath(path) + return err == nil +} + +func normalizeLineEndings(text string) string { + return strings.ReplaceAll(text, "\r\n", "\n") +} + +func splitLines(text string) []string { + text = normalizeLineEndings(text) + if text == "" { + return nil + } + + lines := strings.Split(text, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func formatNumberedLines(lines []string, startLine int) string { + if len(lines) == 0 { + return "" + } + + var builder strings.Builder + for i, line := range lines { + if i > 0 { + builder.WriteByte('\n') + } + fmt.Fprintf(&builder, "%d: %s", startLine+i, line) + } + return builder.String() +} + +func spliceLineRange(existing []string, startLine, endLine int, replacement []string) ([]string, error) { + if startLine < 1 { + return nil, fmt.Errorf("start_line must be >= 1") + } + if endLine < -1 { + return nil, fmt.Errorf("end_line must be -1 or >= 0") + } + + totalLines := len(existing) + if endLine == -1 { + endLine = totalLines + } + if endLine < startLine-1 { + return nil, fmt.Errorf("end_line must be -1 or at least start_line-1") + } + if startLine > totalLines+1 { + return nil, fmt.Errorf("start_line %d is beyond EOF; file has %d lines", startLine, totalLines) + } + if endLine > totalLines { + return nil, fmt.Errorf("end_line %d is beyond EOF; file has %d lines", endLine, totalLines) + } + + prefix := append([]string(nil), existing[:startLine-1]...) + suffix := append([]string(nil), existing[endLine:]...) + + nextLines := make([]string, 0, len(prefix)+len(replacement)+len(suffix)) + nextLines = append(nextLines, prefix...) + nextLines = append(nextLines, replacement...) + nextLines = append(nextLines, suffix...) + + return nextLines, nil +} + +func lineRangeLines(lines []string, startLine, endLine int) ([]string, error) { + if startLine < 1 { + return nil, fmt.Errorf("start_line must be >= 1") + } + if endLine < -1 { + return nil, fmt.Errorf("end_line must be -1 or >= 0") + } + + totalLines := len(lines) + if endLine == -1 { + endLine = totalLines + } + if endLine < startLine-1 { + return nil, fmt.Errorf("end_line must be -1 or at least start_line-1") + } + if startLine > totalLines+1 { + return nil, fmt.Errorf("start_line %d is beyond EOF; file has %d lines", startLine, totalLines) + } + if endLine > totalLines { + return nil, fmt.Errorf("end_line %d is beyond EOF; file has %d lines", endLine, totalLines) + } + if endLine < startLine-1 { + return []string{}, nil + } + if totalLines == 0 || endLine < startLine { + return []string{}, nil + } + + return append([]string(nil), lines[startLine-1:endLine]...), nil +} + +func findUniqueLineSequence(haystack, needle []string) (int, error) { + if len(needle) == 0 { + return 0, fmt.Errorf("expected_text cannot be empty") + } + if len(needle) > len(haystack) { + return 0, fmt.Errorf("expected text has %d lines, but the requested range only has %d lines", len(needle), len(haystack)) + } + + matchIndex := -1 + matchCount := 0 + for i := 0; i <= len(haystack)-len(needle); i++ { + matched := true + for j := range needle { + if haystack[i+j] != needle[j] { + matched = false + break + } + } + if matched { + matchCount++ + if matchIndex == -1 { + matchIndex = i + } + } + } + + switch { + case matchCount == 0: + return 0, fmt.Errorf("expected text was not found within the requested range") + case matchCount > 1: + return 0, fmt.Errorf("expected text matched %d locations within the requested range", matchCount) + default: + return matchIndex, nil + } +} + +func stripReadFileLinePrefix(line string) (string, bool) { + if line == "" { + return "", false + } + + i := 0 + for i < len(line) && line[i] >= '0' && line[i] <= '9' { + i++ + } + if i == 0 || i >= len(line) || line[i] != ':' { + return "", false + } + + rest := line[i+1:] + if strings.HasPrefix(rest, " ") { + rest = rest[1:] + } + return rest, true +} + +func normalizePatchExpectedLines(expectedText string) []string { + lines := splitLines(expectedText) + if len(lines) == 0 { + return nil + } + + if len(lines) >= 2 && strings.HasPrefix(lines[0], "File: ") && strings.HasPrefix(lines[1], "Lines ") && strings.Contains(lines[1], " of ") { + lines = lines[2:] + normalized := make([]string, len(lines)) + for i, line := range lines { + if stripped, ok := stripReadFileLinePrefix(line); ok { + normalized[i] = stripped + } else { + normalized[i] = line + } + } + return normalized + } + + normalized := make([]string, len(lines)) + allNumbered := true + for i, line := range lines { + stripped, ok := stripReadFileLinePrefix(line) + if !ok { + allNumbered = false + break + } + normalized[i] = stripped + } + if allNumbered { + return normalized + } + + return lines +} + +// --- File Tools --- + +// ReadFile reads a line range from a file within the workspace. +func (v *WorkspaceValidator) ReadFile(ctx context.Context, path string, startLine, endLine int) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + absPath, err := v.Validate(path) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + data, err := os.ReadFile(absPath) + if err != nil { + return &Result{Success: false, Error: fmt.Sprintf("failed to read file: %v", err)}, nil + } + + if startLine < 1 { + return &Result{Success: false, Error: "start_line must be >= 1"}, nil + } + + if endLine != -1 && endLine < 1 { + return &Result{Success: false, Error: "end_line must be -1 or >= 1"}, nil + } + if endLine != -1 && endLine < startLine { + return &Result{Success: false, Error: "end_line must be -1 or greater than or equal to start_line"}, nil + } + + lines := splitLines(string(data)) + totalLines := len(lines) + if totalLines == 0 { + return &Result{ + Content: fmt.Sprintf("File: %s\nFile is empty.", absPath), + Success: true, + }, nil + } + + actualEnd := totalLines + if endLine != -1 { + if endLine < totalLines { + actualEnd = endLine + } + } + + if startLine > totalLines { + displayEnd := "EOF" + if endLine != -1 { + displayEnd = fmt.Sprintf("%d", endLine) + } + return &Result{ + Content: fmt.Sprintf( + "File: %s\nRequested lines %d-%s are beyond EOF. File has %d lines.", + absPath, + startLine, + displayEnd, + totalLines, + ), + Success: true, + }, nil + } + + if actualEnd < startLine { + return &Result{ + Content: fmt.Sprintf( + "File: %s\nRequested lines %d-%d are empty. File has %d lines.", + absPath, + startLine, + actualEnd, + totalLines, + ), + Success: true, + }, nil + } + + selected := lines[startLine-1 : actualEnd] + displayEnd := fmt.Sprintf("%d", actualEnd) + if endLine == -1 && actualEnd == totalLines { + displayEnd = "EOF" + } + + return &Result{ + Content: fmt.Sprintf( + "File: %s\nLines %d-%s of %d:\n%s", + absPath, + startLine, + displayEnd, + totalLines, + formatNumberedLines(selected, startLine), + ), + Success: true, + }, nil +} + +// WriteFile replaces a line range in a file within the workspace, creating parent directories if needed. +func (v *WorkspaceValidator) WriteFile(ctx context.Context, path string, startLine, endLine int, content string) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + absPath, err := v.Validate(path) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + dir := filepath.Dir(absPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return &Result{Success: false, Error: fmt.Sprintf("failed to create directories: %v", err)}, nil + } + + existingData, readErr := os.ReadFile(absPath) + if readErr != nil && !os.IsNotExist(readErr) { + return &Result{Success: false, Error: fmt.Sprintf("failed to read existing file: %v", readErr)}, nil + } + + existingLines := splitLines(string(existingData)) + replacementLines := splitLines(content) + originalEndLine := endLine + + nextLines, err := spliceLineRange(existingLines, startLine, endLine, replacementLines) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + nextContent := strings.Join(nextLines, "\n") + if err := os.WriteFile(absPath, []byte(nextContent), 0644); err != nil { + return &Result{Success: false, Error: fmt.Sprintf("failed to write file: %v", err)}, nil + } + + action := "replaced" + if os.IsNotExist(readErr) || (len(existingLines) == 0 && startLine == 1 && originalEndLine <= 0) { + action = "created" + } else if originalEndLine < startLine { + action = "inserted" + } + + replacedCount := 0 + if endLine == -1 { + replacedCount = len(existingLines) - startLine + 1 + if replacedCount < 0 { + replacedCount = 0 + } + } else if endLine >= startLine { + replacedCount = endLine - startLine + 1 + } + + message := fmt.Sprintf("Successfully %s %s", action, absPath) + if action == "inserted" { + message = fmt.Sprintf("Successfully inserted %d lines before line %d in %s", len(replacementLines), startLine, absPath) + } else if action == "created" { + message = fmt.Sprintf("Successfully created %s with %d lines", absPath, len(nextLines)) + } else { + message = fmt.Sprintf("Successfully replaced %d lines starting at line %d in %s", replacedCount, startLine, absPath) + } + + return &Result{Content: message, Success: true}, nil +} + +// PatchFile replaces the unique snippet matching the expected text within a requested line window. +func (v *WorkspaceValidator) PatchFile(ctx context.Context, path string, startLine, endLine int, expectedText, content string) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + absPath, err := v.Validate(path) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + dir := filepath.Dir(absPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return &Result{Success: false, Error: fmt.Sprintf("failed to create directories: %v", err)}, nil + } + + existingData, readErr := os.ReadFile(absPath) + if readErr != nil && !os.IsNotExist(readErr) { + return &Result{Success: false, Error: fmt.Sprintf("failed to read existing file: %v", readErr)}, nil + } + + existingLines := splitLines(string(existingData)) + windowLines, err := lineRangeLines(existingLines, startLine, endLine) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + expectedLines := normalizePatchExpectedLines(expectedText) + matchOffset, err := findUniqueLineSequence(windowLines, expectedLines) + if err != nil { + displayEnd := "EOF" + if endLine != -1 { + displayEnd = fmt.Sprintf("%d", endLine) + } + return &Result{ + Success: false, + Error: fmt.Sprintf( + "patch guard failed for requested range %d-%s: %v. Re-read the exact snippet and include a little more context if needed.", + startLine, + displayEnd, + err, + ), + }, nil + } + + actualStartLine := startLine + matchOffset + actualEndLine := actualStartLine + len(expectedLines) - 1 + + replacementLines := splitLines(content) + nextLines, err := spliceLineRange(existingLines, actualStartLine, actualEndLine, replacementLines) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + nextContent := strings.Join(nextLines, "\n") + if err := os.WriteFile(absPath, []byte(nextContent), 0644); err != nil { + return &Result{Success: false, Error: fmt.Sprintf("failed to write file: %v", err)}, nil + } + + return &Result{ + Content: fmt.Sprintf("Successfully patched lines %d-%d in %s", actualStartLine, actualEndLine, absPath), + Success: true, + }, nil +} + +// CreateDirectory creates a directory within the workspace. +func (v *WorkspaceValidator) CreateDirectory(ctx context.Context, path string) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + absPath, err := v.Validate(path) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + if err := os.MkdirAll(absPath, 0755); err != nil { + return &Result{Success: false, Error: fmt.Sprintf("failed to create directory: %v", err)}, nil + } + + return &Result{Content: fmt.Sprintf("Successfully created directory: %s", absPath), Success: true}, nil +} + +// DeleteFile removes a file within the workspace. +func (v *WorkspaceValidator) DeleteFile(ctx context.Context, path string) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + absPath, err := v.Validate(path) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + if err := os.Remove(absPath); err != nil { + if os.IsNotExist(err) { + return &Result{Success: false, Error: fmt.Sprintf("file not found: %s", path)}, nil + } + return &Result{Success: false, Error: fmt.Sprintf("failed to delete file: %v", err)}, nil + } + + return &Result{Content: fmt.Sprintf("Successfully deleted: %s", absPath), Success: true}, nil +} + +// ListDirectory lists files and directories within the workspace. +func (v *WorkspaceValidator) ListDirectory(ctx context.Context, path string) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + absPath, err := v.Validate(path) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + entries, err := os.ReadDir(absPath) + if err != nil { + return &Result{Success: false, Error: fmt.Sprintf("failed to read directory: %v", err)}, nil + } + + var lines []string + lines = append(lines, fmt.Sprintf("Contents of %s:", absPath)) + + for _, entry := range entries { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + mode := entry.Type() + var prefix string + if mode.IsDir() { + prefix = "D " + } else { + prefix = "F " + } + + info, err := entry.Info() + size := "0" + if err == nil && info != nil { + size = fmt.Sprintf("%d", info.Size()) + } + + name := entry.Name() + if mode.IsDir() { + name += "/" + } + lines = append(lines, fmt.Sprintf("%s %8s %s", prefix, size, name)) + } + + return &Result{Content: strings.Join(lines, "\n")}, nil +} + +// --- Search Tools --- + +// SearchFiles searches for files matching a pattern within the workspace. +func (v *WorkspaceValidator) SearchFiles(ctx context.Context, pathPattern string) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + // Validate the base path + baseDir := filepath.Dir(pathPattern) + if baseDir != "." && baseDir != "/" { + if _, err := v.Validate(baseDir); err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + } + + var matches []string + pattern := pathPattern + + err := filepath.Walk(v.workspacePath, func(currentPath string, info os.FileInfo, err error) error { + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + relPath, _ := filepath.Rel(v.workspacePath, currentPath) + if matched, _ := filepath.Match(pattern, relPath); matched { + matches = append(matches, relPath) + return nil + } + if matched, _ := filepath.Match(pattern, filepath.Base(relPath)); matched { + matches = append(matches, relPath) + } + return nil + }) + + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + if err != nil { + if errors.Is(err, context.Canceled) { + return cancelledResult() + } + return &Result{Success: false, Error: fmt.Sprintf("search failed: %v", err)}, nil + } + + if len(matches) == 0 { + return &Result{Content: "No files found matching pattern.", Success: true}, nil + } + + var output []string + output = append(output, fmt.Sprintf("Found %d files matching '%s':", len(matches), pattern)) + for _, m := range matches { + output = append(output, " "+m) + } + + return &Result{Content: strings.Join(output, "\n")}, nil +} + +// Grep searches for text content within files in the workspace. +func (v *WorkspaceValidator) Grep(ctx context.Context, searchTerm string, path string) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + absPath, err := v.Validate(path) + if err != nil { + return &Result{Success: false, Error: err.Error()}, nil + } + + var results []string + count := 0 + + err = filepath.Walk(absPath, func(currentPath string, info os.FileInfo, err error) error { + if ctx != nil && ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + if count >= 100 { + return fmt.Errorf("max results reached") + } + + data, err := os.ReadFile(currentPath) + if err != nil { + return nil + } + + relPath, _ := filepath.Rel(v.workspacePath, currentPath) + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if strings.Contains(line, searchTerm) { + results = append(results, fmt.Sprintf("%s:%d: %s", relPath, i+1, strings.TrimSpace(line))) + count++ + } + } + return nil + }) + + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + if count >= 100 { + results = append(results, "... (truncated at 100 results)") + } + + if len(results) == 0 { + return &Result{Content: fmt.Sprintf("No matches for '%s' in %s", searchTerm, path)}, nil + } + + return &Result{Content: strings.Join(results, "\n")}, nil +} + +// --- Shell Tools --- + +// RunCommand executes a shell command within the workspace. +func (v *WorkspaceValidator) RunCommand(ctx context.Context, command string, args []string) (*Result, error) { + if ctx != nil && ctx.Err() != nil { + return cancelledResult() + } + shellCommand := buildShellCommand(command, args) + if shellCommand == "" { + return &Result{Success: false, Error: "command is required"}, nil + } + + // Check for dangerous commands + dangerousCmds := []string{"rm -rf /", "rm -rf *", "dd if=", "mkfs", "fdisk", "> /dev/"} + for _, d := range dangerousCmds { + if strings.Contains(strings.ToLower(shellCommand), d) { + return &Result{Success: false, Error: fmt.Sprintf("dangerous command blocked: %s", shellCommand)}, nil + } + } + + if ctx == nil { + ctx = context.Background() + } + shellPath := preferredShellPath() + shellArgs := []string{"-lc", shellCommand} + switch filepath.Base(shellPath) { + case "zsh", "bash": + shellArgs[0] = "-lic" + } + cmd := exec.CommandContext(ctx, shellPath, shellArgs...) + cmd.Dir = v.workspacePath + + output, err := cmd.CombinedOutput() + outputStr := string(output) + + if err != nil { + if ctx.Err() != nil { + return cancelledResult() + } + exitCode := -1 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } + return &Result{ + Content: outputStr, + Success: false, + Error: fmt.Sprintf("command failed (exit %d): %v", exitCode, err), + }, nil + } + + // Truncate output + maxLen := 50000 + if len(outputStr) > maxLen { + outputStr = outputStr[:maxLen] + "\n... [output truncated]" + } + + return &Result{Content: outputStr, Success: true}, nil +} + +// GetWorkspacePath returns the workspace path. +func (v *WorkspaceValidator) GetWorkspacePath() string { + return v.workspacePath +} + +// --- Tool Definitions --- + +// AvailableTools returns the list of workspace tool definitions for function calling. +func AvailableTools(workspacePath string) []Tool { + tools := []Tool{ + { + Name: "read_file", + Description: "Read a line range from a file in the workspace. Use start_line=1 and end_line=-1 to read the full file. The output includes line numbers and a header, and it can be pasted directly into patch_file as expected_text for the same snippet or range.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "path": { + Type: "string", + Description: "The path of the file to read (relative to workspace)", + }, + "start_line": { + Type: "integer", + Description: "The first line to read, starting at 1. Defaults to 1.", + }, + "end_line": { + Type: "integer", + Description: "The last line to read, inclusive. Use -1 to read through EOF. Defaults to -1.", + }, + }, + Required: []string{"path"}, + }, + }, + { + Name: "patch_file", + Description: "Guarded snippet replacement within a line window. Like write_file, but only applies if expected_text matches one unique contiguous snippet inside the requested range. expected_text can be raw file text or the line-numbered output from read_file. Use this for surgical edits to avoid clobbering concurrent changes.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "path": { + Type: "string", + Description: "The path of the file to patch (relative to workspace)", + }, + "start_line": { + Type: "integer", + Description: "The first line of the search window, starting at 1. Defaults to 1.", + }, + "end_line": { + Type: "integer", + Description: "The last line of the search window, inclusive. Use -1 to search through EOF.", + }, + "expected_text": { + Type: "string", + Description: "The current text to match. This can be raw file text or the output from read_file for a matching snippet within the requested window.", + }, + "content": { + Type: "string", + Description: "The replacement text for the requested line range", + }, + }, + Required: []string{"path", "expected_text", "content"}, + }, + }, + { + Name: "write_file", + Description: "Replace a line range in a file. Use start_line=1 and end_line=-1 to replace the full file or create a new file. Use end_line=start_line-1 to insert content before a line, such as start_line=1 and end_line=0 to prepend to an existing file. Prefer patch_file when you want to guard against stale content.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "path": { + Type: "string", + Description: "The path of the file to write (relative to workspace)", + }, + "start_line": { + Type: "integer", + Description: "The first line to replace, starting at 1. Defaults to 1.", + }, + "end_line": { + Type: "integer", + Description: "The last line to replace, inclusive. Use -1 to replace through EOF. Use start_line-1 to insert before start_line.", + }, + "content": { + Type: "string", + Description: "The replacement text for the requested line range", + }, + }, + Required: []string{"path", "content"}, + }, + }, + { + Name: "create_directory", + Description: "Create a new directory or get an existing one within the workspace.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "path": { + Type: "string", + Description: "The path of the directory to create (relative to workspace)", + }, + }, + Required: []string{"path"}, + }, + }, + { + Name: "delete_file", + Description: "Delete a file from the workspace.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "path": { + Type: "string", + Description: "The path of the file to delete (relative to workspace)", + }, + }, + Required: []string{"path"}, + }, + }, + { + Name: "list_directory", + Description: "List the contents of a directory within the workspace. Shows file sizes and types.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "path": { + Type: "string", + Description: "The path of the directory to list (relative to workspace, defaults to workspace root if '.' or empty)", + }, + }, + Required: []string{"path"}, + }, + }, + { + Name: "search_files", + Description: "Search for files by name pattern within the workspace directory tree.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "path_pattern": { + Type: "string", + Description: "The glob pattern to match (e.g., '*.go', 'src/**/*.ts')", + }, + }, + Required: []string{"path_pattern"}, + }, + }, + { + Name: "grep", + Description: "Search for text content within files in the workspace. Limited to 100 results.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "search_term": { + Type: "string", + Description: "The text pattern to search for", + }, + "path": { + Type: "string", + Description: "The directory to search in (relative to workspace)", + }, + }, + Required: []string{"search_term", "path"}, + }, + }, + { + Name: "run_command", + Description: "Execute a shell command through the user's login shell within the workspace directory. Shell syntax, pipes, redirects, and installed tools available on PATH are supported.", + Parameters: Schema{ + Type: "object", + Properties: map[string]Schema{ + "command": { + Type: "string", + Description: "The command to execute (e.g., 'ls', 'cat', 'go build', 'npm test')", + }, + "args": { + Type: "array", + Items: &Schema{ + Type: "string", + }, + Description: "Arguments for the command", + }, + }, + Required: []string{"command"}, + }, + }, + } + + _ = workspacePath + return tools +} diff --git a/tools/tools_test.go b/tools/tools_test.go new file mode 100644 index 0000000..cd0ac96 --- /dev/null +++ b/tools/tools_test.go @@ -0,0 +1,356 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeMockShell(t *testing.T, dir string) string { + t.Helper() + + shellPath := filepath.Join(dir, "mock-shell") + script := `#!/bin/sh +set -eu +printf '%s\n' "$1" > shell-flag.txt +printf '%s\n' "$2" > shell-command.txt +shift +eval "$1" +` + if err := os.WriteFile(shellPath, []byte(script), 0755); err != nil { + t.Fatalf("failed to write mock shell: %v", err) + } + return shellPath +} + +func TestValidateResolvesRelativePathAgainstWorkspace(t *testing.T) { + workspace := t.TempDir() + validator := NewWorkspaceValidator(workspace) + + got, err := validator.Validate("src/main.go") + if err != nil { + t.Fatalf("Validate returned error: %v", err) + } + + want := filepath.Join(workspace, "src/main.go") + if got != want { + t.Fatalf("Validate returned %q, want %q", got, want) + } +} + +func TestValidateRejectsTraversalOutsideWorkspace(t *testing.T) { + workspace := t.TempDir() + validator := NewWorkspaceValidator(workspace) + + if _, err := validator.Validate("../outside.go"); err == nil { + t.Fatal("expected Validate to reject path traversal outside the workspace") + } +} + +func TestReadFileReturnsRequestedLineRange(t *testing.T) { + workspace := t.TempDir() + path := filepath.Join(workspace, "sample.txt") + if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\ndelta\n"), 0644); err != nil { + t.Fatalf("failed to write sample file: %v", err) + } + + validator := NewWorkspaceValidator(workspace) + result, err := validator.ReadFile(context.Background(), "sample.txt", 2, 3) + if err != nil { + t.Fatalf("ReadFile returned error: %v", err) + } + if !result.Success { + t.Fatalf("ReadFile returned unsuccessful result: %+v", result) + } + + if !strings.Contains(result.Content, "Lines 2-3 of 4:") { + t.Fatalf("ReadFile content did not include requested range: %q", result.Content) + } + if !strings.Contains(result.Content, "2: beta") || !strings.Contains(result.Content, "3: gamma") { + t.Fatalf("ReadFile content did not include expected lines: %q", result.Content) + } + if strings.Contains(result.Content, "1: alpha") || strings.Contains(result.Content, "4: delta") { + t.Fatalf("ReadFile content included lines outside the requested range: %q", result.Content) + } +} + +func TestWriteFileReplacesRangePrependsAndCreates(t *testing.T) { + workspace := t.TempDir() + validator := NewWorkspaceValidator(workspace) + ctx := context.Background() + + t.Run("replace middle range", func(t *testing.T) { + path := filepath.Join(workspace, "sample.txt") + if err := os.WriteFile(path, []byte("one\ntwo\nthree\nfour\n"), 0644); err != nil { + t.Fatalf("failed to write sample file: %v", err) + } + + result, err := validator.WriteFile(ctx, "sample.txt", 2, 3, "TWO\nTHREE") + if err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + if !result.Success { + t.Fatalf("WriteFile returned unsuccessful result: %+v", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read updated file: %v", err) + } + want := "one\nTWO\nTHREE\nfour" + if string(got) != want { + t.Fatalf("WriteFile wrote %q, want %q", string(got), want) + } + }) + + t.Run("prepend at start", func(t *testing.T) { + path := filepath.Join(workspace, "prepend.txt") + if err := os.WriteFile(path, []byte("body\n"), 0644); err != nil { + t.Fatalf("failed to write sample file: %v", err) + } + + result, err := validator.WriteFile(ctx, "prepend.txt", 1, 0, "header") + if err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + if !result.Success { + t.Fatalf("WriteFile returned unsuccessful result: %+v", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read updated file: %v", err) + } + want := "header\nbody" + if string(got) != want { + t.Fatalf("WriteFile wrote %q, want %q", string(got), want) + } + }) + + t.Run("create new file", func(t *testing.T) { + path := filepath.Join(workspace, "created.txt") + + result, err := validator.WriteFile(ctx, "created.txt", 1, -1, "first\nsecond") + if err != nil { + t.Fatalf("WriteFile returned error: %v", err) + } + if !result.Success { + t.Fatalf("WriteFile returned unsuccessful result: %+v", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read created file: %v", err) + } + want := "first\nsecond" + if string(got) != want { + t.Fatalf("WriteFile wrote %q, want %q", string(got), want) + } + }) +} + +func TestRunCommandExecutesThroughShell(t *testing.T) { + workspace := t.TempDir() + shellDir := t.TempDir() + t.Setenv("SHELL", writeMockShell(t, shellDir)) + + validator := NewWorkspaceValidator(workspace) + result, err := validator.RunCommand(context.Background(), "echo hello from shell", nil) + if err != nil { + t.Fatalf("RunCommand returned error: %v", err) + } + if !result.Success { + t.Fatalf("RunCommand returned unsuccessful result: %+v", result) + } + if got, want := strings.TrimSpace(result.Content), "hello from shell"; got != want { + t.Fatalf("RunCommand output = %q, want %q", got, want) + } + + commandBytes, err := os.ReadFile(filepath.Join(workspace, "shell-command.txt")) + if err != nil { + t.Fatalf("failed to read shell command capture: %v", err) + } + if got, want := strings.TrimSpace(string(commandBytes)), "echo hello from shell"; got != want { + t.Fatalf("shell command capture = %q, want %q", got, want) + } +} + +func TestRunCommandQuotesArgsInShellCommand(t *testing.T) { + workspace := t.TempDir() + shellDir := t.TempDir() + t.Setenv("SHELL", writeMockShell(t, shellDir)) + + validator := NewWorkspaceValidator(workspace) + result, err := validator.RunCommand(context.Background(), "printf", []string{"%s", "hello world"}) + if err != nil { + t.Fatalf("RunCommand returned error: %v", err) + } + if !result.Success { + t.Fatalf("RunCommand returned unsuccessful result: %+v", result) + } + if got, want := strings.TrimSpace(result.Content), "hello world"; got != want { + t.Fatalf("RunCommand output = %q, want %q", got, want) + } + + commandBytes, err := os.ReadFile(filepath.Join(workspace, "shell-command.txt")) + if err != nil { + t.Fatalf("failed to read shell command capture: %v", err) + } + commandLine := strings.TrimSpace(string(commandBytes)) + if !strings.Contains(commandLine, "'hello world'") { + t.Fatalf("shell command capture did not quote spaced argument: %q", commandLine) + } +} + +func TestRunCommandRunsInstalledToolThroughShellPath(t *testing.T) { + workspace := t.TempDir() + validator := NewWorkspaceValidator(workspace) + + result, err := validator.RunCommand(context.Background(), "go version", nil) + if err != nil { + t.Fatalf("RunCommand returned error: %v", err) + } + if !result.Success { + t.Fatalf("RunCommand returned unsuccessful result: %+v", result) + } + if !strings.Contains(result.Content, "go version") { + t.Fatalf("RunCommand output did not include go version information: %q", result.Content) + } +} + +func TestPatchFileGuardsAgainstStaleRanges(t *testing.T) { + workspace := t.TempDir() + validator := NewWorkspaceValidator(workspace) + ctx := context.Background() + + t.Run("applies when expected text matches", func(t *testing.T) { + path := filepath.Join(workspace, "guarded.txt") + if err := os.WriteFile(path, []byte("one\ntwo\nthree\n"), 0644); err != nil { + t.Fatalf("failed to write sample file: %v", err) + } + + result, err := validator.PatchFile(ctx, "guarded.txt", 2, 2, "two", "TWO") + if err != nil { + t.Fatalf("PatchFile returned error: %v", err) + } + if !result.Success { + t.Fatalf("PatchFile returned unsuccessful result: %+v", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read patched file: %v", err) + } + want := "one\nTWO\nthree" + if string(got) != want { + t.Fatalf("PatchFile wrote %q, want %q", string(got), want) + } + }) + + t.Run("matches a snippet inside a larger requested window", func(t *testing.T) { + path := filepath.Join(workspace, "snippet.txt") + original := "alpha\nbeta\n\n\nneedle\nomega\n" + if err := os.WriteFile(path, []byte(original), 0644); err != nil { + t.Fatalf("failed to write sample file: %v", err) + } + + result, err := validator.PatchFile(ctx, "snippet.txt", 1, -1, "\n\nneedle", "\n\nreplacement") + if err != nil { + t.Fatalf("PatchFile returned error: %v", err) + } + if !result.Success { + t.Fatalf("PatchFile returned unsuccessful result: %+v", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read patched file: %v", err) + } + want := "alpha\nbeta\n\n\nreplacement\nomega" + if string(got) != want { + t.Fatalf("PatchFile wrote %q, want %q", string(got), want) + } + }) + + t.Run("accepts read_file output as expected text", func(t *testing.T) { + path := filepath.Join(workspace, "readfile.txt") + if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0644); err != nil { + t.Fatalf("failed to write sample file: %v", err) + } + + readResult, err := validator.ReadFile(ctx, "readfile.txt", 1, 2) + if err != nil { + t.Fatalf("ReadFile returned error: %v", err) + } + + result, err := validator.PatchFile(ctx, "readfile.txt", 1, 2, readResult.Content, "ALPHA\nBETA") + if err != nil { + t.Fatalf("PatchFile returned error: %v", err) + } + if !result.Success { + t.Fatalf("PatchFile returned unsuccessful result: %+v", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read patched file: %v", err) + } + want := "ALPHA\nBETA\ngamma" + if string(got) != want { + t.Fatalf("PatchFile wrote %q, want %q", string(got), want) + } + }) + + t.Run("matches blank lines exactly", func(t *testing.T) { + path := filepath.Join(workspace, "blank.txt") + if err := os.WriteFile(path, []byte("alpha\n\nomega\n"), 0644); err != nil { + t.Fatalf("failed to write sample file: %v", err) + } + + result, err := validator.PatchFile(ctx, "blank.txt", 2, 2, "\n", "BETA") + if err != nil { + t.Fatalf("PatchFile returned error: %v", err) + } + if !result.Success { + t.Fatalf("PatchFile returned unsuccessful result: %+v", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read patched file: %v", err) + } + want := "alpha\nBETA\nomega" + if string(got) != want { + t.Fatalf("PatchFile wrote %q, want %q", string(got), want) + } + }) + + t.Run("rejects stale content", func(t *testing.T) { + path := filepath.Join(workspace, "stale.txt") + if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0644); err != nil { + t.Fatalf("failed to write sample file: %v", err) + } + + result, err := validator.PatchFile(ctx, "stale.txt", 2, 2, "gamma", "BETA") + if err != nil { + t.Fatalf("PatchFile returned error: %v", err) + } + if result.Success { + t.Fatalf("expected guarded patch to fail, got success: %+v", result) + } + if !strings.Contains(result.Error, "patch guard failed") { + t.Fatalf("unexpected guarded patch error: %+v", result) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read unchanged file: %v", err) + } + want := "alpha\nbeta\n" + if string(got) != want { + t.Fatalf("PatchFile changed file content unexpectedly: %q", string(got)) + } + }) +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..1c2375b --- /dev/null +++ b/web/app.js @@ -0,0 +1,2252 @@ +let currentConversationId = null; +let isStreaming = false; +let abortController = null; +let currentGroupFilter = null; +let groups = []; +let expandedGroups = new Set(); +let groupLocations = {}; +let currentSettingsGroupId = null; +let selectedGroupId = null; +let agents = []; +let selectedAgent = "none"; +let llmSettings = null; +let activeStreamUi = null; +let cancelRequested = false; +const conversationRenderState = { + pendingToolBubble: null, +}; +const messageInputMaxLines = 12; + +function generateId() { + return Date.now().toString(36) + Math.random().toString(36).substr(2, 9); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatElapsed(ms) { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, '0')}`; +} + +function formatStepLabel(step, total) { + if (!Number.isFinite(step) || step < 1) { + return ''; + } + if (Number.isFinite(total) && total > 0) { + return `Step ${step} of ${total}`; + } + return `Step ${step}`; +} + +function formatTokenCount(tokens) { + const value = Number(tokens); + if (!Number.isFinite(value) || value <= 0) { + return ''; + } + return `${value.toLocaleString('en-US')} ${value === 1 ? 'token' : 'tokens'}`; +} + +function setSettingsError(message) { + const errorEl = document.getElementById('settingsFormError'); + if (!errorEl) return; + if (message) { + errorEl.textContent = message; + errorEl.classList.remove('hidden'); + } else { + errorEl.textContent = ''; + errorEl.classList.add('hidden'); + } +} + +function normalizeRuntimeSettings(settings) { + if (!settings) return settings; + return { + ...settings, + reasoning_effort: settings.reasoning_effort ?? '', + auto_open_reasoning: settings.auto_open_reasoning !== false, + }; +} + +function reasoningAutoOpenEnabled() { + return !llmSettings || llmSettings.auto_open_reasoning !== false; +} + +function populateSettingsForm(settings) { + if (!settings) return; + const normalized = normalizeRuntimeSettings(settings); + const fieldMap = { + settingsProvider: normalized.provider ?? '', + settingsBaseURL: normalized.base_url ?? '', + settingsAPIKey: normalized.api_key ?? '', + settingsModel: normalized.model ?? '', + settingsTemperature: normalized.temperature ?? '', + settingsMaxTokens: normalized.max_tokens ?? '', + settingsTopP: normalized.top_p ?? '', + settingsFrequencyPenalty: normalized.frequency_penalty ?? '', + settingsPresencePenalty: normalized.presence_penalty ?? '', + settingsServerHost: normalized.server_host ?? '', + settingsServerAddr: normalized.server_addr ?? '', + settingsContextTokens: normalized.context_tokens ?? '', + settingsTriggerRatio: normalized.context_compaction_trigger_ratio ?? '', + settingsTargetRatio: normalized.context_compaction_target_ratio ?? '', + }; + for (const [id, value] of Object.entries(fieldMap)) { + const input = document.getElementById(id); + if (input) { + input.value = value; + } + } + const reasoningEffortInput = document.getElementById('settingsReasoningEffort'); + if (reasoningEffortInput) { + reasoningEffortInput.value = normalized.reasoning_effort ?? ''; + } + const autoOpenReasoningInput = document.getElementById('settingsAutoOpenReasoning'); + if (autoOpenReasoningInput) { + autoOpenReasoningInput.checked = normalized.auto_open_reasoning !== false; + } +} + +async function loadLLMSettings() { + const res = await fetch('/api/settings'); + if (!res.ok) { + throw new Error('Failed to load runtime settings'); + } + llmSettings = normalizeRuntimeSettings(await res.json()); + return llmSettings; +} + +async function openLLMSettingsModal() { + try { + const settings = await loadLLMSettings(); + populateSettingsForm(settings); + setSettingsError(''); + const modal = document.getElementById('settingsModal'); + modal.classList.remove('hidden'); + modal.classList.add('flex'); + const input = document.getElementById('settingsProvider'); + setTimeout(() => input.focus(), 50); + } catch (err) { + console.error('Failed to load runtime settings:', err); + setSettingsError('Unable to load settings right now.'); + } +} + +function closeLLMSettingsModal() { + const modal = document.getElementById('settingsModal'); + modal.classList.add('hidden'); + modal.classList.remove('flex'); + setSettingsError(''); +} + +async function saveLLMSettings() { + const providerInput = document.getElementById('settingsProvider'); + const baseURLInput = document.getElementById('settingsBaseURL'); + const apiKeyInput = document.getElementById('settingsAPIKey'); + const modelInput = document.getElementById('settingsModel'); + const temperatureInput = document.getElementById('settingsTemperature'); + const maxTokensInput = document.getElementById('settingsMaxTokens'); + const topPInput = document.getElementById('settingsTopP'); + const frequencyPenaltyInput = document.getElementById('settingsFrequencyPenalty'); + const presencePenaltyInput = document.getElementById('settingsPresencePenalty'); + const reasoningEffortInput = document.getElementById('settingsReasoningEffort'); + const autoOpenReasoningInput = document.getElementById('settingsAutoOpenReasoning'); + const serverHostInput = document.getElementById('settingsServerHost'); + const serverAddrInput = document.getElementById('settingsServerAddr'); + const tokensInput = document.getElementById('settingsContextTokens'); + const triggerInput = document.getElementById('settingsTriggerRatio'); + const targetInput = document.getElementById('settingsTargetRatio'); + + const provider = providerInput.value.trim(); + const baseURL = baseURLInput.value.trim(); + const apiKey = apiKeyInput.value; + const model = modelInput.value.trim(); + const temperature = Number(temperatureInput.value); + const maxTokens = Number(maxTokensInput.value); + const topP = Number(topPInput.value); + const frequencyPenalty = Number(frequencyPenaltyInput.value); + const presencePenalty = Number(presencePenaltyInput.value); + const reasoningEffort = reasoningEffortInput.value; + const autoOpenReasoning = autoOpenReasoningInput.checked; + const serverHost = serverHostInput.value.trim(); + const serverAddr = serverAddrInput.value.trim(); + const contextTokens = Number(tokensInput.value); + const triggerRatio = Number(triggerInput.value); + const targetRatio = Number(targetInput.value); + + if (!provider) { + setSettingsError('Provider is required.'); + return; + } + if (!baseURL) { + setSettingsError('Base URL is required.'); + return; + } + if (!model) { + setSettingsError('Model is required.'); + return; + } + if (!Number.isFinite(temperature) || temperature < 0) { + setSettingsError('Temperature must be 0 or higher.'); + return; + } + if (!Number.isFinite(maxTokens) || maxTokens <= 0) { + setSettingsError('Max tokens must be a positive number.'); + return; + } + if (!Number.isFinite(topP) || topP <= 0 || topP > 1) { + setSettingsError('Top P must be greater than 0 and less than or equal to 1.'); + return; + } + if (!Number.isFinite(frequencyPenalty)) { + setSettingsError('Frequency penalty must be a number.'); + return; + } + if (!Number.isFinite(presencePenalty)) { + setSettingsError('Presence penalty must be a number.'); + return; + } + if (!['', 'low', 'medium', 'high'].includes(reasoningEffort)) { + setSettingsError('Reasoning effort must be Default, Low, Medium, or High.'); + return; + } + if (!serverHost) { + setSettingsError('Server host is required.'); + return; + } + if (!serverAddr) { + setSettingsError('Server address is required.'); + return; + } + if (!Number.isFinite(contextTokens) || contextTokens <= 0) { + setSettingsError('Prompt budget must be a positive number.'); + return; + } + if (!Number.isFinite(triggerRatio) || triggerRatio <= 0 || triggerRatio >= 1) { + setSettingsError('Compaction trigger must be greater than 0 and less than 1.'); + return; + } + if (!Number.isFinite(targetRatio) || targetRatio <= 0 || targetRatio >= triggerRatio) { + setSettingsError('Compaction target must be greater than 0 and less than the trigger.'); + return; + } + + try { + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + provider, + base_url: baseURL, + api_key: apiKey, + model, + temperature, + max_tokens: maxTokens, + top_p: topP, + frequency_penalty: frequencyPenalty, + presence_penalty: presencePenalty, + reasoning_effort: reasoningEffort, + auto_open_reasoning: autoOpenReasoning, + server_host: serverHost, + server_addr: serverAddr, + context_tokens: contextTokens, + context_compaction_trigger_ratio: triggerRatio, + context_compaction_target_ratio: targetRatio, + }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error((data && data.error) || 'Failed to save settings'); + } + llmSettings = normalizeRuntimeSettings(data); + closeLLMSettingsModal(); + } catch (err) { + console.error('Failed to save runtime settings:', err); + setSettingsError(err.message || 'Failed to save settings'); + } +} + +function updateStreamingControls() { + const sendBtn = document.getElementById('sendBtn'); + const cancelBtn = document.getElementById('cancelBtn'); + if (sendBtn) { + sendBtn.disabled = isStreaming; + sendBtn.textContent = isStreaming ? 'Sending...' : 'Send'; + } + if (cancelBtn) { + cancelBtn.classList.toggle('hidden', !isStreaming); + cancelBtn.disabled = !isStreaming; + } +} + +// Marked preserves raw HTML, so sanitize the rendered fragment before it hits innerHTML. +const safeMarkdownTags = new Set([ + 'a', + 'abbr', + 'b', + 'blockquote', + 'br', + 'code', + 'del', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'img', + 'input', + 'li', + 'ol', + 'p', + 'pre', + 'strong', + 'sub', + 'sup', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + 'ul', +]); + +function isSafeMarkdownUrl(value) { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return false; + } + try { + const url = new URL(trimmed, window.location.href); + return ['http:', 'https:', 'mailto:', 'tel:'].includes(url.protocol); + } catch (err) { + return false; + } +} + +function sanitizeRenderedMarkdown(html) { + if (!html || typeof document === 'undefined') { + return html; + } + + const template = document.createElement('template'); + template.innerHTML = html; + + function sanitizeElement(element) { + for (const child of Array.from(element.children)) { + sanitizeElement(child); + } + + const tagName = element.tagName.toLowerCase(); + if (!safeMarkdownTags.has(tagName)) { + const parent = element.parentNode; + if (!parent) { + return; + } + while (element.firstChild) { + parent.insertBefore(element.firstChild, element); + } + parent.removeChild(element); + return; + } + + for (const attr of Array.from(element.attributes)) { + const attrName = attr.name.toLowerCase(); + let keepAttr = false; + + switch (tagName) { + case 'a': + keepAttr = ['href', 'title', 'target', 'rel'].includes(attrName); + if (keepAttr && attrName === 'href' && !isSafeMarkdownUrl(attr.value)) { + keepAttr = false; + } + break; + case 'img': + keepAttr = ['src', 'alt', 'title', 'width', 'height', 'loading'].includes(attrName); + if (keepAttr && attrName === 'src' && !isSafeMarkdownUrl(attr.value)) { + keepAttr = false; + } + break; + case 'code': + case 'pre': + keepAttr = attrName === 'class'; + break; + case 'input': + keepAttr = ['type', 'checked', 'disabled'].includes(attrName); + if (keepAttr && attrName === 'type' && String(attr.value).toLowerCase() !== 'checkbox') { + keepAttr = false; + } + break; + case 'th': + case 'td': + keepAttr = ['colspan', 'rowspan'].includes(attrName); + break; + default: + keepAttr = false; + break; + } + + if (attrName.startsWith('on') || !keepAttr) { + element.removeAttribute(attr.name); + } + } + + if (tagName === 'a' && element.hasAttribute('href')) { + element.setAttribute('rel', 'noreferrer noopener'); + } + } + + for (const child of Array.from(template.content.children)) { + sanitizeElement(child); + } + + return template.innerHTML; +} + +function renderMarkdown(text) { + marked.setOptions({ + breaks: true, + gfm: true, + }); + return sanitizeRenderedMarkdown(marked.parse(text)); +} + +function completeIncompleteBlocks(text) { + // Count unclosed code blocks + let codeBlockOpen = (text.match(/```/g) || []).length; + if (codeBlockOpen % 2 !== 0) { + text += '\n```'; + } + + // Complete unclosed list items + let lines = text.split('\n'); + let lastLine = lines[lines.length - 1]; + if (lastLine && lastLine.trim() && !lastLine.trim().endsWith(':') && !lastLine.trim().endsWith(',') && !lastLine.trim().endsWith('.')) { + // Check if we're in the middle of a code block or already completed + if ((text.match(/```/g) || []).length % 2 === 0) { + // Not in a code block, add a newline to help markdown parser + text += '\n\n'; + } + } + + return text; +} + +function resizeMessageInput(input) { + if (!input) return; + const style = window.getComputedStyle(input); + const lineHeight = parseFloat(style.lineHeight) || 24; + const paddingTop = parseFloat(style.paddingTop) || 0; + const paddingBottom = parseFloat(style.paddingBottom) || 0; + const borderTop = parseFloat(style.borderTopWidth) || 0; + const borderBottom = parseFloat(style.borderBottomWidth) || 0; + const maxHeight = (lineHeight * messageInputMaxLines) + paddingTop + paddingBottom + borderTop + borderBottom; + + input.style.height = 'auto'; + const nextHeight = Math.min(input.scrollHeight + borderTop + borderBottom, maxHeight); + input.style.height = `${nextHeight}px`; + input.style.overflowY = input.scrollHeight + borderTop + borderBottom > maxHeight ? 'auto' : 'hidden'; +} + +function resetConversationRenderState() { + conversationRenderState.pendingToolBubble = null; +} + +function splitNormalizedLines(text) { + const normalized = String(text || '').replace(/\r\n/g, '\n'); + if (normalized === '') { + return []; + } + const lines = normalized.split('\n'); + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop(); + } + return lines; +} + +function stripReadFileLinePrefix(line) { + const match = String(line || '').match(/^\s*\d+:\s?(.*)$/); + return match ? match[1] : String(line || ''); +} + +function normalizePatchExpectedLines(expectedText) { + const lines = splitNormalizedLines(expectedText); + if (lines.length === 0) { + return []; + } + + if ( + lines.length >= 2 && + lines[0].startsWith('File: ') && + lines[1].startsWith('Lines ') && + lines[1].includes(' of ') + ) { + return lines.slice(2).map(stripReadFileLinePrefix); + } + + const normalized = []; + for (const line of lines) { + if (/^\s*\d+:\s?/.test(line)) { + normalized.push(stripReadFileLinePrefix(line)); + continue; + } + return lines; + } + + return normalized; +} + +function parseJsonObject(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed); + } catch (err) { + return null; + } +} + +function prettyPrintValue(value) { + if (typeof value === 'string') { + return value; + } + if (value === null || value === undefined) { + return ''; + } + try { + return JSON.stringify(value, null, 2); + } catch (err) { + return String(value); + } +} + +function humanizeToolName(name) { + const trimmed = String(name || '').trim(); + if (!trimmed) { + return 'Tool'; + } + return trimmed + .replace(/_/g, ' ') + .replace(/\s+/g, ' ') + .replace(/\b\w/g, char => char.toUpperCase()); +} + +function formatToolRangeLabel(startLine, endLine) { + const start = Number(startLine); + if (!Number.isFinite(start) || start <= 0) { + return ''; + } + if (endLine === -1) { + return `${start}-EOF`; + } + const end = Number(endLine); + if (!Number.isFinite(end)) { + return `${start}-EOF`; + } + return `${start}-${end}`; +} + +function renderToolSummaryRows(rows) { + const visibleRows = rows.filter(row => row && row[0] && row[1] !== undefined && row[1] !== null && String(row[1]).trim() !== ''); + if (visibleRows.length === 0) { + return ''; + } + + return ` +
+ ${visibleRows.map(([label, value]) => ` +
+
${escapeHtml(String(label))}
+
${escapeHtml(String(value))}
+
+ `).join('')} +
+ `; +} + +function renderToolTextBlock(text, extraClass = '') { + const rawText = String(text ?? ''); + const content = rawText.trim() ? rawText : 'No output'; + return `
${escapeHtml(content)}
`; +} + +function renderToolPlaceholder(text) { + return `
${escapeHtml(String(text || 'Waiting for result...'))}
`; +} + +function renderPatchDiffHtml(args) { + const path = String(args?.path || '').trim() || 'patch'; + const oldLines = normalizePatchExpectedLines(args?.expected_text || ''); + const newLines = splitNormalizedLines(args?.content || ''); + const startLine = Number(args?.start_line); + const endLine = Number(args?.end_line); + const rangeLabel = formatToolRangeLabel(startLine, endLine); + const diffWindowLabel = rangeLabel ? `range ${rangeLabel}` : 'requested range'; + const header = [ + `--- a/${path}`, + `+++ b/${path}`, + `@@ ${diffWindowLabel} · -${oldLines.length} +${newLines.length} @@`, + ]; + + if (oldLines.length === 0 && newLines.length === 0) { + return ` +
+
${escapeHtml(header.join('\n'))}
+
+
No diff content available.
+
+
+ `; + } + + const diffLines = []; + for (const line of oldLines) { + diffLines.push(` +
+ - + ${escapeHtml(line)} +
+ `); + } + for (const line of newLines) { + diffLines.push(` +
+ + + ${escapeHtml(line)} +
+ `); + } + + return ` +
+
${escapeHtml(header.join('\n'))}
+
+ ${diffLines.join('')} +
+
+ `; +} + +function renderToolExecutionSection(toolName, args, rawContent) { + const normalizedName = String(toolName || '').toLowerCase(); + const rows = []; + let extraHtml = ''; + + switch (normalizedName) { + case 'read_file': + case 'write_file': + case 'patch_file': + if (args && args.path) { + rows.push(['Path', args.path]); + } + if (args && Object.prototype.hasOwnProperty.call(args, 'start_line')) { + rows.push(['Range', formatToolRangeLabel(args.start_line, args.end_line) || '1-EOF']); + } + if (normalizedName === 'patch_file') { + rows.push(['Mode', 'Guarded snippet replacement']); + extraHtml = renderPatchDiffHtml(args || {}); + } + break; + case 'run_command': + if (args && args.command) { + rows.push(['Command', args.command]); + } + if (args && Array.isArray(args.args) && args.args.length > 0) { + rows.push(['Args', args.args.map(arg => JSON.stringify(arg)).join(' ')]); + } + break; + case 'grep': + if (args && args.search_term) { + rows.push(['Search', args.search_term]); + } + if (args && args.path) { + rows.push(['Path', args.path]); + } + break; + case 'search_files': + if (args && args.path_pattern) { + rows.push(['Pattern', args.path_pattern]); + } + break; + case 'list_directory': + case 'create_directory': + case 'delete_file': + if (args && args.path) { + rows.push(['Path', args.path]); + } + break; + default: + break; + } + + const summaryHtml = renderToolSummaryRows(rows); + if (normalizedName === 'patch_file') { + const patchHtml = `${summaryHtml}${extraHtml}`.trim(); + if (patchHtml) { + if (summaryHtml && extraHtml) { + return `${summaryHtml}
${extraHtml}
`; + } + return patchHtml; + } + } + if (summaryHtml) { + return summaryHtml; + } + + const fallbackText = rawContent && rawContent.trim() ? rawContent : prettyPrintValue(args || {}); + return renderToolTextBlock(fallbackText); +} + +function parseReadFileResult(contentText) { + const lines = splitNormalizedLines(contentText); + if (lines.length < 2 || !lines[0].startsWith('File: ')) { + return null; + } + + const path = lines[0].slice('File: '.length).trim(); + const secondLine = lines[1] || ''; + if (!secondLine) { + return { path, note: '', contentLines: [] }; + } + if (secondLine === 'File is empty.') { + return { path, note: secondLine, contentLines: [] }; + } + if (secondLine.startsWith('Requested lines ')) { + return { path, note: secondLine, contentLines: [] }; + } + + const match = secondLine.match(/^Lines (\d+)-(\d+|EOF) of (\d+):$/); + if (!match) { + return { + path, + note: '', + contentLines: lines.slice(1), + }; + } + + return { + path, + rangeLabel: `Lines ${match[1]}-${match[2]} of ${match[3]}`, + contentLines: lines.slice(2), + }; +} + +function renderReadFileResultHtml(parsed) { + const hasContent = Array.isArray(parsed.contentLines) && parsed.contentLines.length > 0; + const previewText = hasContent ? parsed.contentLines.join('\n') : ''; + const note = parsed.note || ''; + return ` +
+
+
+
${escapeHtml(parsed.path || 'File')}
+ ${parsed.rangeLabel ? `
${escapeHtml(parsed.rangeLabel)}
` : ''} +
+ ${hasContent ? ` + + ` : ''} +
+ ${ + hasContent + ? ` + + ` + : ` +
+ ${escapeHtml(note || 'No content')} +
+ ` + } +
+ `; +} + +function renderToolResultSection(toolName, contentText, args, options = {}) { + if (options.placeholder) { + return renderToolPlaceholder('Waiting for result...'); + } + + const normalizedName = String(toolName || '').toLowerCase(); + const rawText = String(contentText || ''); + if (normalizedName === 'read_file') { + const parsed = parseReadFileResult(rawText); + if (parsed) { + return renderReadFileResultHtml(parsed); + } + } + + const isError = rawText.trim().toLowerCase().startsWith('error:'); + return renderToolTextBlock(rawText || 'No output', isError ? 'tool-text-block-error' : ''); +} + +function wireReadFilePreviewToggle(root) { + const previewEl = root.querySelector('[data-tool-file-preview]'); + const toggleBtn = root.querySelector('.tool-file-toggle'); + if (!previewEl || !toggleBtn) { + return; + } + + toggleBtn.addEventListener('click', () => { + const expanded = previewEl.classList.toggle('is-expanded'); + previewEl.classList.toggle('is-collapsed', !expanded); + toggleBtn.textContent = expanded ? 'Collapse to 7 lines' : 'Show full file'; + toggleBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + }); +} + +function createToolBubbleState(message) { + const toolName = String(message.name || '').trim(); + const agent = String(message.agent || '').trim(); + const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0; + const tokenBadge = totalTokens > 0 + ? `${escapeHtml(formatTokenCount(totalTokens))}` + : ''; + const args = parseJsonObject(message.content) || {}; + const bubble = document.createElement('div'); + bubble.className = 'mb-4'; + bubble.innerHTML = ` +
+ ${assistantAvatarHtml('bg-slate-700')} +
+
+ ${escapeHtml(humanizeToolName(toolName))} + ${agent ? `${escapeHtml(agent)}` : ''} + ${tokenBadge} +
+
+
+
+
+ `; + const executionBody = bubble.querySelector('.tool-execution'); + const resultBody = bubble.querySelector('.tool-result'); + const state = { + bubble, + executionBody, + resultBody, + toolName, + agent, + args, + resolved: false, + }; + executionBody.innerHTML = renderToolExecutionSection(toolName, args, message.content || ''); + resultBody.innerHTML = renderToolResultSection(toolName, '', args, { placeholder: true }); + return state; +} + +function createStandaloneToolResultBubble(message) { + const toolName = String(message.name || '').trim(); + const agent = String(message.agent || '').trim(); + const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0; + const tokenBadge = totalTokens > 0 + ? `${escapeHtml(formatTokenCount(totalTokens))}` + : ''; + const bubble = document.createElement('div'); + bubble.className = 'mb-4'; + bubble.innerHTML = ` +
+ ${assistantAvatarHtml('bg-slate-700')} +
+
+ ${escapeHtml(humanizeToolName(toolName))} + ${agent ? `${escapeHtml(agent)}` : ''} + ${tokenBadge} +
+
+
+
+
+ `; + const executionBody = bubble.querySelector('.tool-execution'); + const resultBody = bubble.querySelector('.tool-result'); + executionBody.innerHTML = renderToolPlaceholder('Execution details unavailable.'); + resultBody.innerHTML = renderToolResultSection(toolName, message.content || '', {}, {}); + wireReadFilePreviewToggle(resultBody); + return bubble; +} + +function updatePendingToolBubbleResult(state, message) { + if (!state || !state.resultBody) { + return; + } + state.resultBody.innerHTML = renderToolResultSection(state.toolName, message.content || '', state.args); + state.resolved = true; + wireReadFilePreviewToggle(state.resultBody); +} + +function finalizePendingToolBubble() { + const pending = conversationRenderState.pendingToolBubble; + if (!pending) { + return; + } + if (!pending.resolved && pending.resultBody) { + pending.resultBody.innerHTML = renderToolPlaceholder('No result recorded.'); + } + conversationRenderState.pendingToolBubble = null; +} + +function buildThinkingPanelHtml(content, options = {}) { + const label = options.label || 'Reasoning'; + const tokenBadge = options.tokenBadge || ''; + const openAttr = (options.open ?? reasoningAutoOpenEnabled()) ? ' open' : ''; + return ` +
+ + + + + + ${escapeHtml(label)} + + ${tokenBadge} + +
${renderMarkdown(completeIncompleteBlocks(content || ''))}
+
+ `; +} + +async function loadGroups() { + try { + const res = await fetch('/api/groups'); + groups = await res.json(); + } catch (err) { + console.error('Failed to load groups:', err); + } +} + +async function loadConversations() { + await loadGroups(); + try { + const res = await fetch('/api/conversations'); + const data = await res.json(); + const listEl = document.getElementById('conversationList'); + listEl.innerHTML = ''; + + // Render groups + for (const group of groups) { + const apiGroup = data.groups.find(g => g.id === group.id); + const groupConvs = (apiGroup?.conversations || []).filter(Boolean); + + + const groupContainer = document.createElement('div'); + const isExpanded = expandedGroups.has(group.id); + + // Group header + const groupHeader = document.createElement('div'); + groupHeader.className = 'flex items-center gap-2 px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-50 rounded-lg transition-colors'; + groupHeader.innerHTML = ` + + + + ${escapeHtml(group.name)} + ${groupConvs.length} + ${Number(group.total_tokens || 0) > 0 ? `${escapeHtml(formatTokenCount(group.total_tokens))}` : ''} + + + + `; + + const deleteBtn = groupHeader.querySelector('.group-btn-delete'); + deleteBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + if (confirm(`Delete workspace "${group.name}"?`)) { + await deleteGroup(group.id); + } + }); + + const newConvBtn = groupHeader.querySelector('.group-btn-new'); + newConvBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + await createConversationForGroup(group.id); + }); + + const settingsBtn = groupHeader.querySelector('.group-btn-settings'); + settingsBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + await openGroupSettings(group.id); + }); + + groupHeader.addEventListener('click', () => { + selectedGroupId = group.id; + if (expandedGroups.has(group.id)) { + expandedGroups.delete(group.id); + } else { + expandedGroups.add(group.id); + } + loadConversations(); + }); + + groupContainer.appendChild(groupHeader); + + // Group conversations (only if expanded) + if (isExpanded) { + const groupConvList = document.createElement('div'); + groupConvList.className = 'ml-4 space-y-1'; + + for (const conv of groupConvs) { + groupConvList.appendChild(createConversationItem(conv, group.id)); + } + groupContainer.appendChild(groupConvList); + } + + listEl.appendChild(groupContainer); + } + + // Render ungrouped conversations + if (data.ungrouped.length > 0) { + const ungroupedHeader = document.createElement('div'); + ungroupedHeader.className = 'flex items-center gap-2 px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider'; + ungroupedHeader.innerHTML = ` + + Ungrouped + ${data.ungrouped.length} + `; + listEl.appendChild(ungroupedHeader); + + const ungroupedList = document.createElement('div'); + ungroupedList.className = 'space-y-1'; + + for (const conv of data.ungrouped) { + ungroupedList.appendChild(createConversationItem(conv, null)); + } + listEl.appendChild(ungroupedList); + } + } catch (err) { + console.error('Failed to load conversations:', err); + } +} + +function createConversationItem(conv, groupId) { + const item = document.createElement('div'); + item.className = 'conversation-item px-3 py-2 rounded-lg cursor-pointer text-sm flex items-center gap-2 group transition-colors'; + if (conv.id === currentConversationId) { + item.classList.add('bg-blue-100', 'text-blue-700'); + } else { + item.classList.add('hover:bg-gray-100', 'text-gray-700'); + } + item.dataset.id = conv.id; + + const icon = document.createElement('span'); + icon.className = 'text-gray-400 flex-shrink-0'; + icon.innerHTML = ''; + + const title = document.createElement('span'); + title.className = 'flex-1 truncate'; + title.textContent = conv.title; + + const tokenCount = Number(conv.total_tokens || 0); + const tokenBadge = document.createElement('span'); + tokenBadge.className = 'rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold tracking-normal text-slate-600 whitespace-nowrap'; + tokenBadge.textContent = formatTokenCount(tokenCount); + if (!tokenBadge.textContent) { + tokenBadge.remove(); + } + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all flex-shrink-0 p-0.5'; + deleteBtn.innerHTML = ''; + deleteBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + if (confirm('Delete this conversation?')) { + await deleteConversation(conv.id); + } + }); + + item.appendChild(icon); + item.appendChild(title); + if (tokenBadge.textContent) { + item.appendChild(tokenBadge); + } + item.appendChild(deleteBtn); + + item.addEventListener('click', () => { + if (groupId) selectedGroupId = groupId; + loadConversation(conv.id, groupId); + }); + + return item; +} + +async function loadConversation(convId, groupId) { + currentConversationId = convId; + isStreaming = false; + if (abortController) { + abortController.abort(); + abortController = null; + } + + const resultEl = document.getElementById('result'); + const resultText = document.getElementById('resultText'); + const emptyState = document.getElementById('emptyState'); + resultEl.classList.remove('hidden'); + emptyState.classList.add('hidden'); + resultText.innerHTML = ''; + resetConversationRenderState(); + + try { + const res = await fetch(`/api/conversations/${convId}/messages`); + const messages = await res.json(); + + messages.forEach(msg => { + appendConversationMessage(msg); + }); + finalizePendingToolBubble(); + + const emptyState = document.getElementById('emptyState'); + if (messages.length === 0) { + emptyState.classList.remove('hidden'); + } else { + emptyState.classList.add('hidden'); + } + + document.getElementById('messageInput').focus(); + } catch (err) { + console.error('Failed to load conversation:', err); + } + + await loadConversations(); + + if (groupId) { + await showGroupLocationInHeader(groupId); + } else { + const locEl = document.getElementById('headerLocation'); + locEl.classList.add('hidden'); + locEl.classList.remove('flex'); + } +} + +async function createConversation() { + try { + const body = { title: 'New Conversation' }; + const groupId = selectedGroupId || currentGroupFilter || undefined; + if (groupId) { + body.group_id = groupId; + } + const res = await fetch('/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const conv = await res.json(); + await loadConversation(conv.id, groupId); + } catch (err) { + console.error('Failed to create conversation:', err); + } +} + +async function deleteConversation(convId) { + try { + await fetch(`/api/conversations/${convId}`, { method: 'DELETE' }); + if (currentConversationId === convId) { + currentConversationId = null; + const resultEl = document.getElementById('result'); + const resultText = document.getElementById('resultText'); + resultEl.classList.add('hidden'); + resultText.innerHTML = ''; + resetConversationRenderState(); + document.getElementById('messageInput').value = ''; + } + await loadConversations(); + } catch (err) { + console.error('Failed to delete conversation:', err); + } +} + +async function createGroup() { + const modal = document.getElementById('groupModal'); + modal.classList.remove('hidden'); + modal.classList.add('flex'); + const input = document.getElementById('groupNameInput'); + input.value = ''; + setTimeout(() => input.focus(), 50); +} + +function closeGroupModal() { + const modal = document.getElementById('groupModal'); + modal.classList.add('hidden'); + modal.classList.remove('flex'); +} + +async function createGroupFromModal() { + const input = document.getElementById('groupNameInput'); + const name = input.value.trim(); + if (!name) return; + try { + const res = await fetch('/api/groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + const group = await res.json(); + expandedGroups.add(group.id); + currentGroupFilter = group.id; + closeGroupModal(); + await loadConversations(); + await loadGroupLocation(); + } catch (err) { + console.error('Failed to create group:', err); + } +} + +async function createConversationForGroup(groupId) { + try { + const body = { title: 'New Conversation', group_id: groupId }; + const res = await fetch('/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const conv = await res.json(); + await loadConversation(conv.id, groupId); + } catch (err) { + console.error('Failed to create conversation:', err); + } + } + + async function deleteGroup(groupId) { + groups = groups.filter(g => g.id !== groupId); + expandedGroups.delete(groupId); + if (currentGroupFilter === groupId) { + currentGroupFilter = null; + } + await loadConversations(); +} + +function assistantAvatarHtml(toneClass) { + return ` +
+ +
+ `; +} + +function labelForMessage(kind, name, agent = '') { + if (agent) return agent; + if (name) return name; + switch ((kind || '').toLowerCase()) { + case 'planner': + return 'Planner'; + case 'programmer': + return 'Programmer'; + case 'qa': + return 'QA'; + case 'manager': + return 'Manager'; + default: + return ''; + } +} + +function toneForMessageKind(kind) { + switch ((kind || '').toLowerCase()) { + case 'planner': + return 'border-indigo-200 bg-indigo-50 text-indigo-950'; + case 'programmer': + return 'border-blue-200 bg-blue-50 text-blue-950'; + case 'qa': + return 'border-emerald-200 bg-emerald-50 text-emerald-950'; + case 'manager': + return 'border-slate-200 bg-slate-50 text-slate-900'; + default: + return 'border-gray-200 bg-gray-50 text-gray-800'; + } +} + +function scrollConversationToBottom() { + const chatArea = document.getElementById('chatScrollArea'); + if (chatArea) { + chatArea.scrollTop = chatArea.scrollHeight; + return; + } + const resultText = document.getElementById('resultText'); + if (resultText) { + resultText.scrollTop = resultText.scrollHeight; + } +} + +function appendConversationMessage(message) { + const resultText = document.getElementById('resultText'); + if (!resultText) return null; + + const role = (message.role || 'assistant').toLowerCase(); + const kind = (message.kind || 'message').toLowerCase(); + const name = message.name || ''; + const agent = message.agent || ''; + const totalTokens = Number(message.total_tokens ?? message.totalTokens ?? 0) || 0; + const content = message.content || ''; + const contentText = content.trim(); + let msgDiv = document.createElement('div'); + msgDiv.className = 'mb-4'; + const tokenBadge = totalTokens > 0 + ? `${escapeHtml(formatTokenCount(totalTokens))}` + : ''; + let shouldAppend = true; + + if (role === 'user' && kind === 'message') { + msgDiv.innerHTML = ` +
+
+ ${escapeHtml(content)} +
+
+ `; + } else if (kind === 'thinking') { + const label = labelForMessage(kind, name, agent) || 'Reasoning'; + msgDiv.innerHTML = ` +
+ ${assistantAvatarHtml('bg-slate-700')} +
+ ${buildThinkingPanelHtml(contentText || '', { + label, + tokenBadge, + })} +
+
+ `; + } else if (kind === 'tool_call') { + if (conversationRenderState.pendingToolBubble && !conversationRenderState.pendingToolBubble.resolved) { + finalizePendingToolBubble(); + } + const bubbleState = createToolBubbleState(message); + conversationRenderState.pendingToolBubble = bubbleState; + msgDiv = bubbleState.bubble; + } else if (kind === 'tool_result') { + const pending = conversationRenderState.pendingToolBubble; + const sameTool = pending && + !pending.resolved && + String(pending.toolName || '').trim() === String(name || '').trim() && + String(pending.agent || '').trim() === String(agent || '').trim(); + if (sameTool) { + updatePendingToolBubbleResult(pending, message); + msgDiv = pending.bubble; + shouldAppend = false; + conversationRenderState.pendingToolBubble = null; + } else { + if (pending && !pending.resolved) { + finalizePendingToolBubble(); + } + conversationRenderState.pendingToolBubble = null; + msgDiv = createStandaloneToolResultBubble(message); + } + } else if (kind === 'error') { + msgDiv.innerHTML = ` +
+ ${assistantAvatarHtml('bg-red-600')} +
+
Error
+
${escapeHtml(contentText || 'Unknown error')}
+
+
+ `; + } else if (kind === 'cancelled') { + msgDiv.innerHTML = ` +
+ ${assistantAvatarHtml('bg-slate-500')} +
+
Cancelled
+
${escapeHtml(contentText || 'Request stopped')}
+
+
+ `; + } else { + const label = labelForMessage(kind, name, agent); + const tone = label === 'Manager' ? 'border-slate-200 bg-slate-50 text-slate-900' : toneForMessageKind(kind); + const avatarTone = + label === 'Manager' || kind === 'manager' ? 'bg-slate-700' : + kind === 'planner' ? 'bg-indigo-600' : + kind === 'programmer' ? 'bg-blue-600' : + kind === 'qa' ? 'bg-emerald-600' : + 'bg-gray-800'; + msgDiv.innerHTML = ` +
+ ${assistantAvatarHtml(avatarTone)} +
+ ${(label || tokenBadge) ? `
${label ? `${escapeHtml(label)}` : ''}${tokenBadge}
` : ''} +
${renderMarkdown(completeIncompleteBlocks(contentText || ''))}
+
+
+ `; + } + + if (shouldAppend) { + resultText.appendChild(msgDiv); + } + if (activeStreamUi && typeof activeStreamUi.moveToEnd === 'function') { + activeStreamUi.moveToEnd(); + } + scrollConversationToBottom(); + return msgDiv; +} + +function statusLabelForKind(kind) { + switch ((kind || '').toLowerCase()) { + case 'planner': + return 'Planner'; + case 'programmer': + return 'Programmer'; + case 'qa': + return 'QA'; + case 'manager': + return 'Manager'; + case 'tool_call': + case 'tool_result': + case 'tool': + return 'Tool'; + case 'streaming': + return 'Streaming'; + case 'done': + return 'Done'; + case 'cancelled': + return 'Cancelled'; + case 'error': + return 'Error'; + case 'thinking': + return 'Thinking'; + default: + return 'Working'; + } +} + +function createStreamingBubble(initialStatus) { + const bubble = document.createElement('div'); + bubble.className = 'mb-4'; + bubble.innerHTML = ` +
+ ${assistantAvatarHtml('bg-gray-800')} +
+
+
+
+
+ Working + +
+
+
+
+ 0:00 elapsed + + Working... +
+
+
+ + +
+
+ `; + const statusLabelEl = bubble.querySelector('.stream-status-label'); + const statusStepEl = bubble.querySelector('.stream-status-step'); + const statusTextEl = bubble.querySelector('.stream-status-text'); + const statusElapsedEl = bubble.querySelector('.stream-status-elapsed'); + const statusTokensEl = bubble.querySelector('.stream-status-tokens'); + const statusIndicatorEl = bubble.querySelector('.stream-status-indicator'); + const thinkingContainerEl = bubble.querySelector('.stream-thinking'); + const contentEl = bubble.querySelector('.stream-content'); + const startTime = Date.now(); + let timerId = null; + let autoHideTimerId = null; + let reasoningText = ''; + let thinkingPanelEl = null; + let thinkingBodyEl = null; + let thinkingTokensEl = null; + let reasoningCollapsedByUser = false; + if (initialStatus) { + statusTextEl.textContent = initialStatus; + } + let contentMode = false; + let finalized = false; + let lastStep = 0; + let lastTotal = 0; + + function updateElapsed() { + if (statusElapsedEl) { + statusElapsedEl.textContent = `${formatElapsed(Date.now() - startTime)} elapsed`; + } + } + + function updateTokens(totalTokens) { + if (!statusTokensEl) return; + const label = formatTokenCount(totalTokens); + statusTokensEl.textContent = label; + statusTokensEl.classList.toggle('hidden', !label); + if (thinkingTokensEl) { + thinkingTokensEl.textContent = label; + thinkingTokensEl.classList.toggle('hidden', !label); + } + } + + function updateProgress(step, total) { + if (Number.isFinite(step) && step > 0) { + lastStep = step; + } + if (Number.isFinite(total) && total > 0) { + lastTotal = total; + } + if (statusStepEl) { + const label = formatStepLabel(lastStep, lastTotal); + statusStepEl.textContent = label; + statusStepEl.classList.toggle('hidden', !label); + } + } + + function clearAutoHideTimer() { + if (autoHideTimerId) { + window.clearTimeout(autoHideTimerId); + autoHideTimerId = null; + } + } + + function stopTimer() { + if (timerId) { + window.clearInterval(timerId); + timerId = null; + } + } + + function ensureThinkingPanel() { + if (!thinkingContainerEl || thinkingPanelEl) { + return; + } + thinkingContainerEl.classList.remove('hidden'); + thinkingContainerEl.innerHTML = ` +
+ + + + + + Reasoning + + + +
+
+ `; + thinkingPanelEl = thinkingContainerEl.querySelector('.thinking-panel'); + thinkingBodyEl = thinkingContainerEl.querySelector('.thinking-body'); + thinkingTokensEl = thinkingContainerEl.querySelector('.thinking-summary-tokens'); + if (thinkingPanelEl) { + thinkingPanelEl.addEventListener('toggle', () => { + if (thinkingPanelEl.open) { + reasoningCollapsedByUser = false; + } else if (reasoningText) { + reasoningCollapsedByUser = true; + } + }); + } + } + + function updateThinkingBody() { + if (!thinkingBodyEl) return; + thinkingBodyEl.innerHTML = renderMarkdown(completeIncompleteBlocks(reasoningText)); + } + + function appendReasoningChunk(chunk) { + if (!chunk) return; + reasoningText += chunk; + ensureThinkingPanel(); + updateThinkingBody(); + if (thinkingPanelEl && reasoningAutoOpenEnabled() && !reasoningCollapsedByUser) { + thinkingPanelEl.open = true; + } + api.moveToEnd(); + } + + timerId = window.setInterval(updateElapsed, 1000); + updateElapsed(); + + const api = { + bubble, + contentEl, + hasStructuredMessages: false, + moveToEnd() { + if (bubble.parentNode) { + bubble.parentNode.appendChild(bubble); + } + }, + setStatus(text, kind, step, total) { + if (finalized) return; + statusLabelEl.textContent = statusLabelForKind(kind); + statusTextEl.textContent = text || ''; + if (statusIndicatorEl) { + const activeKind = (kind || '').toLowerCase(); + statusIndicatorEl.classList.toggle('hidden', activeKind === 'done' || activeKind === 'cancelled' || activeKind === 'error'); + } + updateProgress(step, total); + updateElapsed(); + api.moveToEnd(); + }, + setTokenUsage(totalTokens) { + updateTokens(totalTokens); + api.moveToEnd(); + }, + appendReasoning(chunk) { + appendReasoningChunk(chunk); + scrollConversationToBottom(); + }, + showContent() { + if (contentMode) return; + contentMode = true; + contentEl.classList.remove('hidden'); + api.moveToEnd(); + }, + finish(options = {}) { + if (finalized) return; + finalized = true; + const kind = options.kind || 'done'; + statusLabelEl.textContent = statusLabelForKind(kind); + statusTextEl.textContent = options.text || 'Completed'; + if (statusIndicatorEl) { + statusIndicatorEl.classList.add('hidden'); + } + updateElapsed(); + stopTimer(); + clearAutoHideTimer(); + api.moveToEnd(); + if (options.autoHideMs && options.autoHideMs > 0) { + autoHideTimerId = window.setTimeout(() => api.destroy(), options.autoHideMs); + } + }, + cancel(text, autoHideMs) { + api.finish({ + kind: 'cancelled', + text: text || 'Stopped by you', + autoHideMs: autoHideMs || 0, + }); + }, + fail(text, autoHideMs) { + api.finish({ + kind: 'error', + text: text || 'Request failed', + autoHideMs: autoHideMs || 0, + }); + }, + destroy() { + clearAutoHideTimer(); + stopTimer(); + if (bubble.parentNode) { + bubble.remove(); + } + if (activeStreamUi === api) { + activeStreamUi = null; + } + }, + }; + + return api; +} + +async function sendMessage() { + const input = document.getElementById('messageInput'); + const rawMessage = input.value; + const message = rawMessage.trim(); + if (!message || isStreaming) return; + + isStreaming = true; + cancelRequested = false; + updateStreamingControls(); + + const resultEl = document.getElementById('result'); + const resultText = document.getElementById('resultText'); + const emptyState = document.getElementById('emptyState'); + resultEl.classList.remove('hidden'); + emptyState.classList.add('hidden'); + + // Append user message + appendConversationMessage({ role: 'user', kind: 'message', content: rawMessage }); + input.value = ''; + resizeMessageInput(input); + + // Create a transient bubble that can show live status, then morph into streamed content. + const streamUi = createStreamingBubble('Waiting for the agent...'); + activeStreamUi = streamUi; + resultText.appendChild(streamUi.bubble); + scrollConversationToBottom(); + let structuredStreamSeen = false; + + abortController = new AbortController(); + const requestSignal = abortController.signal; + + // Get conversation ID or create new one + let convId = currentConversationId; + if (!convId) { + const titleText = message.replace(/\s+/g, ' ').trim(); + const body = { title: titleText.substring(0, 50) }; + let groupId = selectedGroupId || currentGroupFilter; + if (groupId) { + body.group_id = groupId; + } + const res = await fetch('/api/conversations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: requestSignal, + }); + const conv = await res.json(); + convId = conv.id; + currentConversationId = convId; + await loadConversations(); + } + + const groupId = selectedGroupId || currentGroupFilter || undefined; + + try { + const fetchRes = await fetch('/api/echo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: message, + conversation_id: convId, + group_id: groupId, + agent_types: selectedAgent === 'none' ? [] : [selectedAgent], + }), + signal: requestSignal, + }); + + if (!fetchRes.body) { + throw new Error('No response body'); + } + + const reader = fetchRes.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let fullContent = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + let parsed = null; + try { + parsed = JSON.parse(data); + } catch (jsonErr) { + parsed = null; + } + + if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'conversation_message' && typeof parsed.kind === 'string') { + structuredStreamSeen = true; + if (activeStreamUi) { + activeStreamUi.hasStructuredMessages = true; + } + appendConversationMessage(parsed); + if (activeStreamUi && parsed.kind === 'message') { + activeStreamUi.finish({ + kind: 'done', + text: 'Completed', + autoHideMs: 1200, + }); + } else if (activeStreamUi && parsed.kind === 'error') { + activeStreamUi.fail(parsed.content || 'Request failed'); + } + continue; + } + + if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'status') { + if (activeStreamUi && activeStreamUi.setStatus) { + activeStreamUi.setStatus(parsed.content || 'Working...', parsed.kind || 'thinking', parsed.step, parsed.total_steps); + scrollConversationToBottom(); + } + continue; + } + + if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'stream_usage') { + if (activeStreamUi && activeStreamUi.setTokenUsage) { + activeStreamUi.setTokenUsage(parsed.total_tokens || 0); + scrollConversationToBottom(); + } + continue; + } + + if (parsed && typeof parsed === 'object' && parsed.gocoder_event === 'thinking') { + if (activeStreamUi && activeStreamUi.appendReasoning) { + activeStreamUi.appendReasoning(parsed.content || ''); + scrollConversationToBottom(); + } + continue; + } + + if (activeStreamUi && activeStreamUi.showContent) { + activeStreamUi.showContent(); + } + fullContent += data.replace(/\\n/g, '\n'); + if (activeStreamUi && activeStreamUi.contentEl) { + activeStreamUi.contentEl.innerHTML = renderMarkdown(completeIncompleteBlocks(fullContent)); + scrollConversationToBottom(); + } + } + } + } + + if (activeStreamUi) { + activeStreamUi.finish({ + kind: 'done', + text: structuredStreamSeen ? 'Completed' : 'Completed', + autoHideMs: structuredStreamSeen ? 1200 : 0, + }); + } + + } catch (err) { + if (err.name !== 'AbortError') { + console.error('Stream error:', err); + if (activeStreamUi) { + activeStreamUi.fail(`Error: ${err.message}`); + } + appendConversationMessage({ role: 'assistant', kind: 'error', content: 'Error: ' + err.message }); + } else if (cancelRequested && activeStreamUi) { + activeStreamUi.cancel('Stopped by you', activeStreamUi.hasStructuredMessages ? 1200 : 0); + } else if (activeStreamUi) { + activeStreamUi.fail('Request aborted', activeStreamUi.hasStructuredMessages ? 1200 : 0); + } + } finally { + isStreaming = false; + abortController = null; + cancelRequested = false; + activeStreamUi = null; + updateStreamingControls(); + input.focus(); + loadConversations(); + } +} + + async function showGroupLocationInHeader(groupId) { + const group = groups.find(g => g.id === groupId); + if (!group) { + const locEl = document.getElementById('headerLocation'); + locEl.classList.add('hidden'); + locEl.classList.remove('flex'); + return; + } + try { + const res = await fetch(`/api/groups/${groupId}?action=get_location`); + if (res.ok) { + const data = await res.json(); + if (data.location) { + groupLocations[groupId] = data.location; + } else { + delete groupLocations[groupId]; + } + } + } catch (err) { + console.error('Failed to load group location:', err); + } + const locEl = document.getElementById('headerLocation'); + const locTextEl = document.getElementById('headerLocationText'); + if (groupLocations[groupId]) { + locEl.classList.remove('hidden'); + locEl.classList.add('flex'); + locTextEl.textContent = groupLocations[groupId]; + } else { + locEl.classList.add('hidden'); + locEl.classList.remove('flex'); + } + renderAgentButtons(); +} + +async function loadGroupLocation(groupId) { + if (groups.length === 0) return; + const targetGroupId = currentSettingsGroupId || groupId || selectedGroupId; + const group = targetGroupId ? groups.find(g => g.id === targetGroupId) || groups[0] : groups[0]; + try { + const res = await fetch(`/api/groups/${group.id}?action=get_location`); + if (res.ok) { + const data = await res.json(); + if (data.location) { + groupLocations[group.id] = data.location; + showLocation(data.location, true, group.id); + } else { + delete groupLocations[group.id]; + showLocation(null, false, group.id); + } + renderAgentButtons(); + } + } catch (err) { + console.error('Failed to load group location:', err); + } +} + +function showLocation(location, hasLocation, groupId) { + // Always update header location + const headerLocationEl = document.getElementById('headerLocation'); + const headerLocationTextEl = document.getElementById('headerLocationText'); + const headerPickBtn = document.getElementById('pickLocationBtn'); + const headerChangeBtn = document.getElementById('changeLocationBtn'); + + if (hasLocation && location) { + headerLocationEl.classList.remove('hidden'); + headerLocationEl.classList.add('flex'); + headerLocationTextEl.textContent = location; + headerPickBtn.classList.add('hidden'); + headerPickBtn.classList.remove('flex'); + headerChangeBtn.classList.remove('hidden'); + headerChangeBtn.classList.add('flex'); + } else { + headerLocationEl.classList.add('hidden'); + headerLocationEl.classList.remove('flex'); + headerPickBtn.classList.remove('hidden'); + headerPickBtn.classList.add('flex'); + } + + if (groupId) { + // Per-group location (settings modal) + const locationEl = document.getElementById('groupSettingsLocation'); + const locationTextEl = document.getElementById('groupSettingsLocationText'); + const pickBtn = document.getElementById('groupSettingsPickBtn'); + + if (hasLocation && location) { + locationEl.classList.remove('hidden'); + locationEl.classList.add('flex'); + locationTextEl.textContent = location; + pickBtn.classList.add('hidden'); + pickBtn.classList.remove('flex'); + } else { + locationEl.classList.add('hidden'); + locationEl.classList.remove('flex'); + pickBtn.classList.remove('hidden'); + pickBtn.classList.add('flex'); + } + } +} + +async function pickFolderLocation(groupId) { + if (typeof window.electronAPI !== 'undefined' && window.electronAPI.pickFolder) { + try { + const result = await window.electronAPI.pickFolder(); + if (result && result.path) { + await saveGroupLocation(result.path, groupId); + } + } catch (err) { + console.error('Failed to pick folder:', err); + } + return; + } + + // Try File System Access API (Chromium browsers only) + if (typeof window.showDirectoryPicker === 'function') { + try { + const dirHandle = await window.showDirectoryPicker(); + const path = dirHandle.name; + if (path) { + await saveGroupLocation(path, groupId); + } + return; + } catch (err) { + if (err.name !== 'AbortError') { + console.error('File System Access API failed:', err); + } + } + } + + // Fallback: prompt user to enter folder path + const path = prompt('Enter the full path to your workspace folder:'); + if (path && path.trim()) { + await saveGroupLocation(path.trim(), groupId); + } +} + +async function saveGroupLocation(location, groupId) { + const group = groupId ? groups.find(g => g.id === groupId) || groups[0] : (selectedGroupId ? groups.find(g => g.id === selectedGroupId) || groups[0] : groups[0]); + try { + const res = await fetch(`/api/groups/${group.id}?action=set_location`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ location }), + }); + if (res.ok) { + groupLocations[group.id] = location; + if (groupId) { + selectedGroupId = groupId; + await loadGroupLocation(groupId); + } else { + await loadGroupLocation(); + } + await loadConversations(); + renderAgentButtons(); + } + } catch (err) { + console.error('Failed to save group location:', err); + } +} + +async function changeGroupLocation(groupId) { + await pickFolderLocation(groupId); +} + +async function loadAgents() { + try { + const res = await fetch('/api/agents'); + agents = await res.json(); + renderAgentButtons(); + } catch (err) { + console.error('Failed to load agents:', err); + } +} + +function renderAgentButtons() { + const container = document.getElementById('agentSelector'); + if (!container || agents.length === 0) return; + + container.innerHTML = ''; + + const noneBtn = document.createElement('button'); + noneBtn.type = 'button'; + noneBtn.dataset.agent = 'none'; + noneBtn.className = `agent-btn px-3 py-1 text-xs font-medium rounded-lg border border-gray-300 text-gray-600 hover:bg-gray-100 transition-colors ${selectedAgent === 'none' ? 'bg-blue-50 border-blue-300 text-blue-700' : ''}`; + noneBtn.textContent = 'None'; + noneBtn.addEventListener('click', () => { + selectedAgent = 'none'; + renderAgentButtons(); + }); + container.appendChild(noneBtn); + + for (const agent of agents) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.dataset.agent = agent.type; + + const requiresWorkspace = agent.requires_workspace; + const hasWorkspace = selectedGroupId && groupLocations[selectedGroupId]; + const hasWorkspaceNeeded = requiresWorkspace && !hasWorkspace; + + const baseClass = `agent-btn px-3 py-1 text-xs font-medium rounded-lg border text-gray-600 hover:bg-gray-100 transition-colors ${selectedAgent === agent.type ? 'bg-blue-50 border-blue-300 text-blue-700' : ''}`; + + if (hasWorkspaceNeeded) { + btn.className = `${baseClass} opacity-50 cursor-not-allowed`; + btn.title = `To use ${agent.display_name}, you must set a workspace folder location in workspace settings`; + btn.disabled = true; + } else { + btn.className = baseClass; + btn.title = agent.description; + btn.addEventListener('click', () => { + selectedAgent = agent.type; + renderAgentButtons(); + }); + } + + btn.innerHTML = ` + + ${agent.icon} + ${agent.display_name} + + `; + container.appendChild(btn); + } +} + +async function openGroupSettings(groupId) { + currentSettingsGroupId = groupId; + const group = groups.find(g => g.id === groupId); + if (!group) return; + + const modal = document.getElementById('groupSettingsModal'); + const nameInput = document.getElementById('groupSettingsNameInput'); + nameInput.value = group.name; + + modal.classList.remove('hidden'); + modal.classList.add('flex'); + setTimeout(() => nameInput.focus(), 50); + + await loadGroupLocation(groupId); +} + +function closeGroupSettingsModal() { + const modal = document.getElementById('groupSettingsModal'); + modal.classList.add('hidden'); + modal.classList.remove('flex'); + currentSettingsGroupId = null; +} + +async function saveGroupSettings() { + if (!currentSettingsGroupId) return; + const group = groups.find(g => g.id === currentSettingsGroupId); + if (!group) return; + + const nameInput = document.getElementById('groupSettingsNameInput'); + const name = nameInput.value.trim(); + if (!name) return; + + try { + await fetch(`/api/groups/${group.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + group.name = name; + closeGroupSettingsModal(); + await loadConversations(); + } catch (err) { + console.error('Failed to save group settings:', err); + } +} + +// Initialize +document.addEventListener('DOMContentLoaded', async () => { + + document.getElementById('messageForm').addEventListener('submit', (e) => { + e.preventDefault(); + sendMessage(); + }); + document.getElementById('cancelBtn').addEventListener('click', () => { + if (!isStreaming || !abortController) return; + cancelRequested = true; + if (activeStreamUi) { + activeStreamUi.setStatus('Stopping...', 'cancelled'); + } + abortController.abort(); + updateStreamingControls(); + }); + + document.getElementById('newConversationBtn').addEventListener('click', createConversation); + document.getElementById('newGroupBtn').addEventListener('click', createGroup); + + document.getElementById('messageInput').addEventListener('keydown', (e) => { + if ( + e.key === 'Enter' && + !e.shiftKey && + !e.altKey && + !e.ctrlKey && + !e.metaKey && + !e.isComposing + ) { + e.preventDefault(); + sendMessage(); + } + }); + const messageInput = document.getElementById('messageInput'); + if (messageInput) { + resizeMessageInput(messageInput); + messageInput.addEventListener('input', () => resizeMessageInput(messageInput)); + window.addEventListener('resize', () => resizeMessageInput(messageInput)); + } + + // Mobile menu + const mobileMenuBtns = document.querySelectorAll('#mobileMenuBtn'); + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + mobileMenuBtns.forEach(btn => { + btn.addEventListener('click', () => { + sidebar.classList.toggle('open'); + overlay.classList.toggle('open'); + }); + }); + overlay.addEventListener('click', () => { + sidebar.classList.remove('open'); + overlay.classList.remove('open'); + }); + + // Mobile menu desktop button + const mobileMenuBtnDesktop = document.getElementById('mobileMenuBtnDesktop'); + if (mobileMenuBtnDesktop) { + mobileMenuBtnDesktop.addEventListener('click', () => { + sidebar.classList.toggle('open'); + overlay.classList.toggle('open'); + }); + } + + // Group modal + const groupModal = document.getElementById('groupModal'); + document.getElementById('closeGroupModal').addEventListener('click', closeGroupModal); + document.getElementById('cancelGroupBtn').addEventListener('click', closeGroupModal); + document.getElementById('groupForm').addEventListener('submit', async (e) => { + e.preventDefault(); + await createGroupFromModal(); + }); + groupModal.addEventListener('click', (e) => { + if (e.target === groupModal) { + closeGroupModal(); + } + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeGroupModal(); + } + }); + + // Group settings modal + const groupSettingsModal = document.getElementById('groupSettingsModal'); + document.getElementById('closeGroupSettingsModal').addEventListener('click', closeGroupSettingsModal); + document.getElementById('cancelGroupSettingsBtn').addEventListener('click', closeGroupSettingsModal); + document.getElementById('groupSettingsForm').addEventListener('submit', async (e) => { + e.preventDefault(); + await saveGroupSettings(); + }); + groupSettingsModal.addEventListener('click', (e) => { + if (e.target === groupSettingsModal) { + closeGroupSettingsModal(); + } + }); + document.getElementById('groupSettingsPickBtn').addEventListener('click', () => { + pickFolderLocation(currentSettingsGroupId); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeGroupSettingsModal(); + } + }); + + // Runtime settings modal + const settingsModal = document.getElementById('settingsModal'); + document.getElementById('openSettingsBtn').addEventListener('click', openLLMSettingsModal); + document.getElementById('closeSettingsModal').addEventListener('click', closeLLMSettingsModal); + document.getElementById('cancelSettingsBtn').addEventListener('click', closeLLMSettingsModal); + document.getElementById('settingsForm').addEventListener('submit', async (e) => { + e.preventDefault(); + await saveLLMSettings(); + }); + settingsModal.addEventListener('click', (e) => { + if (e.target === settingsModal) { + closeLLMSettingsModal(); + } + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeLLMSettingsModal(); + } + }); + + // Load runtime settings before rendering history so reasoning panels use the right default. + await loadLLMSettings().catch((err) => { + console.error('Failed to preload runtime settings:', err); + }); + + // Load and render agents after the initial conversation list is ready. + await loadConversations(); + await loadGroupLocation(); + loadAgents(); + + // Folder picker button in header + document.getElementById('pickLocationBtn').addEventListener('click', pickFolderLocation); + + // Change location button (shown when location is locked) + document.getElementById('changeLocationBtn').addEventListener('click', changeGroupLocation); + + updateStreamingControls(); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..aecb995 --- /dev/null +++ b/web/index.html @@ -0,0 +1,406 @@ + + + + + + GoCoder + + + + + + +
+ + + + + + + + + + + + + + + + +
+ +
+ +
+

GoCoder

+ + + +
+ +
+ + +
+ +
+
+ +
+

Start a conversation

+

Type a message to begin, or select an existing conversation

+ +
+
+ + +
+
+ Agent: +
+ + + + +
+
+
+ + + +
+
+ Enter sends + Shift+Enter adds a new line + Alt, Ctrl, or Cmd+Enter also add a new line +
+
+
+
+ + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..fe0dc48 --- /dev/null +++ b/web/style.css @@ -0,0 +1,302 @@ +.markdown-body { + line-height: 1.6; +} + +.markdown-body p { + margin: 0.5em 0; +} + +.markdown-body p:first-child { + margin-top: 0; +} + +.markdown-body p:last-child { + margin-bottom: 0; +} + +.markdown-body pre { + background: #1e1e2e; + color: #cdd6f4; + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; + margin: 0.5em 0; +} + +.markdown-body code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.875em; +} + +.markdown-body :not(pre) > code { + background: #e5e7eb; + color: #1e40af; + padding: 0.125em 0.375em; + border-radius: 0.25rem; +} + +.markdown-body pre code { + background: none; + padding: 0; + color: inherit; + font-size: 0.8125rem; + line-height: 1.7; +} + +.markdown-body blockquote { + border-left: 3px solid #d1d5db; + padding-left: 1rem; + color: #6b7280; + margin: 0.5em 0; +} + +.markdown-body ul, +.markdown-body ol { + padding-left: 1.5rem; + margin: 0.5em 0; +} + +.markdown-body li { + margin: 0.25em 0; +} + +.markdown-body li > p { + display: inline; +} + +.markdown-body hr { + border: none; + border-top: 1px solid #d1d5db; + margin: 1em 0; +} + +.markdown-body table { + border-collapse: separate; + border-spacing: 0; + width: 100%; + margin: 0.5em 0; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + overflow: hidden; +} + +.markdown-body th, +.markdown-body td { + border: none; + padding: 0.5rem 0.875rem; + text-align: left; +} + +.markdown-body th { + background: #f9fafb; + font-weight: 600; + color: #374151; + border-bottom: 2px solid #e5e7eb; +} + +.markdown-body tr:nth-child(even) { + background: #fafbfc; +} + +.markdown-body tr:nth-child(odd) { + background: #ffffff; +} + +.markdown-body tr:hover { + background: #f3f4f6; +} + +.markdown-body a { + color: #2563eb; + text-decoration: underline; +} + +.markdown-body img { + max-width: 100%; + border-radius: 0.375rem; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + margin: 1em 0 0.5em 0; + font-weight: 600; + line-height: 1.3; +} + +.markdown-body h1 { + font-size: 1.5rem; +} + +.markdown-body h2 { + font-size: 1.25rem; +} + +.markdown-body h3 { + font-size: 1.125rem; +} + +.thinking-panel > summary { + list-style: none; +} + +.thinking-panel > summary::-webkit-details-marker { + display: none; +} + +.thinking-chevron { + transition: transform 0.15s ease; +} + +.thinking-panel[open] .thinking-chevron { + transform: rotate(90deg); +} + +.tool-meta-list { + display: grid; + gap: 0.5rem; +} + +.tool-meta-row { + display: grid; + grid-template-columns: 6.5rem minmax(0, 1fr); + gap: 0.75rem; + align-items: start; +} + +.tool-meta-key { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #94a3b8; +} + +.tool-meta-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5; + color: #0f172a; + white-space: pre-wrap; + word-break: break-word; +} + +.tool-text-block { + margin: 0; + max-height: 18rem; + overflow: auto; + border: 1px solid #dbe4f0; + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.95); + padding: 0.875rem 1rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5rem; + color: #0f172a; + white-space: pre-wrap; + word-break: break-word; +} + +.tool-text-block-error { + border-color: #fecaca; + background: #fff1f2; + color: #7f1d1d; +} + +.tool-placeholder { + border: 1px dashed #cbd5e1; + border-radius: 0.75rem; + padding: 0.75rem 1rem; + font-size: 0.875rem; + color: #64748b; + font-style: italic; +} + +.tool-file-preview { + max-height: 12.25rem; + overflow-y: auto; + border: 1px solid #dbe4f0; + border-radius: 0.75rem; + background: rgba(255, 255, 255, 0.95); +} + +.tool-file-preview.is-expanded { + max-height: 60vh; +} + +.tool-file-preview pre { + margin: 0; + padding: 0.875rem 1rem; + background: transparent; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + line-height: 1.5rem; + color: inherit; + white-space: pre-wrap; + word-break: break-word; +} + +.tool-diff { + overflow: hidden; + border-radius: 0.75rem; + border: 1px solid #0f172a; + background: #0f172a; +} + +.tool-diff-header { + padding: 0.625rem 0.875rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + color: #cbd5e1; +} + +.tool-diff-body { + max-height: 18rem; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + line-height: 1.5rem; +} + +.tool-diff-line { + display: flex; + gap: 0.5rem; + padding: 0 0.875rem; + white-space: pre-wrap; + word-break: break-word; +} + +.tool-diff-prefix { + flex: 0 0 1rem; + text-align: center; + user-select: none; + opacity: 0.7; +} + +.tool-diff-remove { + background: rgba(239, 68, 68, 0.08); + color: #fecaca; +} + +.tool-diff-add { + background: rgba(16, 185, 129, 0.08); + color: #a7f3d0; +} + +.tool-diff-note { + padding: 0.875rem; + color: #cbd5e1; +} + +.message-input { + resize: none; + overflow-y: hidden; + min-height: 3.5rem; + line-height: 1.5rem; +}