This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-core/main.go

1674 lines
48 KiB
Go

package main
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"sort"
"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 = `Use the fewest words possible. No preamble, filler, or narration ("Let me...", "I'll now...", "Great!"). No explanations of actions taken. No summaries of completed work. No reasoning unless asked. If the answer is one word, write one word.
Call tools immediately and directly. Never describe what you are about to do — act.
Complete tasks fully before stopping. Do not pause mid-task to narrate progress or request confirmation.
Do not ask clarifying questions unless the task is genuinely ambiguous. Attempt the task; correct based on feedback.
Do not restate tasks, hedge, or self-congratulate.
Do not attempt tasks outside your tools.
Do not use hedging language ("it looks like", "it appears", "it seems", "likely", "probably"). If you are uncertain, use tools to find out. Give definite answers based on evidence.
Do not re-read or re-fetch any file or resource that already has a result in the conversation history. Use the existing result.
Use tools to gather information before asking the user for clarification. Explore files, run commands, and investigate the environment first. Only ask when you have exhausted what you can discover on your own.
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.
Use file_read and file_write for all file read and write operations. Never use shell commands to read or write files.
Tool call examples:
Read a file: {"path": "/home/user/foo.go"}
Read lines 10-20: {"path": "/home/user/foo.go", "start_line": 10, "end_line": 20}
Run shell code: {"code": "ls -la", "language": "bash"}
List directory: {"code": "find . -maxdepth 2 -type f", "language": "bash"}
Run named tool: {"tool": "discover_skill.sh", "args": ["keyword"]}
Pipeline: {"pipe": [{"code": "cat file.txt"}, {"code": "grep foo"}]}`
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)."}
}
}`),
},
{
Name: "file_read",
Description: "Read a file or a range of lines. Output includes line numbers. Always use this instead of shell commands for reading files.",
Parameters: json.RawMessage(`{
"type": "object",
"required": ["path"],
"properties": {
"path": {"type": "string", "description": "Path to the file."},
"start_line": {"type": "integer", "description": "First line to read, 1-based (default: 1)."},
"end_line": {"type": "integer", "description": "Last line to read, inclusive (default: EOF)."}
}
}`),
},
{
Name: "file_write",
Description: "Write content to a file. Omit start_line/end_line to overwrite the whole file. Provide both to replace only that line range. Always use file_read or grep -n to identify the exact line range before writing. Never guess line numbers. Preserve original formatting and indentation. Always use this instead of shell commands for writing files.",
Parameters: json.RawMessage(`{
"type": "object",
"required": ["path", "content"],
"properties": {
"path": {"type": "string", "description": "Path to the file."},
"content": {"type": "string", "description": "Content to write."},
"start_line": {"type": "integer", "description": "First line of range to replace, 1-based."},
"end_line": {"type": "integer", "description": "Last line of range to replace, inclusive."}
}
}`),
},
}
// agentState represents what the agent is currently doing.
type agentState int
const (
agentIdle agentState = iota
agentThinking
agentRunningTool
agentRetrying
agentConfirming
agentStalled
)
// 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/
mcpExec *tools.Executor // current agent's MCP executor; closed on agent switch
builtinExec *execpkg.Executor
confirmPtr *agent.ConfirmFn // indirection so startAgent can set it per-run
ctxOverhead int // fixed per-request char overhead (system prompt + tool schemas)
invalidateCaches func() // clears tool-call dedup caches; set from agentEnv
sessionsDir string
sessionID string
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
confirmCh chan bool // non-nil when waiting for user confirmation
}
type agentMsg struct {
role string
content string
name string
done bool
usage backend.Usage
ctxStats agent.ContextStats
confirmCh chan bool // set when role=="confirm"
}
type confirmMsg struct {
approved bool
}
// 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 {
mcpExec *tools.Executor // kept so it can be closed on agent switch
tools []backend.Tool
exec agent.ToolExecutor
confirm *agent.ConfirmFn // pointer filled in by startAgent per-run
hooks map[string]string
systemPrompt string
genParams backend.GenerationParams
ctxOverhead int
messages []string // startup / status messages to display
invalidateCaches func() // clears per-session tool-call dedup caches on compact/clear
}
// 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 := ""
trustedTools := map[string]struct{}{}
var genParams backend.GenerationParams
if cfg != nil {
if cfg.Hooks != nil {
hooks = cfg.Hooks
}
agentPrompt = cfg.Prompt
for _, t := range cfg.TrustedTools {
trustedTools[t] = struct{}{}
}
genParams = backend.GenerationParams{
MaxTokens: cfg.MaxTokens,
Temperature: cfg.Temperature,
FrequencyPenalty: cfg.FrequencyPenalty,
PresencePenalty: cfg.PresencePenalty,
}
}
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{}{}
}
var confirmFn agent.ConfirmFn // filled in by startAgent
confirmPtr := &confirmFn
// fileRanges tracks which line ranges of each file have been read this session.
// Used to warn on overlapping re-reads and to guard file_write against blind overwrites.
// Cleared on /compact and /clear; individual entries deleted when a file is written.
type fileReadState struct {
ranges []lineRange
totalLines int // total lines when last read; used for whole-file write coverage check
}
fileRanges := make(map[string]*fileReadState)
// toolCallSeen tracks all non-file tool calls by exact (name, args) to warn on repeats.
toolCallSeen := make(map[string]bool)
rawExec := func(ctx context.Context, 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 {
var cfn agent.ConfirmFn
if _, trusted := trustedTools[name]; !trusted {
cfn = *confirmPtr
}
if name == "file_read" {
var a struct {
Path string `json:"path"`
}
json.Unmarshal(args, &a) //nolint:errcheck
if a.Path != "" {
if st := fileRanges[a.Path]; st != nil {
var reqStart, reqEnd int
var tmp struct {
Start int `json:"start_line"`
End int `json:"end_line"`
}
json.Unmarshal(args, &tmp) //nolint:errcheck
reqStart, reqEnd = tmp.Start, tmp.End
if reqStart <= 0 {
reqStart = 1
}
if reqEnd <= 0 {
reqEnd = st.totalLines
}
if reqEnd > 0 && rangesOverlap(st.ranges, reqStart, reqEnd) {
return "", fmt.Errorf("file_read: lines %d-%d of %s are already in context this session; use existing content instead of re-reading", reqStart, reqEnd, a.Path)
}
}
}
meta, err := dispatchFileRead(cfn, args)
if err != nil {
return "", err
}
if a.Path != "" {
st := fileRanges[a.Path]
if st == nil {
st = &fileReadState{}
fileRanges[a.Path] = st
}
st.ranges = append(st.ranges, lineRange{meta.start, meta.end})
st.totalLines = meta.totalLines
}
return meta.content, nil
}
if name == "file_write" {
var a struct {
Path string `json:"path"`
Content string `json:"content"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
}
json.Unmarshal(args, &a) //nolint:errcheck
if a.Path != "" {
st := fileRanges[a.Path]
if st == nil {
// New files (not yet on disk) need no prior read.
if _, statErr := os.Stat(a.Path); !os.IsNotExist(statErr) {
return "", fmt.Errorf("file_write: %s has not been read this session; read it first to avoid overwriting unknown changes", a.Path)
}
} else {
ws, we := a.StartLine, a.EndLine
if ws == 0 && we == 0 {
// Whole-file write: verify full coverage.
totalLines := st.totalLines
if totalLines == 0 {
data, err := os.ReadFile(a.Path)
if err != nil {
return "", fmt.Errorf("file_write: cannot verify read coverage: %w", err)
}
totalLines = len(strings.Split(string(data), "\n"))
}
ws, we = 1, totalLines
}
if !rangesCover(st.ranges, ws, we) {
return "", fmt.Errorf("file_write: lines %d-%d of %s have not been read this session; read them first to avoid overwriting unknown changes", ws, we, a.Path)
}
}
}
result, err := dispatchBuiltinExec(ctx, name, builtinExec, cfn, args)
if err != nil {
return "", err
}
// Repopulate fileRanges so follow-up writes to the same range
// don't require a re-read.
if a.Path != "" {
ws, we := a.StartLine, a.EndLine
totalLines := 0
if ws == 0 && we == 0 {
// Whole-file: we know the exact new line count.
totalLines = len(strings.Split(a.Content, "\n"))
ws, we = 1, totalLines
}
fileRanges[a.Path] = &fileReadState{
ranges: []lineRange{{ws, we}},
totalLines: totalLines,
}
}
return result, nil
}
return dispatchBuiltinExec(ctx, name, builtinExec, cfn, args)
}
return "", fmt.Errorf("unknown tool: %s", name)
}
execFn := func(ctx context.Context, name string, args json.RawMessage) (string, error) {
// Hard-error on duplicate tool calls (file_read/file_write handled above).
if name != "file_read" && name != "file_write" {
key := name + "\x00" + string(args)
if toolCallSeen[key] {
return "", fmt.Errorf("duplicate tool call: %s with these exact arguments was already called this session; the result is already in your context", name)
}
result, err := rawExec(ctx, name, args)
if err == nil {
toolCallSeen[key] = true
}
return result, err
}
return rawExec(ctx, name, args)
}
return agentEnv{
mcpExec: mcpExec,
tools: allTools,
exec: execFn,
confirm: confirmPtr,
hooks: hooks,
systemPrompt: sp,
genParams: genParams,
ctxOverhead: overhead,
messages: messages,
invalidateCaches: func() {
clear(fileRanges)
clear(toolCallSeen)
},
}
}
// 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
}
// newSessionID returns a unique session identifier embedding the current timestamp.
func newSessionID() string {
b := make([]byte, 3)
rand.Read(b) //nolint:errcheck
return time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%06x", b)
}
// saveSession persists the current session to disk; silently no-ops if no
// active session or session ID is set.
func (m *model) saveSession() {
if m.session == nil || m.sessionID == "" || m.sessionsDir == "" {
return
}
path := m.sessionsDir + "/" + m.sessionID + ".json"
if err := m.session.SaveTo(path, m.sessionID, m.agentName); err != nil {
m.appendDisplay("session save: " + err.Error())
}
}
func main() {
sessionFlag := flag.String("session", "", "resume a session by ID")
flag.Parse()
extraArgs := flag.Args()
home, _ := os.UserHomeDir()
agentsDir := home + "/.config/ollie/agents"
sessionsDir := home + "/.config/ollie/sessions"
if err := os.MkdirAll(sessionsDir, 0700); err != nil {
log.Fatalf("sessions dir: %v", err)
}
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",
)
// Determine agent name: explicit arg > session file > env > "default".
agentName := os.Getenv("OLLIE_AGENT")
if agentName == "" {
agentName = "default"
}
// If resuming a session, peek at the file to get the stored agent name
// (used only when no agent is specified on the command line).
sessionID := newSessionID()
var resumeMessages []backend.Message
if *sessionFlag != "" {
sessionPath := sessionsDir + "/" + *sessionFlag + ".json"
data, readErr := os.ReadFile(sessionPath)
if readErr != nil {
log.Fatalf("--session: %v", readErr)
}
var ps agent.PersistedSession
if jsonErr := json.Unmarshal(data, &ps); jsonErr != nil {
log.Fatalf("--session: bad JSON: %v", jsonErr)
}
sessionID = ps.ID
resumeMessages = ps.Messages
if ps.Agent != "" && len(extraArgs) == 0 {
agentName = ps.Agent
}
}
if len(extraArgs) > 0 {
agentName = extraArgs[0]
}
cfgPath := agentConfigPath(agentsDir, agentName)
cfg, cfgErr := config.Load(cfgPath)
var startup []string
if cfgErr != nil {
startup = append(startup, fmt.Sprintf("agent config: %v", cfgErr))
}
env := buildAgentEnv(cfg, builtinExec)
startup = append(startup, env.messages...)
// Restore or record session info.
var initialSession *agent.Session
if len(resumeMessages) > 0 {
initialSession = agent.RestoreSession(resumeMessages, agent.ContextConfig{
FixedOverheadChars: env.ctxOverhead,
})
startup = append(startup, fmt.Sprintf("session: %s (resumed)", sessionID))
} else {
startup = append(startup, fmt.Sprintf("session: %s", sessionID))
}
loopcfg := agent.Config{
Backend: be,
Model: modelName,
SystemPrompt: env.systemPrompt,
Tools: env.tools,
Exec: env.exec,
MaxSteps: 20,
GenerationParams: env.genParams,
}
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,
session: initialSession,
loopcfg: loopcfg,
hooks: env.hooks,
display: startup,
modelName: modelName,
backendName: backendName,
agentName: agentName,
agentsDir: agentsDir,
sessionsDir: sessionsDir,
sessionID: sessionID,
mcpExec: env.mcpExec,
builtinExec: builtinExec,
confirmPtr: env.confirm,
ctxOverhead: env.ctxOverhead,
invalidateCaches: env.invalidateCaches,
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)
case agentConfirming:
stateStr = "confirm [y/n]"
case agentStalled:
stateStr = "stalled"
}
// 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
s := strings.TrimRight(am.content, "\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)
if m.session != nil {
m.session.Rollback()
}
case "confirm":
m.state = agentConfirming
m.confirmCh = am.confirmCh
m.appendDisplay("Confirm: " + am.content + " [y/n]")
case "stalled":
m.state = agentStalled
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.
// If interrupted is true, rolls back any incomplete assistant turn.
func (m *model) drainAgent(interrupted bool) {
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
}
if interrupted && m.session != nil {
m.session.Rollback()
}
m.state = agentIdle
m.currentTool = ""
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.viewport.Width = msg.Width
m.textarea.SetWidth(msg.Width)
barHeight := lipgloss.Height(m.renderStatusBar())
m.viewport.Height = msg.Height - m.textarea.Height() - barHeight
m.refreshView()
m.ready = true
case timeoutMsg:
// Clear quitPending flag when timeout expires
m.quitPending = false
return m, nil
case confirmMsg:
if m.confirmCh != nil {
m.confirmCh <- msg.approved
m.confirmCh = nil
}
m.state = agentRunningTool
m.refreshView()
if m.agentCh == nil {
return m, nil
}
return m, func() tea.Msg { return <-m.agentCh }
case agentMsg:
m.apply(msg)
m.refreshView()
if msg.done {
if m.state != agentStalled {
m.state = agentIdle
}
m.currentTool = ""
m.agentCh = nil
m.cancel = nil
m.doneCh = nil
m.saveSession()
return m, nil
}
// Stop pumping while waiting for user confirmation.
if m.state == agentConfirming {
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(true)
}
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(true)
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
}
// Handle confirmation prompt
if m.state == agentConfirming && m.confirmCh != nil {
m.textarea.Reset()
lower := strings.ToLower(input)
if lower == "y" || lower == "yes" {
m.appendDisplay("You: " + input)
m.refreshView()
return m, func() tea.Msg { return confirmMsg{approved: true} }
} else if lower == "n" || lower == "no" {
m.appendDisplay("You: " + input)
m.refreshView()
return m, func() tea.Msg { return confirmMsg{approved: false} }
}
// Anything else: deny and fall through to normal prompt handling
ch := m.confirmCh
m.confirmCh = nil
m.state = agentIdle
ch <- false
}
m.drainAgent(false)
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, "!") {
cmdStr := strings.TrimSpace(input[1:])
if cmdStr == "" {
return true
}
out, err := exec.Command("sh", "-c", cmdStr).CombinedOutput()
if err != nil {
m.appendDisplay("Error: " + err.Error())
}
if len(out) > 0 {
m.appendDisplay(strings.TrimRight(string(out), "\n"))
}
return true
}
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 "/agents":
entries, err := os.ReadDir(m.agentsDir)
if err != nil {
m.display = append(m.display, fmt.Sprintf("agents: %v", err))
return true
}
found := false
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
marker := " "
if name == m.agentName {
marker = "* "
}
m.display = append(m.display, marker+name)
found = true
}
if !found {
m.display = append(m.display, "no agents found in "+m.agentsDir)
}
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
}
if m.mcpExec != nil {
m.mcpExec.Close()
}
env := buildAgentEnv(cfg, m.builtinExec)
m.mcpExec = env.mcpExec
m.hooks = env.hooks
m.loopcfg.SystemPrompt = env.systemPrompt
m.loopcfg.Tools = env.tools
m.loopcfg.Exec = env.exec
m.loopcfg.GenerationParams = env.genParams
m.ctxOverhead = env.ctxOverhead
m.confirmPtr = env.confirm
m.invalidateCaches = env.invalidateCaches
m.agentName = name
m.session = nil // new agent, new session
m.sessionID = newSessionID()
for _, msg := range env.messages {
m.display = append(m.display, msg)
}
m.display = append(m.display, fmt.Sprintf("agent: %s", name))
return true
case "/compact":
if m.session == nil {
m.display = append(m.display, "nothing to compact")
return true
}
n, summary, err := m.session.Compact(context.Background(), m.loopcfg.Backend, m.loopcfg.Model)
if err != nil {
m.display = append(m.display, "compact error: "+err.Error())
} else if n == 0 {
m.display = append(m.display, "nothing to compact")
} else {
m.display = append(m.display, fmt.Sprintf("compacted %d messages", n))
if summary != "" {
m.display = append(m.display, "")
for _, line := range strings.Split(summary, "\n") {
m.display = append(m.display, line)
}
}
m.saveSession()
if m.invalidateCaches != nil {
m.invalidateCaches()
}
}
return true
case "/context":
if m.session == nil {
m.display = append(m.display, "no active session")
return true
}
for _, line := range strings.Split(m.session.ContextDebug(), "\n") {
m.display = append(m.display, line)
}
return true
case "/history":
if m.session == nil {
m.display = append(m.display, "no active session")
return true
}
for _, msg := range m.session.History() {
preview := msg.Content
if len(preview) > 200 {
preview = preview[:200] + "..."
}
m.display = append(m.display, fmt.Sprintf("[%s] %s", msg.Role, preview))
}
return true
case "/clear":
m.session = nil
m.display = nil
m.buf = ""
m.ctxStats = agent.ContextStats{}
m.lastUsage = backend.Usage{}
m.sessionID = newSessionID()
if m.invalidateCaches != nil {
m.invalidateCaches()
}
return true
case "/sessions":
entries, err := os.ReadDir(m.sessionsDir)
if err != nil {
m.display = append(m.display, fmt.Sprintf("sessions: %v", err))
return true
}
found := false
// ReadDir returns sorted by name (date-prefixed IDs → chronological).
// Show most recent first.
for i := len(entries) - 1; i >= 0; i-- {
e := entries[i]
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
id := strings.TrimSuffix(e.Name(), ".json")
marker := " "
if id == m.sessionID {
marker = "* "
}
// Quick-read agent name and first user message for context.
label := id
if data, readErr := os.ReadFile(m.sessionsDir + "/" + e.Name()); readErr == nil {
var ps agent.PersistedSession
if json.Unmarshal(data, &ps) == nil {
agentLabel := ps.Agent
goal := ""
for _, msg := range ps.Messages {
if msg.Role == "user" {
goal = msg.Content
break
}
}
if len(goal) > 60 {
goal = goal[:60] + "..."
}
label = fmt.Sprintf("%-24s [%s] %q", id, agentLabel, goal)
}
}
m.display = append(m.display, marker+label)
found = true
}
if !found {
m.display = append(m.display, "no sessions found in "+m.sessionsDir)
}
return true
case "/help":
m.display = append(m.display, "Available commands:")
m.display = append(m.display, " /agents - List available agent configs")
m.display = append(m.display, " /sessions - List saved sessions")
m.display = append(m.display, " /agent [name] - Show or switch active agent")
m.display = append(m.display, " /backend <type> - Switch backend (ollama, openai)")
m.display = append(m.display, " /model <name> - Switch model")
m.display = append(m.display, " /compact - Summarize evicted context messages")
m.display = append(m.display, " /context - Show context window debug info")
m.display = append(m.display, " /history - Dump bounded message history")
m.display = append(m.display, " /clear - Clear session and display")
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
}
}
loopcfg.Confirm = func(prompt string) bool {
replyCh := make(chan bool, 1)
select {
case ch <- agentMsg{role: "confirm", content: prompt, confirmCh: replyCh}:
case <-ctx.Done():
return false
}
select {
case approved := <-replyCh:
return approved
case <-ctx.Done():
return false
}
}
if m.confirmPtr != nil {
*m.confirmPtr = loopcfg.Confirm
}
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(ctx context.Context, name string, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
switch name {
case "file_read":
r, err := dispatchFileRead(confirm, args)
return r.content, err
case "file_write":
return dispatchFileWrite(confirm, args)
case "execute_pipe":
return dispatchExecutePipe(ctx, e, confirm, args)
case "execute_tool":
return dispatchExecuteTool(ctx, e, confirm, args)
default: // execute_code
return dispatchExecuteCode(ctx, e, confirm, args)
}
}
func execArgs(args json.RawMessage) (code, language, sandbox string, timeout int, err error) {
var a struct {
Code string `json:"code"`
Language string `json:"language"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err = json.Unmarshal(args, &a); err != nil {
return
}
code = a.Code
language = a.Language
if language == "" {
language = "bash"
}
timeout = a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox = a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return
}
func dispatchExecuteCode(ctx context.Context, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
code, language, sandbox, timeout, err := execArgs(args)
if err != nil {
return "", fmt.Errorf("execute_code: bad args: %w", err)
}
if code == "" {
return "", fmt.Errorf("execute_code: 'code' is required")
}
if confirm != nil && !confirm(fmt.Sprintf("execute_code: %s", squashWhitespace(code))) {
return "", fmt.Errorf("execute_code: denied by user")
}
return e.Execute(ctx, code, language, timeout, sandbox, false)
}
func dispatchExecuteTool(ctx context.Context, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
var a struct {
Tool string `json:"tool"`
Args []string `json:"args"`
Language string `json:"language"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("execute_tool: bad args: %w", err)
}
if a.Tool == "" {
return "", fmt.Errorf("execute_tool: 'tool' is required")
}
if confirm != nil && !confirm(fmt.Sprintf("execute_tool: %s %s", a.Tool, strings.Join(a.Args, " "))) {
return "", fmt.Errorf("execute_tool: denied by user")
}
toolCode, err := execpkg.ReadTool(a.Tool)
if err != nil {
return "", err
}
code := toolCode
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)
}
language := a.Language
if language == "" {
language = "bash"
}
timeout := a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox := a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return e.Execute(ctx, code, language, timeout, sandbox, true)
}
func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
var a struct {
Pipe []execpkg.PipeStep `json:"pipe"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("execute_pipe: bad args: %w", err)
}
if len(a.Pipe) == 0 {
return "", fmt.Errorf("execute_pipe: 'pipe' is required")
}
code, _, err := execpkg.BuildPipeline(a.Pipe)
if err != nil {
return "", err
}
if confirm != nil && !confirm(fmt.Sprintf("execute_pipe: %s", squashWhitespace(code))) {
return "", fmt.Errorf("execute_pipe: denied by user")
}
timeout := a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox := a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return e.Execute(ctx, code, "bash", timeout, sandbox, true)
}
// lineRange is an inclusive [start, end] line range (1-based).
type lineRange struct{ start, end int }
// fileReadResult carries the output of dispatchFileRead plus the range metadata
// needed for per-session coverage tracking.
type fileReadResult struct {
content string
start int // actual first line returned (1-based)
end int // actual last line returned (1-based)
totalLines int // total lines in the file before any truncation
}
// rangesOverlap reports whether [ws, we] overlaps any interval in ranges.
func rangesOverlap(ranges []lineRange, ws, we int) bool {
for _, r := range ranges {
if r.start <= we && r.end >= ws {
return true
}
}
return false
}
// rangesCover reports whether the union of ranges fully contains [ws, we].
func rangesCover(ranges []lineRange, ws, we int) bool {
sorted := make([]lineRange, len(ranges))
copy(sorted, ranges)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].start < sorted[j].start })
cur := ws - 1 // coverage must start at ws; anything before is irrelevant
for _, r := range sorted {
if r.start > cur+1 {
break
}
if r.end > cur {
cur = r.end
}
}
return cur >= we
}
func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (fileReadResult, error) {
var a struct {
Path string `json:"path"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
}
if err := json.Unmarshal(args, &a); err != nil {
return fileReadResult{}, fmt.Errorf("file_read: bad args: %w", err)
}
if a.Path == "" {
return fileReadResult{}, fmt.Errorf("file_read: 'path' is required")
}
if confirm != nil && !confirm(fmt.Sprintf("read %s", a.Path)) {
return fileReadResult{}, fmt.Errorf("file_read: denied by user")
}
data, err := os.ReadFile(a.Path)
if err != nil {
return fileReadResult{}, fmt.Errorf("file_read: %w", err)
}
lines := strings.Split(string(data), "\n")
totalLines := len(lines)
start := 1
end := totalLines
if a.StartLine > 0 {
start = a.StartLine
}
if a.EndLine > 0 {
end = a.EndLine
}
if start < 1 {
start = 1
}
if end > totalLines {
end = totalLines
}
if start > end {
return fileReadResult{}, fmt.Errorf("file_read: start_line %d > end_line %d", start, end)
}
var out strings.Builder
for i, line := range lines[start-1 : end] {
fmt.Fprintf(&out, "%d\t%s\n", start+i, line)
}
content := strings.TrimRight(out.String(), "\n")
return fileReadResult{content: content, start: start, end: end, totalLines: totalLines}, nil
}
func dispatchFileWrite(confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
var a struct {
Path string `json:"path"`
Content string `json:"content"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("file_write: bad args: %w", err)
}
if a.Path == "" {
return "", fmt.Errorf("file_write: 'path' is required")
}
prompt := fmt.Sprintf("write %s", a.Path)
if a.StartLine > 0 {
prompt = fmt.Sprintf("write %s lines %d-%d", a.Path, a.StartLine, a.EndLine)
}
if confirm != nil && !confirm(prompt) {
return "", fmt.Errorf("file_write: denied by user")
}
// Full overwrite
if a.StartLine == 0 && a.EndLine == 0 {
if err := os.WriteFile(a.Path, []byte(a.Content), 0644); err != nil {
return "", fmt.Errorf("file_write: %w", err)
}
return fmt.Sprintf("wrote %d bytes to %s", len(a.Content), a.Path), nil
}
// Range replacement
data, err := os.ReadFile(a.Path)
if err != nil {
return "", fmt.Errorf("file_write: %w", err)
}
lines := strings.Split(string(data), "\n")
start, end := a.StartLine, a.EndLine
if start < 1 {
start = 1
}
if end > len(lines) {
end = len(lines)
}
if start > end {
return "", fmt.Errorf("file_write: start_line %d > end_line %d", start, end)
}
newLines := strings.Split(a.Content, "\n")
result := append(lines[:start-1], append(newLines, lines[end:]...)...)
if err := os.WriteFile(a.Path, []byte(strings.Join(result, "\n")), 0644); err != nil {
return "", fmt.Errorf("file_write: %w", err)
}
return fmt.Sprintf("replaced lines %d-%d in %s", start, end, a.Path), nil
}