This commit is contained in:
2026-04-21 13:19:17 +03:00
commit 1d52096e38
21 changed files with 8991 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
gocoder

79
agents/agent.go Normal file
View File

@@ -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
}

40
agents/coding_agent.go Normal file
View File

@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`
}
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
}

40
agents/compactor.go Normal file
View File

@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"/><path d="M6 7v12h12V7"/><path d="M9 11h6"/><path d="M9 15h6"/></svg>`
}
func (a *CompactorAgent) SystemMessage() string {
return ContextCompactorSystemMessage()
}
func (a *CompactorAgent) ShouldRespond(message string) bool {
return true
}
func (a *CompactorAgent) RequiresWorkspace() bool {
return false
}

33
agents/dadjokes.go Normal file
View File

@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>`
}
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
}

31
agents/poet.go Normal file
View File

@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v18"/><path d="M8 7c0-2.2 1.8-4 4-4s4 1.8 4 4"/><path d="M4 14c0-2.2 1.8-4 4-4"/><path d="M20 14c0-2.2-1.8-4-4-4"/></svg>`
}
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
}

62
agents/robot.go Normal file
View File

@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>`
}
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
}

46
build.sh Executable file
View File

@@ -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"

21
config.json Normal file
View File

@@ -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"
}
}

336
context_compaction.go Normal file
View File

@@ -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
}

601
db/db.go Normal file
View File

@@ -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, &notNull, &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()
}

211
db/db_test.go Normal file
View File

@@ -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)
}
}

19
go.mod Normal file
View File

@@ -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
)

51
go.sum Normal file
View File

@@ -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=

2508
main.go Normal file

File diff suppressed because it is too large Load Diff

614
main_test.go Normal file
View File

@@ -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 <think>inner</think> 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)
}
}

981
tools/tools.go Normal file
View File

@@ -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
}

356
tools/tools_test.go Normal file
View File

@@ -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))
}
})
}

2252
web/app.js Normal file

File diff suppressed because it is too large Load Diff

406
web/index.html Normal file
View File

@@ -0,0 +1,406 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoCoder</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="style.css">
<style>
.conversation-item {
border-left: 3px solid transparent;
}
.conversation-item:hover {
border-left-color: #cbd5e1;
}
.conversation-item.active {
border-left-color: #3b82f6;
background-color: #eff6ff;
color: #1d4ed8;
}
.conversation-item.active .text-gray-400 {
color: #3b82f6;
}
.loading-dots {
display: inline-flex;
align-items: center;
}
.loading-dots .dot {
animation: blink 1.4s infinite both;
animation-duration: 1.4s;
}
.loading-dots .dot:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dots .dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0%, 80%, 100% { opacity: 0; }
40% { opacity: 1; }
}
.sidebar {
transition: transform 0.2s ease;
}
@media (max-width: 768px) {
.sidebar {
position: fixed;
z-index: 50;
height: 100vh;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
z-index: 40;
}
.sidebar-overlay.open {
display: block;
}
}
</style>
</head>
<body class="min-h-screen bg-gray-50">
<div class="flex h-screen">
<!-- Sidebar -->
<div class="sidebar w-72 bg-white border-r border-gray-200 flex flex-col" id="sidebar">
<div class="p-4 border-b border-gray-200">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-800">Workspaces</h2>
<div class="flex gap-1">
<button id="newGroupBtn" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-600 transition-colors" title="New workspace">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</button>
<button id="newConversationBtn" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-600 transition-colors" title="New conversation">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
</div>
</div>
<div id="conversationList" class="flex-1 overflow-y-auto p-2 space-y-3">
</div>
<div class="p-3 border-t border-gray-200">
<button id="mobileMenuBtn" class="md:hidden w-full text-center text-sm text-gray-500 hover:text-gray-700 py-2">
Close sidebar
</button>
</div>
</div>
<!-- Sidebar overlay for mobile -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Group settings modal -->
<div id="groupSettingsModal" class="fixed inset-0 z-50 hidden items-center justify-center" style="background: rgba(0,0,0,0.4);">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div class="p-6">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-semibold text-gray-800">Workspace Settings</h3>
<button id="closeGroupSettingsModal" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<form id="groupSettingsForm">
<label class="block text-sm font-medium text-gray-700 mb-1.5">Workspace name</label>
<input
type="text"
id="groupSettingsNameInput"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800"
required
>
<label class="block text-sm font-medium text-gray-700 mb-1.5 mt-4">Workspace folder</label>
<div id="groupSettingsLocation" class="hidden mb-3 px-3 py-2 bg-gray-50 border border-gray-200 rounded-xl">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-400 flex-shrink-0"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
<span id="groupSettingsLocationText" class="text-sm text-gray-600 truncate"></span>
</div>
</div>
<button type="button" id="groupSettingsPickBtn" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition-colors flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
Pick Folder
</button>
<div class="flex gap-3 mt-5">
<button type="button" id="cancelGroupSettingsBtn" class="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition-colors">
Cancel
</button>
<button type="submit" class="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-colors">
Save
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Runtime settings modal -->
<div id="settingsModal" class="fixed inset-0 z-50 hidden items-center justify-center p-4" style="background: rgba(0,0,0,0.4);">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
<div class="max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-gray-800">Runtime Settings</h3>
<p class="mt-1 text-sm text-gray-500">Edit the model endpoint, generation params, server settings, and compaction budget used by the chat runtime.</p>
</div>
<button id="closeSettingsModal" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<form id="settingsForm">
<div class="space-y-4">
<div class="rounded-2xl border border-gray-200 bg-gray-50/70 p-4">
<div class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
<span>Connection</span>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsProvider">Provider</label>
<input type="text" id="settingsProvider" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsModel">Model</label>
<input type="text" id="settingsModel" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsBaseURL">Base URL</label>
<input type="url" id="settingsBaseURL" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsAPIKey">API key</label>
<input type="password" id="settingsAPIKey" autocomplete="off" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
<p class="mt-1 text-xs text-gray-500">Leave as-is to keep the current key or replace it with a new one.</p>
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50/70 p-4">
<div class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
<span>Server</span>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsServerHost">Host</label>
<input type="text" id="settingsServerHost" required placeholder="localhost" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsServerAddr">Address</label>
<input type="text" id="settingsServerAddr" required placeholder=":8080" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div class="md:col-span-2">
<p class="text-xs text-gray-500">Server settings are saved immediately but only take effect after you restart the app.</p>
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50/70 p-4">
<div class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
<span>Generation</span>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsTemperature">Temperature</label>
<input type="number" id="settingsTemperature" min="0" step="0.01" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsMaxTokens">Max tokens</label>
<input type="number" id="settingsMaxTokens" min="1" step="1" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsTopP">Top P</label>
<input type="number" id="settingsTopP" min="0.01" max="1" step="0.01" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsFrequencyPenalty">Frequency penalty</label>
<input type="number" id="settingsFrequencyPenalty" step="0.1" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsPresencePenalty">Presence penalty</label>
<input type="number" id="settingsPresencePenalty" step="0.1" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsReasoningEffort">Reasoning effort</label>
<select id="settingsReasoningEffort" class="w-full px-4 py-2.5 border border-gray-300 rounded-xl bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
<option value="">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<p class="mt-1 text-xs text-gray-500">Forwarded to LM Studio when the backend supports reasoning controls.</p>
</div>
<div class="md:col-span-2 flex items-start gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3">
<input type="checkbox" id="settingsAutoOpenReasoning" class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div class="min-w-0">
<label class="block text-sm font-medium text-gray-700" for="settingsAutoOpenReasoning">Auto-open reasoning</label>
<p class="mt-1 text-xs text-gray-500">When enabled, live reasoning starts expanded. You can still collapse it while the model is thinking.</p>
</div>
</div>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50/70 p-4">
<div class="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
<span>Context compaction</span>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsContextTokens">Prompt budget (tokens)</label>
<input type="number" id="settingsContextTokens" min="1" step="1" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
<p class="mt-1 text-xs text-gray-500">This is the estimated prompt size budget used before compaction starts.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsTriggerRatio">Compaction trigger</label>
<input type="number" id="settingsTriggerRatio" min="0.01" max="0.99" step="0.01" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
<p class="mt-1 text-xs text-gray-500">Compaction starts above this fraction of the budget.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5" for="settingsTargetRatio">Compaction target</label>
<input type="number" id="settingsTargetRatio" min="0.01" max="0.99" step="0.01" required class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800">
<p class="mt-1 text-xs text-gray-500">The summarizer tries to shrink history below this fraction.</p>
</div>
</div>
</div>
</div>
<p id="settingsFormError" class="mt-4 hidden text-sm text-red-600"></p>
<div class="flex gap-3 mt-5">
<button type="button" id="cancelSettingsBtn" class="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition-colors">
Cancel
</button>
<button type="submit" class="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-colors">
Save
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Group creation modal -->
<div id="groupModal" class="fixed inset-0 z-50 hidden items-center justify-center" style="background: rgba(0,0,0,0.4);">
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 overflow-hidden">
<div class="p-6">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-semibold text-gray-800">New Workspace</h3>
<button id="closeGroupModal" class="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<form id="groupForm">
<label class="block text-sm font-medium text-gray-700 mb-1.5">Workspace name</label>
<input
type="text"
id="groupNameInput"
placeholder="e.g. My Project"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800 placeholder:text-gray-400"
required
autofocus
>
<div class="flex gap-3 mt-5">
<button type="button" id="cancelGroupBtn" class="flex-1 px-4 py-2.5 border border-gray-300 rounded-xl text-gray-700 font-medium hover:bg-gray-50 transition-colors">
Cancel
</button>
<button type="submit" class="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-xl transition-colors">
Create
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Main content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Header -->
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center gap-4">
<button id="mobileMenuBtnDesktop" class="md:hidden p-2 rounded-lg hover:bg-gray-100 text-gray-600 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="flex items-center gap-2 flex-1 min-w-0">
<h1 class="text-xl font-bold text-gray-800">GoCoder</h1>
<span class="text-gray-300 text-lg leading-none mx-1 flex-shrink-0"></span>
<div id="headerLocation" class="hidden items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-400"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
<span id="headerLocationText" class="text-sm text-gray-500 truncate max-w-xs"></span>
<button id="changeLocationBtn" class="hidden p-1 rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors" title="Change location">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>
</button>
</div>
<button id="pickLocationBtn" class="hidden items-center gap-1 px-2.5 py-1 text-xs bg-gray-100 hover:bg-gray-200 text-gray-600 rounded-lg transition-colors border border-gray-200">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></svg>
Pick Location
</button>
</div>
<button id="openSettingsBtn" class="p-2 rounded-lg hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors" title="Runtime settings">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<!-- Chat area -->
<div id="chatScrollArea" class="flex-1 overflow-y-auto px-4 md:px-8 lg:px-16 py-6">
<div id="result" class="hidden">
<div id="resultText" class="space-y-4"></div>
</div>
<div id="emptyState" class="flex flex-col items-center justify-center h-full text-center">
<div class="w-20 h-20 bg-gray-100 rounded-2xl flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</div>
<h2 class="text-2xl font-semibold text-gray-700 mb-2">Start a conversation</h2>
<p class="text-gray-500 mb-6">Type a message to begin, or select an existing conversation</p>
<button id="newConversationBtnDesktop" class="bg-blue-600 hover:bg-blue-700 text-white font-medium px-6 py-3 rounded-lg transition-colors">
New Conversation
</button>
</div>
</div>
<!-- Input area -->
<div class="bg-white border-t border-gray-200 px-4 md:px-8 lg:px-16 py-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-gray-500 font-medium">Agent:</span>
<div id="agentSelector" class="flex gap-1.5 flex-wrap">
<button type="button" data-agent="none" class="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 bg-blue-50 border-blue-300 text-blue-700">None</button>
<button type="button" data-agent="dadjokes" class="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">Dadjokes</button>
<button type="button" data-agent="poet" class="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">Poet</button>
<button type="button" data-agent="robot" class="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">Robot</button>
</div>
</div>
<form id="messageForm" class="flex items-end gap-3">
<textarea
id="messageInput"
name="message"
rows="1"
placeholder="Type a message... Enter sends, Shift+Enter adds a new line"
aria-describedby="messageInputHint"
class="message-input flex-1 px-4 py-3 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-800"
required
></textarea>
<button
type="submit"
id="sendBtn"
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white font-medium px-6 py-3 rounded-xl transition-colors"
>
Send
</button>
<button
type="button"
id="cancelBtn"
class="hidden border border-red-200 bg-white px-5 py-3 font-medium text-red-600 rounded-xl transition-colors hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
Stop
</button>
</form>
<div id="messageInputHint" class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-500">
<span><kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Enter</kbd> sends</span>
<span><kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Shift</kbd>+<kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Enter</kbd> adds a new line</span>
<span><kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Alt</kbd>, <kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Ctrl</kbd>, or <kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Cmd</kbd>+<kbd class="rounded border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[10px] text-gray-700">Enter</kbd> also add a new line</span>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

302
web/style.css Normal file
View File

@@ -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;
}