package main import ( "bytes" "context" "encoding/json" "fmt" "log" "os" "os/exec" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "ollie/agent" "ollie/backend" "ollie/config" execpkg "ollie/exec" "ollie/mcp" "ollie/tools" ) const systemPromptBase = `Be terse. No preamble, narration, or filler ("Let me...", "I'll now...", "Great!"). Output only errors, ambiguities requiring clarification, and deliverables. Do not restate tasks, hedge, or self-congratulate. Always use tools to perform actions; never simulate or guess outputs. Do not attempt tasks outside your tools. Use execute_code for all shell commands and scripts. Use execute_tool only for named scripts in ~/mnt/anvillm/tools. Use execute_pipe to chain steps: use {code: "cmd --flags"} for shell commands, {tool, args} only for named scripts in ~/mnt/anvillm/tools.` func systemPrompt(allTools []backend.Tool) string { cwd, _ := os.Getwd() now := time.Now().Format("2006-01-02 15:04:05 MST") names := make([]string, len(allTools)) for i, t := range allTools { names[i] = t.Name } s := systemPromptBase + "\n\nWorking directory: " + cwd + "\nCurrent time: " + now + "\nAvailable tools: " + strings.Join(names, ", ") return s } var builtinTools = []backend.Tool{ { Name: "execute_code", Description: "Run inline code in a sandboxed environment.", Parameters: json.RawMessage(`{ "type": "object", "required": ["code"], "properties": { "code": {"type": "string", "description": "Code to execute."}, "language": {"type": "string", "description": "Language interpreter (default: bash)."}, "timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."}, "sandbox": {"type": "string", "description": "Sandbox name (default: default)."} } }`), }, { Name: "execute_tool", Description: "Run a named tool script from ~/mnt/anvillm/tools in a sandboxed environment. Use this only for named scripts, not for inline shell commands.", Parameters: json.RawMessage(`{ "type": "object", "required": ["tool"], "properties": { "tool": {"type": "string", "description": "Name of the tool script (e.g. discover_skill.sh)."}, "args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."}, "timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."}, "sandbox": {"type": "string", "description": "Sandbox name (default: default)."} } }`), }, { Name: "execute_pipe", Description: "Run a pipeline of steps, piping stdout of each into stdin of the next. Use {code: \"cmd --flags\"} for shell commands; use {tool, args} only for named scripts in ~/mnt/anvillm/tools.", Parameters: json.RawMessage(`{ "type": "object", "required": ["pipe"], "properties": { "pipe": { "type": "array", "items": { "type": "object", "properties": { "tool": {"type": "string"}, "args": {"type": "array", "items": {"type": "string"}}, "code": {"type": "string"} } } }, "timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."}, "sandbox": {"type": "string", "description": "Sandbox name (default: default)."} } }`), }, } // agentState represents what the agent is currently doing. type agentState int const ( agentIdle agentState = iota agentThinking agentRunningTool agentRetrying ) // resolveBackendName returns a short human-readable backend label derived // from OLLIE_BACKEND and (for openai-compatible backends) OLLIE_OPENAI_URL. // Known provider hostnames are mapped to friendly names so that e.g. // openrouter.ai shows up as "openrouter" rather than "openai". func resolveBackendName() string { which := os.Getenv("OLLIE_BACKEND") if which == "" { which = "ollama" } if which != "openai" { return which } // For openai-compatible backends, try to identify the provider from the URL. url := strings.ToLower(os.Getenv("OLLIE_OPENAI_URL")) switch { case strings.Contains(url, "openrouter"): return "openrouter" case strings.Contains(url, "together"): return "together" case strings.Contains(url, "groq"): return "groq" case strings.Contains(url, "mistral"): return "mistral" case strings.Contains(url, "anthropic"): return "anthropic" case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): return "local" case url == "": return "openai" default: return "openai" } } // NOTE: buf and display lines use plain strings; bubbletea copies the model // by value on every Update(), so strings.Builder and other copy-sensitive // types will panic. type model struct { textarea textarea.Model viewport viewport.Model session *agent.Session loopcfg agent.Config display []string buf string // live streaming assistant text hooks map[string]string ready bool agentCh chan tea.Msg cancel context.CancelFunc doneCh chan struct{} modelName string backendName string // e.g. "ollama", "openrouter", "openai" agentName string // active agent config name, e.g. "default" agentsDir string // path to ~/.config/ollie/agents/ builtinExec *execpkg.Executor ctxOverhead int // fixed per-request char overhead (system prompt + tool schemas) quitPending bool // whether a second Ctrl+C should quit lastCtrlC time.Time // timestamp of last Ctrl+C press // status bar state state agentState currentTool string retrySecsLeft int lastUsage backend.Usage ctxStats agent.ContextStats } type agentMsg struct { role string content string name string done bool usage backend.Usage ctxStats agent.ContextStats } // statusBarStyle is a full-width reversed bar. var statusBarStyle = lipgloss.NewStyle(). Reverse(true). Padding(0, 1) // timeoutMsg is sent when the Ctrl+C double-press window expires type timeoutMsg struct{} // agentEnv holds the runtime state derived from an agent config file. type agentEnv struct { tools []backend.Tool exec agent.ToolExecutor hooks map[string]string systemPrompt string ctxOverhead int messages []string // startup / status messages to display } // buildAgentEnv constructs the runtime environment for a given agent config. // cfg may be nil (no agent file found), in which case only the builtin tool // is available and no hooks or agent prompt are set. func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { var messages []string mcpExec := tools.NewExecutor() if cfg != nil { for name, serverCfg := range cfg.MCPServers { if serverCfg.Disabled || serverCfg.Command == "" { continue } transport := mcp.NewSTDIOTransport(serverCfg.Command, serverCfg.Args, serverCfg.Env) client, err := transport.Connect() if err != nil { messages = append(messages, fmt.Sprintf("MCP %s: failed to connect: %v", name, err)) continue } mcpExec.AddServer(name, client) messages = append(messages, fmt.Sprintf("MCP %s: connected", name)) } } mcpTools, err := mcpExec.ListTools() if err != nil { messages = append(messages, fmt.Sprintf("MCP list tools: %v", err)) } else if len(mcpTools) > 0 { messages = append(messages, fmt.Sprintf("MCP tools loaded: %d", len(mcpTools))) } serverOf := make(map[string]string, len(mcpTools)) for _, t := range mcpTools { serverOf[t.Name] = t.Server } allTools := append(mcpToolsToBackend(mcpTools), builtinTools...) hooks := map[string]string{} agentPrompt := "" if cfg != nil { if cfg.Hooks != nil { hooks = cfg.Hooks } agentPrompt = cfg.Prompt } sp := systemPrompt(allTools) if agentPrompt != "" { sp = systemPrompt(allTools) + "\n\n" + agentPrompt } overhead := len(sp) for _, t := range allTools { overhead += len(t.Name) + len(t.Description) + len(t.Parameters) } builtinNames := make(map[string]struct{}, len(builtinTools)) for _, t := range builtinTools { builtinNames[t.Name] = struct{}{} } execFn := func(name string, args json.RawMessage) (string, error) { if server, ok := serverOf[name]; ok { raw, err := mcpExec.Execute(server, name, args) if err != nil { return "", err } return extractMCPText(raw), nil } if _, ok := builtinNames[name]; ok { return dispatchBuiltinExec(name, builtinExec, args) } return "", fmt.Errorf("unknown tool: %s", name) } return agentEnv{ tools: allTools, exec: execFn, hooks: hooks, systemPrompt: sp, ctxOverhead: overhead, messages: messages, } } // agentConfigPath returns the path for a named agent config, falling back to // the legacy config.json if the agents/ directory file does not exist. func agentConfigPath(agentsDir, name string) string { p := agentsDir + "/" + name + ".json" if _, err := os.Stat(p); err == nil { return p } // Legacy fallback: only for "default" if name == "default" { home, _ := os.UserHomeDir() return home + "/.config/ollie/config.json" } return p // will produce a load error, which is the right behaviour } func main() { home, _ := os.UserHomeDir() agentsDir := home + "/.config/ollie/agents" be, err := backend.New() if err != nil { log.Fatalf("Failed to create backend: %v", err) } modelName := os.Getenv("OLLIE_MODEL") if modelName == "" { modelName = "qwen3:8b" } // Resolve after backend.New() so that loadEnvFile has already run and // populated OLLIE_BACKEND / OLLIE_OPENAI_URL from ~/.config/ollie/env. backendName := resolveBackendName() builtinExec := execpkg.New( home+"/.local/state/ollie", home+"/.cache/ollie/exec", ) // Load the default agent config. agentName := os.Getenv("OLLIE_AGENT") if agentName == "" { agentName = "default" } if len(os.Args) > 1 { agentName = os.Args[1] } cfgPath := agentConfigPath(agentsDir, agentName) cfg, cfgErr := config.Load(cfgPath) var startup []string if cfgErr != nil && len(os.Args) > 2 { log.Fatalf("Failed to load agent config: %v", cfgErr) } else if cfgErr != nil { startup = append(startup, fmt.Sprintf("agent config: %v", cfgErr)) } env := buildAgentEnv(cfg, builtinExec) startup = append(startup, env.messages...) loopcfg := agent.Config{ Backend: be, Model: modelName, SystemPrompt: env.systemPrompt, Tools: env.tools, Exec: env.exec, MaxSteps: 20, } ta := textarea.New() ta.Placeholder = "Type your message..." ta.Prompt = "" ta.ShowLineNumbers = false ta.CharLimit = 0 ta.SetHeight(5) ta.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("ctrl+j")) ta.Focus() vp := viewport.New(0, 0) vp.KeyMap = viewport.KeyMap{ PageDown: key.NewBinding(key.WithKeys("pgdown")), PageUp: key.NewBinding(key.WithKeys("pgup")), HalfPageDown: key.NewBinding(key.WithKeys("alt+d")), HalfPageUp: key.NewBinding(key.WithKeys("alt+u")), Down: key.NewBinding(key.WithKeys("down", "alt+n")), Up: key.NewBinding(key.WithKeys("up", "alt+p")), } p := tea.NewProgram(model{ textarea: ta, viewport: vp, loopcfg: loopcfg, hooks: env.hooks, display: startup, modelName: modelName, backendName: backendName, agentName: agentName, agentsDir: agentsDir, builtinExec: builtinExec, ctxOverhead: env.ctxOverhead, quitPending: false, state: agentIdle, }) if hook := env.hooks["agentSpawn"]; hook != "" { exec.Command("sh", "-c", hook).Run() } if _, err := p.Run(); err != nil { log.Fatal(err) } } func (m model) Init() tea.Cmd { return textarea.Blink } // renderDisplay produces the full viewport text. func (m model) renderDisplay() string { var b strings.Builder for i, line := range m.display { if i > 0 { b.WriteByte('\n') } b.WriteString(line) } if m.buf != "" { if len(m.display) > 0 { b.WriteString("\n\n") } b.WriteString("Bot: " + m.buf) } return b.String() } func (m *model) refreshView() { m.viewport.SetContent(wordWrap(m.renderDisplay(), m.viewport.Width)) m.viewport.GotoBottom() } // renderStatusBar returns the one-line status bar string. func (m model) renderStatusBar() string { // Agent state segment. var stateStr string switch m.state { case agentIdle: stateStr = "idle" case agentThinking: stateStr = "thinking\u2026" case agentRunningTool: stateStr = "tool: " + m.currentTool case agentRetrying: stateStr = fmt.Sprintf("retry %ds", m.retrySecsLeft) } // Token usage segment. usageStr := "last: -" if m.lastUsage.InputTokens > 0 || m.lastUsage.OutputTokens > 0 { usageStr = fmt.Sprintf("last: \u2191%d \u2193%d", m.lastUsage.InputTokens, m.lastUsage.OutputTokens) } // Context stats segment. ctxStr := "ctx: -" if m.ctxStats.StoredMessages > 0 { ktok := float64(m.ctxStats.ApproxTokens) / 1000.0 ctxStr = fmt.Sprintf("ctx: ~%.1fk tokens (%d msgs)", ktok, m.ctxStats.BoundedMessages) if m.ctxStats.Evicted > 0 { ctxStr += fmt.Sprintf(", %d evicted", m.ctxStats.Evicted) } } bar := fmt.Sprintf("[%s :: %s :: %s] %s | %s | %s", m.backendName, m.modelName, m.agentName, stateStr, usageStr, ctxStr) return statusBarStyle.Width(m.viewport.Width).Render(bar) } // finalizeBuf commits the in-progress streaming text as a new display line. // addStreamingContent concatenates a streaming chunk onto the buffer, // inserting a single space when neither side has whitespace at the boundary. // // Some backends (e.g. DeepInfra) occasionally drop whitespace-only tokens // (emitting null or "" for a space token), causing words to fuse. Since // LLM APIs always deliver complete BPE tokens — which never split mid-word — // inserting a space whenever two non-space runes meet is safe in practice. func addStreamingContent(buf, chunk string) string { return buf + chunk } func (m *model) appendDisplay(line string) { if len(m.display) > 0 { m.display = append(m.display, "") } m.display = append(m.display, line) } func (m *model) finalizeBuf() { if m.buf != "" { m.appendDisplay("Bot: " + m.buf) m.buf = "" } } // apply writes one agent output event into the model. func (m *model) apply(am agentMsg) { switch am.role { case "assistant": m.state = agentThinking m.buf = addStreamingContent(m.buf, am.content) case "call": m.state = agentRunningTool m.currentTool = am.name m.finalizeBuf() args := squashWhitespace(am.content) if len(args) > 500 { args = args[:500] + "..." } m.appendDisplay(fmt.Sprintf("-> %s(%s)", am.name, args)) case "tool": m.state = agentThinking lines := strings.Split(strings.TrimRight(am.content, "\n"), "\n") var squashed []string for _, l := range lines { squashed = append(squashed, squashWhitespace(l)) } s := strings.Join(squashed, "\n") if len(s) > 500 { s = s[:500] + "..." } m.appendDisplay("= " + s) case "retry": m.state = agentRetrying if secs, err := strconv.Atoi(am.content); err == nil { m.retrySecsLeft = secs } case "error": m.finalizeBuf() m.appendDisplay("Error: " + am.content) case "usage": // Update status bar only; no display line appended. m.lastUsage = am.usage m.ctxStats = am.ctxStats } } // drainAgent cancels the in-flight goroutine and drains remaining events. func (m *model) drainAgent() { if m.cancel == nil { return } m.cancel() m.cancel = nil if m.doneCh != nil { <-m.doneCh m.doneCh = nil } if m.agentCh != nil { for msg := range m.agentCh { if am, ok := msg.(agentMsg); ok { m.apply(am) } } m.agentCh = nil } } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: // Reserve 1 row for the status bar, 5 for the textarea. m.viewport.Width = msg.Width m.viewport.Height = msg.Height - 5 - 1 m.textarea.SetWidth(msg.Width) m.refreshView() m.ready = true case timeoutMsg: // Clear quitPending flag when timeout expires m.quitPending = false return m, nil case agentMsg: m.apply(msg) m.refreshView() if msg.done { m.state = agentIdle m.currentTool = "" m.agentCh = nil return m, nil } return m, func() tea.Msg { return <-m.agentCh } case tea.KeyMsg: switch msg.String() { case "esc": // ESC always interrupts if agent is running if m.cancel != nil { m.drainAgent() } m.quitPending = false return m, nil case "ctrl+c": now := time.Now() // Handle double-press to quit if m.quitPending && now.Sub(m.lastCtrlC) <= 1*time.Second { // Double Ctrl+C within 1 second: quit return m, tea.Quit } // First Ctrl+C: interrupt if agent running if m.cancel != nil { m.drainAgent() m.quitPending = false return m, nil } // No agent running, track for potential double-press if m.lastCtrlC.IsZero() { // First Ctrl+C with no agent m.lastCtrlC = now m.quitPending = true return m, tea.Cmd(func() tea.Msg { time.Sleep(1 * time.Second) return timeoutMsg{} }) } else if now.Sub(m.lastCtrlC) <= 1*time.Second { // Double Ctrl+C within 1 second with no agent: quit return m, tea.Quit } else { // Ctrl+C after timeout, reset and start new timer m.lastCtrlC = now m.quitPending = true return m, tea.Cmd(func() tea.Msg { time.Sleep(1 * time.Second) return timeoutMsg{} }) } case "enter": input := strings.TrimSpace(m.textarea.Value()) if input == "" { return m, nil } m.drainAgent() m.finalizeBuf() m.appendDisplay("You: " + input) m.refreshView() m.textarea.Reset() if hook := m.hooks["userPromptSubmit"]; hook != "" { exec.Command("sh", "-c", hook).Run() } if m.handleCommand(input) { m.refreshView() return m, nil } if m.session == nil { m.session = agent.NewSessionWithConfig(input, agent.ContextConfig{ FixedOverheadChars: m.ctxOverhead, }) } else { m.session.AppendUserMessage(input) } m.state = agentThinking ch, cancel, doneCh := m.startAgent(m.session) m.agentCh = ch m.cancel = cancel m.doneCh = doneCh return m, func() tea.Msg { return <-ch } } } var vpCmd tea.Cmd m.viewport, vpCmd = m.viewport.Update(msg) m.textarea, cmd = m.textarea.Update(msg) return m, tea.Batch(cmd, vpCmd) } // handleCommand processes special slash commands and returns true if handled. func (m *model) handleCommand(input string) bool { if !strings.HasPrefix(input, "/") { return false } parts := strings.Fields(input) if len(parts) == 0 { return false } cmd := parts[0] args := parts[1:] switch cmd { case "/backend": if len(args) == 0 { m.display = append(m.display, "Error: /backend requires an argument (e.g., /backend ollama)") return true } backendType := args[0] os.Setenv("OLLIE_BACKEND", backendType) be, err := backend.New() if err != nil { m.display = append(m.display, fmt.Sprintf("Error: Failed to switch backend: %v", err)) return true } m.loopcfg.Backend = be m.backendName = resolveBackendName() m.display = append(m.display, fmt.Sprintf("Switched backend to: %s", m.backendName)) return true case "/model": if len(args) == 0 { m.display = append(m.display, "Error: /model requires an argument (e.g., /model qwen3:8b)") return true } m.loopcfg.Model = args[0] m.modelName = args[0] m.display = append(m.display, fmt.Sprintf("Switched model to: %s", args[0])) return true case "/agent": if len(args) == 0 { m.display = append(m.display, fmt.Sprintf("active agent: %s", m.agentName)) return true } name := args[0] cfgPath := agentConfigPath(m.agentsDir, name) cfg, err := config.Load(cfgPath) if err != nil { m.display = append(m.display, fmt.Sprintf("Error: agent %q: %v", name, err)) return true } env := buildAgentEnv(cfg, m.builtinExec) m.hooks = env.hooks m.loopcfg.SystemPrompt = env.systemPrompt m.loopcfg.Tools = env.tools m.loopcfg.Exec = env.exec m.ctxOverhead = env.ctxOverhead m.agentName = name m.session = nil // new agent, new session for _, msg := range env.messages { m.display = append(m.display, msg) } m.display = append(m.display, fmt.Sprintf("agent: %s", name)) return true case "/help": m.display = append(m.display, "Available commands:") m.display = append(m.display, " /agent [name] - Show or switch active agent") m.display = append(m.display, " /backend - Switch backend (ollama, openai)") m.display = append(m.display, " /model - Switch model") m.display = append(m.display, " /help - Show this help") return true } return false } // startAgent launches the loop in a goroutine, wiring its output to ch. func (m model) startAgent(session *agent.Session) (chan tea.Msg, context.CancelFunc, chan struct{}) { loopcfg := m.loopcfg hooks := m.hooks ch := make(chan tea.Msg, 64) ctx, cancel := context.WithCancel(context.Background()) doneCh := make(chan struct{}) go func() { defer close(ch) defer close(doneCh) loopcfg.Output = func(em agent.OutputMsg) { var msg agentMsg msg.role = em.Role msg.content = em.Content msg.name = em.Name if em.Role == "usage" { msg.usage = em.Usage msg.ctxStats = session.ContextStats() } select { case ch <- msg: case <-ctx.Done(): return } } if err := agent.Run(ctx, loopcfg, session); err != nil { select { case ch <- agentMsg{role: "error", content: err.Error()}: case <-ctx.Done(): return } } if hook := hooks["stop"]; hook != "" { exec.Command("sh", "-c", hook).Run() } select { case ch <- agentMsg{done: true}: case <-ctx.Done(): } }() return ch, cancel, doneCh } func (m model) View() string { if !m.ready { return "Loading..." } return m.viewport.View() + "\n" + m.renderStatusBar() + "\n" + m.textarea.View() } // compactJSON removes whitespace between JSON tokens. func compactJSON(s string) string { var buf bytes.Buffer if err := json.Compact(&buf, []byte(s)); err != nil { return squashWhitespace(s) } return squashWhitespace(buf.String()) } // squashWhitespace collapses all runs of whitespace into single spaces. func squashWhitespace(s string) string { return strings.Join(strings.Fields(s), " ") } // wordWrap wraps text at word boundaries so no line exceeds width. func wordWrap(s string, width int) string { if width <= 0 { return s } var out strings.Builder lines := strings.Split(s, "\n") for _, line := range lines { if out.Len() > 0 { out.WriteByte('\n') } col, first := 0, true for _, word := range strings.Fields(line) { wl := len(word) switch { case first: out.WriteString(word) col = wl first = false case col+wl+1 > width: out.WriteByte('\n') out.WriteString(word) col = wl first = true default: out.WriteByte(' ') out.WriteString(word) col += wl + 1 } } } return out.String() } func mcpToolsToBackend(mcpTools []tools.ToolInfo) []backend.Tool { out := make([]backend.Tool, len(mcpTools)) for i, t := range mcpTools { out[i] = backend.Tool{ Name: t.Name, Description: t.Description, Parameters: t.InputSchema, } } return out } func extractMCPText(raw json.RawMessage) string { var result struct { Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` } if err := json.Unmarshal(raw, &result); err != nil { return string(raw) } var parts []string for _, c := range result.Content { if c.Type == "text" { parts = append(parts, c.Text) } } return strings.Join(parts, "\n") } func dispatchBuiltinExec(name string, e *execpkg.Executor, args json.RawMessage) (string, error) { var a struct { Code string `json:"code"` Language string `json:"language"` Timeout int `json:"timeout"` Sandbox string `json:"sandbox"` Tool string `json:"tool"` Args []string `json:"args"` Pipe []execpkg.PipeStep `json:"pipe"` } if err := json.Unmarshal(args, &a); err != nil { return "", fmt.Errorf("%s: bad args: %w", name, err) } if a.Language == "" { a.Language = "bash" } if a.Timeout <= 0 { a.Timeout = 30 } if a.Sandbox == "" { a.Sandbox = "default" } var code string trusted := false switch name { case "execute_pipe": if len(a.Pipe) == 0 { return "", fmt.Errorf("execute_pipe: 'pipe' is required") } var err error code, trusted, err = execpkg.BuildPipeline(a.Pipe) if err != nil { return "", err } case "execute_tool": if a.Tool == "" { return "", fmt.Errorf("execute_tool: 'tool' is required") } toolCode, err := execpkg.ReadTool(a.Tool) if err != nil { return "", err } code = toolCode trusted = true if len(a.Args) > 0 { var escaped []string for _, arg := range a.Args { escaped = append(escaped, "'"+strings.ReplaceAll(arg, "'", "'\\''")+"'") } code = fmt.Sprintf("set -- %s\n%s", strings.Join(escaped, " "), code) } default: // execute_code if a.Code == "" { return "", fmt.Errorf("execute_code: 'code' is required") } code = a.Code } return e.Execute(code, a.Language, a.Timeout, a.Sandbox, trusted) }