diff --git a/README.md b/README.md index 3e98e6e..8418c29 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ paths/ XDG path resolution **`tools.Server`** — interface for a tool provider: `ListTools`, `CallTool`. Implementations: `execute.Server` (local) and `execute.RemoteServer` (SSH). -**`tools.Registry`** — per-session dynamic tool loading. Discovers scripts from `OLLIE_TOOLS_PATH`, parses schemas, promotes to native callable functions on demand. +**`tools.Registry`** — per-session dynamic tool loading. Discovers scripts from `$XDG_CONFIG_HOME/ollie/tools`, parses schemas, promotes to native callable functions on demand. ## Install @@ -91,11 +91,11 @@ One built-in tool via `execute.Server`: **`shell`** — run a bash command in a sandbox. Accepts `cmd`, `timeout` (default 30s, 0 for unlimited), `sandbox` (profile name), `elevated` (bypass sandbox via elevation broker). -Named tool scripts from `OLLIE_TOOLS_PATH` are promoted to native callable functions via `tools.Registry`. Each script declares its schema and metadata in header comments (`ollie:prompt`, `args_json:`, `ollie:tier`, `ollie:parallel read`). +Named tool scripts from `$XDG_CONFIG_HOME/ollie/tools` are promoted to native callable functions via `tools.Registry`. Each script declares its schema and metadata in header comments (`ollie:prompt`, `args_json:`, `ollie:tier`, `ollie:parallel read`). ## Skills -Skills are domain-specific knowledge modules in `OLLIE_SKILLS_PATH` (default: `~/.config/ollie/skills/`). Each is a directory containing a `SKILL.md` with YAML front-matter (name, description). Loaded into session context on demand via `skill_load`. +Skills are domain-specific knowledge modules in `$XDG_CONFIG_HOME/ollie/skills` (default: `~/.config/ollie/skills/`). Each is a directory containing a `SKILL.md` with YAML front-matter (name, description). Loaded into session context on demand via `skill_load`. ## Session lifecycle diff --git a/agent/agent.go b/agent/agent.go index c734557..d2a56dc 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -57,7 +57,6 @@ type Agent struct { readPlanStep func() string saveSession func() // trigger debounced persistence flushSave func() // immediately flush persistence - turnError func(ctx context.Context, errType, errMsg string) HookResult // overridable for tests } // Backend returns the active backend from the runtime. diff --git a/agent/agent_config.go b/agent/agent_config.go index 30e7442..16db22f 100644 --- a/agent/agent_config.go +++ b/agent/agent_config.go @@ -5,24 +5,6 @@ import ( "io" ) -// HookCmds holds one or more shell commands for a hook. It unmarshals from -// either a JSON string ("cmd") or array (["cmd1","cmd2"]). -type HookCmds []string - -func (h *HookCmds) UnmarshalJSON(data []byte) error { - var s string - if err := json.Unmarshal(data, &s); err == nil { - *h = HookCmds{s} - return nil - } - var ss []string - if err := json.Unmarshal(data, &ss); err != nil { - return err - } - *h = HookCmds(ss) - return nil -} - // Prompt holds the agent prompt. It unmarshals from either a JSON string // (treated as literal text, with the existing resolvePrompt semantics) or // an array of strings (each element is a shell command whose stdout is @@ -59,7 +41,6 @@ func (p Prompt) MarshalJSON() ([]byte, error) { } type AgentConfig struct { - Hooks map[string]HookCmds `json:"hooks,omitempty"` Prompt Prompt `json:"prompt,omitempty"` Backend string `json:"backend,omitempty"` Model string `json:"model,omitempty"` diff --git a/agent/build_runtime.go b/agent/build_runtime.go index b6aa604..fb5b246 100644 --- a/agent/build_runtime.go +++ b/agent/build_runtime.go @@ -36,14 +36,10 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string allToolInfos = append(allToolInfos, toolsrv.DiscoverTools()...) } - hooks := Hooks{} var preamble string var genParams backend.GenerationParams var maxSteps int if cfg != nil { - for k, v := range cfg.Hooks { - hooks[k] = []string(v) - } if resolved, err := resolvePrompt(cfg.Prompt, cwd, env); err != nil { fmt.Fprintf(os.Stderr, "resolve prompt: %v\n", err) } else { @@ -145,7 +141,6 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string Exec: exec, ClassifyTool: classify, ClassifyTier: tierFn, - Hooks: hooks, Preamble: preamble, GenParams: genParams, MaxSteps: maxSteps, diff --git a/agent/config_paths.go b/agent/config_paths.go index 7e75a19..6d2e278 100644 --- a/agent/config_paths.go +++ b/agent/config_paths.go @@ -2,7 +2,6 @@ package agent import ( "os" - "strings" "ollie/paths" ) @@ -12,24 +11,18 @@ func DefaultPromptsDir() string { return paths.CfgDir() + "/prompts" } -// PromptsDirs returns all prompt directories from OLLIE_PROMPTS_PATH (colon-separated). +// PromptsDirs returns all prompt directories. func PromptsDirs() []string { - if p := os.Getenv("OLLIE_PROMPTS_PATH"); p != "" { - return strings.Split(p, ":") - } - return []string{DefaultPromptsDir()} + return paths.PromptsDirs() } -// AgentsDirs returns all agent directories from OLLIE_AGENTS_PATH (colon-separated). +// AgentsDirs returns all agent directories. func AgentsDirs() []string { - if p := os.Getenv("OLLIE_AGENTS_PATH"); p != "" { - return strings.Split(p, ":") - } - return []string{paths.CfgDir() + "/agents"} + return paths.AgentsDirs() } // AgentConfigPath resolves the config file path for a named agent. -// It searches all directories in OLLIE_AGENTS_PATH, falling back to agentsDir. +// It searches all agent directories, falling back to agentsDir. func AgentConfigPath(agentsDir, name string) string { for _, dir := range AgentsDirs() { p := dir + "/" + name + ".json" diff --git a/agent/hooks.go b/agent/hooks.go deleted file mode 100644 index 26e374a..0000000 --- a/agent/hooks.go +++ /dev/null @@ -1,237 +0,0 @@ -package agent - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "os/exec" - "strings" - "syscall" - "time" - - olog "ollie/log" - "ollie/paths" -) - -// Hook name constants for well-known agent lifecycle events. -const ( - HookAgentSpawn = "agentSpawn" - HookPreTurn = "preTurn" - HookPostTurn = "postTurn" - HookPreTool = "preTool" - HookPostTool = "postTool" - HookPreCompact = "preCompact" - HookPostCompact = "postCompact" - HookTurnError = "turnError" -) - -const defaultHookTimeout = 60 - -// hookTimeout is the hook execution timeout in seconds. Overridable in tests. -var hookTimeout = defaultHookTimeout - -// Hooks maps hook names to one or more shell commands. -type Hooks map[string][]string - -// HookResult holds the outcome of running a hook. -type HookResult struct { - // Ran is true when a hook command was configured and executed. - Ran bool - // Handled is true when all hook commands exited 0 (no warnings, not blocked). - // Use this to check whether a hook fully handled an event (e.g. turnError). - Handled bool - // Blocked is true when the hook wants to prevent the action (exit 2). - // For Stop hooks, Blocked means "don't stop, continue". - Blocked bool - // Context is stdout from the hook, injected into the conversation. - Context string - // Warning, if non-empty, is a message about hook execution problems - // (e.g. timeout, start failure) that should be surfaced to the user. - Warning string - // Total is the number of hook commands configured for this event. - Total int - // Succeeded is the number of hook commands that exited 0. - Succeeded int - // Failed is the number of hook commands that did not succeed (non-zero, timeout, start failure). - Failed int - // FailedCmds identifies which commands failed (truncated to 40 chars each). - FailedCmds []string -} - -// Run executes all commands for the named hook in order, sending payload as -// JSON on stdin for each. Returns a combined HookResult. -// -// Exit codes per command: -// - 0: success. Stdout is appended to combined context. -// - 2: block. Stops execution immediately and returns blocked. -// - other: non-blocking warning (stderr logged, execution continues). -func (h Hooks) Run(ctx context.Context, name string, payload any, log *olog.Logger) HookResult { - cmds := h[name] - if len(cmds) == 0 { - return HookResult{} - } - - payloadJSON, _ := json.Marshal(payload) - var cwd string - if m, ok := payload.(map[string]string); ok { - cwd = m["cwd"] - } - - total := len(cmds) - var contextParts []string - var warnings []string - var failedCmds []string - succeeded := 0 - failed := 0 - allHandled := true - for _, cmdStr := range cmds { - log.Debug("hook %s: cmd=%q", name, cmdStr) - result := runHookCmd(ctx, name, cmdStr, payloadJSON, cwd, log) - if result.Warning != "" { - warnings = append(warnings, result.Warning) - allHandled = false - failed++ - failedCmds = append(failedCmds, truncateCmd(cmdStr)) - } else if !result.Ran { - allHandled = false - failed++ - failedCmds = append(failedCmds, truncateCmd(cmdStr)) - } else if result.Blocked { - // Blocked counts as "ran" for accounting but stops iteration. - succeeded++ - return HookResult{ - Ran: true, Blocked: true, Context: result.Context, - Warning: strings.Join(warnings, "; "), - Total: total, - Succeeded: succeeded, - Failed: failed, - FailedCmds: failedCmds, - } - } else { - succeeded++ - if result.Context != "" { - contextParts = append(contextParts, result.Context) - } - } - } - return HookResult{ - Ran: true, Handled: allHandled, - Context: strings.Join(contextParts, "\n"), - Warning: strings.Join(warnings, "; "), - Total: total, - Succeeded: succeeded, - Failed: failed, - FailedCmds: failedCmds, - } -} - -func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cwd string, log *olog.Logger) HookResult { - cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr) - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Stdin = bytes.NewReader(payloadJSON) - if cwd != "" { - expanded := paths.ExpandHome(cwd) - // Only set Dir if the path exists locally (remote sessions - // have a CWD that only exists on the remote host). - if _, err := os.Stat(expanded); err == nil { - cmd.Dir = expanded - } - } - - // Inject payload map keys as OLLIE_* environment variables so hook - // commands can use $OLLIE_SESSION_ID, $OLLIE_MODEL, etc. without - // parsing the JSON payload on stdin. - var payloadMap map[string]string - if err := json.Unmarshal(payloadJSON, &payloadMap); err == nil { - env := cmd.Environ() - for k, v := range payloadMap { - env = append(env, "OLLIE_"+strings.ToUpper(k)+"="+v) - } - cmd.Env = env - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - done := make(chan error, 1) - if err := cmd.Start(); err != nil { - log.Debug("hook %s: start error: %v", name, err) - return HookResult{Ran: true, Warning: fmt.Sprintf("hook %s: failed to start: %v", name, err)} - } - go func() { done <- cmd.Wait() }() - - timeout := time.After(time.Duration(hookTimeout) * time.Second) - select { - case err := <-done: - exitCode := 0 - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - exitCode = exitErr.ExitCode() - } else { - log.Debug("hook %s: wait error: %v", name, err) - return HookResult{} - } - } - switch exitCode { - case 0: - out := strings.TrimSpace(stdout.String()) - log.Debug("hook %s: exit=0 context_len=%d", name, len(out)) - return HookResult{Ran: true, Context: out} - case 2: - msg := strings.TrimSpace(stderr.String()) - log.Debug("hook %s: exit=2 (blocked) msg=%q", name, msg) - return HookResult{Ran: true, Blocked: true, Context: msg} - default: - log.Debug("hook %s: exit=%d (non-blocking error) stderr=%q", name, exitCode, stderr.String()) - return HookResult{Ran: false, Warning: fmt.Sprintf("hook %s: exit %d", name, exitCode)} - } - case <-ctx.Done(): - syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) //nolint:errcheck - <-done - log.Debug("hook %s: cancelled (context done)", name) - return HookResult{} - case <-timeout: - syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) //nolint:errcheck - <-done - log.Debug("hook %s: timed out after %ds", name, hookTimeout) - return HookResult{Ran: true, Warning: fmt.Sprintf("hook %s: timed out after %ds", name, hookTimeout)} - } -} - -// Summary returns a human-readable summary of hook execution, e.g. -// "(2 of 3 hooks run) (1 of 3 failed: my-script.sh)" -func (r HookResult) Summary() string { - if !r.Ran || r.Total == 0 { - return "" - } - ran := r.Succeeded + r.Failed // commands that were attempted - s := fmt.Sprintf("(%d of %d hooks run)", ran, r.Total) - if r.Failed > 0 { - detail := strings.Join(r.FailedCmds, ", ") - s += fmt.Sprintf(" (%d of %d failed: %s)", r.Failed, r.Total, detail) - } - return s -} - -// truncateCmd returns a short identifier for a hook command string. -func truncateCmd(cmd string) string { - cmd = strings.TrimSpace(cmd) - if len(cmd) <= 40 { - return cmd - } - return cmd[:37] + "..." -} - -// hooksRan returns a display string for N hooks having run, e.g. "1 hook run". -// Deprecated: prefer HookResult.Summary() for richer output. -func hooksRan(n int) string { - if n == 1 { - return "1 hook run" - } - return fmt.Sprintf("%d hooks run", n) -} diff --git a/agent/loop.go b/agent/loop.go index 21d647c..7943ef9 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -52,12 +52,9 @@ type agentConfig struct { Output EventHandler preamble string // compiled system+agent prompt sent as the system role GenerationParams backend.GenerationParams - PopInject func() string // returns and clears pending inject, or "" + PopInject func() string // returns and clears pending inject, or "" AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place - SaveSession func() // called after each state.update(); persists mid-turn progress - PreTool func(ctx context.Context, name string, args json.RawMessage) HookResult // called before each tool; exit 2 blocks execution - PostTool func(ctx context.Context, name string, args json.RawMessage, result string) HookResult // called after each tool; exit 0 appends, exit 2 replaces result - TurnError func(ctx context.Context, errType, errMsg string) HookResult // called on first backend error; if ran, skips retries + SaveSession func() // called after each state.update(); persists mid-turn progress // MaxSteps is the maximum number of tool-call rounds per turn. // When reached, a soft nudge is injected and the loop exits cleanly. // 0 means unlimited. @@ -127,15 +124,6 @@ func run(ctx context.Context, cfg agentConfig, state state) error { if ctx.Err() != nil { return ctx.Err() } - // On the first error, fire the turnError hook. If it handles - // the error (exit 0), return immediately — the hook is - // responsible for recovery (e.g. switching model and resubmitting). - if attempt == 0 && cfg.TurnError != nil { - errType := classifyError(err) - if r := cfg.TurnError(ctx, errType, err.Error()); r.Handled { - return fmt.Errorf("step %d: %w", step, err) - } - } wait, retryable := transientWait(err, attempt) if !retryable || attempt >= maxTransientRetries { return fmt.Errorf("step %d: %w", step, err) @@ -281,20 +269,6 @@ func run(ctx context.Context, cfg agentConfig, state state) error { return cr, true } emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) - if cfg.PreTool != nil { - hr := cfg.PreTool(ctx, tc.Name, tc.Arguments) - if hr.Failed > 0 { - emit(cfg, Event{Role: "info", Content: "preTool: " + hr.Summary()}) - } - if hr.Blocked { - blocked := hr.Context - if blocked == "" { - blocked = fmt.Sprintf("tool %q blocked by hook", tc.Name) - } - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: blocked}) - return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: blocked, IsError: true}, false - } - } readSafe := cfg.ClassifyTool != nil && cfg.ClassifyTool(tc.Name) if readSafe { key := tc.Name + "\x00" + string(tc.Arguments) @@ -348,20 +322,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error { result = "error: no tool executor configured" isErr = true } - // Accumulate suffix text (PostTool context, user-interruptions, + // Accumulate suffix text (user-interruptions, // truncation hints) that must be emitted after streaming completes. var suffix string - if cfg.PostTool != nil { - hr := cfg.PostTool(ctx, tc.Name, tc.Arguments, result) - if hr.Failed > 0 { - emit(cfg, Event{Role: "info", Content: "postTool: " + hr.Summary()}) - } - if hr.Blocked { - result = hr.Context - } else if hr.Context != "" { - suffix += "\n" + hr.Context - } - } if cfg.PopInject != nil { if injected := cfg.PopInject(); injected != "" { suffix += "\n\n\n" + injected + "\n" diff --git a/agent/new.go b/agent/new.go index a638285..b10cadf 100644 --- a/agent/new.go +++ b/agent/new.go @@ -55,7 +55,6 @@ func NewAgent(cfg AgentCfg) *Agent { state: "idle", } ag.changeCond = sync.NewCond(&ag.changeMu) - ag.turnError = ag.defaultTurnError ag.wireToolsChanged() return ag } diff --git a/agent/prompt_resolver.go b/agent/prompt_resolver.go index dab0643..ba46267 100644 --- a/agent/prompt_resolver.go +++ b/agent/prompt_resolver.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "ollie/paths" ) // PromptEnv returns the standard PRIME_* environment variables for prompt @@ -108,9 +109,8 @@ func resolveExecPrompt(cmds []string, cwd string, env []string) (string, error) // resolvePromptFile attempts to resolve entry as a prompt file. // It first checks if the entry (after env expansion) is an absolute path to an -// existing file. Otherwise it searches OLLIE_PROMPTS_PATH directories for -// entry and entry.md. Returns the file content (with env vars expanded) and -// true if found. +// existing file. Otherwise it searches CfgDir()/prompts for entry and entry.md. +// Returns the file content (with env vars expanded) and true if found. func resolvePromptFile(entry string, envMap map[string]string, expand func(string) string) (string, bool) { expanded := expand(entry) @@ -131,12 +131,8 @@ func resolvePromptFile(entry string, envMap map[string]string, expand func(strin return "", false } - // Search OLLIE_PROMPTS_PATH. - promptsPath := envMap["OLLIE_PROMPTS_PATH"] - if promptsPath == "" { - return "", false - } - for _, dir := range filepath.SplitList(promptsPath) { + // Search CfgDir()/prompts. + for _, dir := range paths.PromptsDirs() { if dir == "" { continue } diff --git a/agent/runtime.go b/agent/runtime.go index 97c564b..1b71fe0 100644 --- a/agent/runtime.go +++ b/agent/runtime.go @@ -14,7 +14,6 @@ import ( type Runtime struct { Backend backend.Backend ToolServer toolsrv.Runner // the execute server (tool runtime, env, cwd) - Hooks Hooks Preamble string // compiled system prompt Tools []backend.Tool Exec toolExecutor diff --git a/agent/turn.go b/agent/turn.go index d3afd09..4fba79d 100644 --- a/agent/turn.go +++ b/agent/turn.go @@ -2,14 +2,11 @@ package agent import ( "context" - "encoding/json" "errors" "fmt" "os" "runtime/debug" - "strconv" "strings" - "time" "ollie/backend" ) @@ -72,28 +69,8 @@ func (ag *Agent) Submit(ctx context.Context, input string) { func (ag *Agent) executeTurn(ctx context.Context, input string) string { ag.emit(Event{Role: "user", Content: input}) - hookResult := ag.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{ - "session_id": ag.sessionID, - "cwd": ag.effectiveCwd(), - "prompt": input, - }, ag.log) - if hookResult.Blocked { - ag.emit(infoEvent("hook blocked prompt")) - return "" - } - if hookResult.Warning != "" { - ag.emit(infoEvent(hookResult.Warning)) - } - if sum := hookResult.Summary(); sum != "" { - ag.emit(infoEvent("preTurn: " + sum)) - } - if hookResult.Context != "" { - input += "\n" + hookResult.Context - } - // Snapshot session state before this turn modifies it. Restored on failure // so the session is clean for the next attempt. - snapSession := ag.history var snapMessages []backend.Message if ag.history != nil { snapMessages = cloneMessages(ag.history.messages) @@ -132,7 +109,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { GenerationParams: ag.runtime.GenParams, MaxSteps: ag.runtime.MaxSteps, ReadPlanStep: ag.readPlanStep, - TurnError: ag.turnError, } var replyBuf strings.Builder @@ -164,12 +140,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { CostUSD: costUSD, }, est != 0) ag.notifyChange() - if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" { - if limit, ferr := strconv.ParseFloat(maxCostStr, 64); ferr == nil && limit > 0 && ag.history.SessionCostUSD >= limit { - ag.emit(infoEvent(fmt.Sprintf("spending cap $%.2f reached — stopping", limit))) - ag.Interrupt(ErrInterrupted) - } - } } ag.emit(ev) } @@ -179,23 +149,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { } return "" } - ag.cfg.PreTool = func(ctx context.Context, name string, args json.RawMessage) HookResult { - return ag.runtime.Hooks.Run(ctx, HookPreTool, map[string]string{ - "session_id": ag.sessionID, - "cwd": ag.effectiveCwd(), - "tool": name, - "args": string(args), - }, ag.log) - } - ag.cfg.PostTool = func(ctx context.Context, name string, args json.RawMessage, result string) HookResult { - return ag.runtime.Hooks.Run(ctx, HookPostTool, map[string]string{ - "session_id": ag.sessionID, - "cwd": ag.effectiveCwd(), - "tool": name, - "args": string(args), - "result": result, - }, ag.log) - } ag.cfg.IncrToolCallCount = func() int64 { return ag.toolCallCount.Add(1) } @@ -238,24 +191,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { } } - // Spending cap: reject before spending more tokens. - if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" && ag.history != nil { - if limit, err := strconv.ParseFloat(maxCostStr, 64); err == nil && limit > 0 { - if ag.history.SessionCostUSD >= limit { - ag.emit(Event{Role: "error", Content: fmt.Sprintf("spending cap $%.2f reached (session total $%.4f)", limit, ag.history.SessionCostUSD)}) - ag.SetState("idle") - actCancel(nil) - ag.currentAction.CompareAndSwap(handle, nil) - if snapSession == nil { - ag.history = nil - } else { - ag.history.messages = snapMessages - } - return "" - } - } - } - if ag.history != nil { ag.history.resetTurnAccumulators() } @@ -315,27 +250,9 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { return "" } ag.emit(Event{Role: "error", Content: err.Error()}) - // Drain one FIFO item — the turnError hook may have queued a recovery prompt. - if next, ok := ag.fifo.Pop(); ok { - return next - } return "" } - stopResult := ag.runtime.Hooks.Run(ctx, HookPostTurn, map[string]string{ - "session_id": ag.sessionID, - "cwd": ag.effectiveCwd(), - }, ag.log) - if stopResult.Warning != "" { - ag.emit(infoEvent(stopResult.Warning)) - } - if sum := stopResult.Summary(); sum != "" { - ag.emit(infoEvent("postTurn: " + sum)) - } - if !stopResult.Blocked && stopResult.Context != "" && ag.history != nil { - ag.history.appendUserMessage(stopResult.Context) - } - if ag.history != nil { ag.history.recordTurnCost(ag.cfg.Backend.Model()) appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history) @@ -348,11 +265,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { } ag.saveSession() - // Post-turn hook said "continue" — its context becomes the next prompt. - if stopResult.Blocked && stopResult.Context != "" { - return stopResult.Context - } - // Inject that was pending but never consumed (text-only response with no // tool calls) — treat it as the next user message. if p := ag.pendingInject.Swap(nil); p != nil { @@ -367,19 +279,18 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { return "" } -// turnError is called on the first backend error of a turn. If the hook handles -// the error (exit 0), the turn exits immediately. -// This is the default implementation; it can be overridden via the turnError field. -func (ag *Agent) defaultTurnError(ctx context.Context, errType, errMsg string) HookResult { - hookCtx, cancel := context.WithTimeout(context.Background(), time.Duration(hookTimeout)*time.Second) - defer cancel() - return ag.runtime.Hooks.Run(hookCtx, HookTurnError, map[string]string{ - "session_id": ag.sessionID, - "cwd": ag.effectiveCwd(), - "model": ag.runtime.Backend.Model(), - "error_type": errType, - "error": errMsg, - }, ag.log) +// spawnContext assembles the agent context injected at each session refresh +// point (session start, post-clear, post-compaction). It combines the +// agent-specific prompt with any AGENTS.md from the working directory. +func (ag *Agent) spawnContext(ctx context.Context) string { + var parts []string + // Inject AGENTS.md from the working directory if it exists. + if cwd := ag.effectiveCwd(); cwd != "" { + if data, err := os.ReadFile(cwd + "/AGENTS.md"); err == nil && len(data) > 0 { + parts = append(parts, string(data)) + } + } + return strings.Join(parts, "\n\n---\n\n") } // autoCompactLimit returns the token threshold for auto-compaction (75%). @@ -400,54 +311,9 @@ func (ag *Agent) autoWarnLimit(ctx context.Context) int { return ctxLen * 3 / 5 } -// spawnContext assembles the agent context injected at each session refresh -// point (session start, post-clear, post-compaction). It combines the -// agent-specific prompt with any agentSpawn hook output. -func (ag *Agent) spawnContext(ctx context.Context) string { - result := ag.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{ - "session_id": ag.sessionID, - "agent": ag.agentName, - "cwd": ag.effectiveCwd(), - "model": ag.runtime.Backend.Model(), - }, ag.log) - if result.Warning != "" { - ag.emit(infoEvent(result.Warning)) - } - if sum := result.Summary(); sum != "" { - ag.emit(infoEvent("agentSpawn: " + sum)) - } - var parts []string - if result.Context != "" { - parts = append(parts, result.Context) - } - // Inject AGENTS.md from the working directory if it exists. - if cwd := ag.effectiveCwd(); cwd != "" { - if data, err := os.ReadFile(cwd + "/AGENTS.md"); err == nil && len(data) > 0 { - parts = append(parts, string(data)) - } - } - return strings.Join(parts, "\n\n---\n\n") -} - -// runCompact executes a full compaction cycle: pre-hook, compact, spawn-context -// re-injection, post-hook. Returns (n compacted, error). Returns (0, nil) if -// the pre-hook blocked or there was nothing to compact. Caller manages setState. +// runCompact executes a full compaction cycle: compact, spawn-context +// re-injection. Returns (n compacted, error). Caller manages setState. func (ag *Agent) runCompact(ctx context.Context, trigger string) (int, error) { - payload := map[string]string{"session_id": ag.sessionID, "trigger": trigger, "cwd": ag.effectiveCwd()} - pre := ag.runtime.Hooks.Run(ctx, HookPreCompact, payload, ag.log) - if pre.Warning != "" { - ag.emit(infoEvent(pre.Warning)) - } - if sum := pre.Summary(); sum != "" { - ag.emit(infoEvent("preCompact: " + sum)) - } - if pre.Blocked { - ag.emit(infoEvent("compact cancelled by hook")) - return 0, nil - } - if pre.Context != "" { - ag.history.appendUserMessage(pre.Context) - } // Use a cheaper model for compaction if configured. compactModel := resolveCompactionModel(ag.runtime.CompactionModel, ag.runtime.Backend) origModel := ag.runtime.Backend.Model() @@ -466,16 +332,6 @@ func (ag *Agent) runCompact(ctx context.Context, trigger string) (int, error) { ag.history.appendUserMessage(sc) } } - post := ag.runtime.Hooks.Run(ctx, HookPostCompact, payload, ag.log) - if post.Warning != "" { - ag.emit(infoEvent(post.Warning)) - } - if sum := post.Summary(); sum != "" { - ag.emit(infoEvent("postCompact: " + sum)) - } - if post.Context != "" { - ag.history.appendUserMessage(post.Context) - } return n, nil } diff --git a/agent/usage_log.go b/agent/usage_log.go index b9e9cbc..b392557 100644 --- a/agent/usage_log.go +++ b/agent/usage_log.go @@ -22,13 +22,9 @@ type usageEntry struct { CostUSD float64 `json:"cost_usd"` } -// usageLogPath returns the path for the usage log file. -// Respects OLLIE_USAGE_LOG env var; defaults to ~/.local/share/ollie/usage.jsonl. +// usageLogPath returns the path for the usage log file: DataDir()/usage.jsonl. func usageLogPath() string { - if p := os.Getenv("OLLIE_USAGE_LOG"); p != "" { - return p - } - return filepath.Join(paths.DataDir(), "usage.jsonl") + return paths.UsageLogFile() } // appendUsageLog writes a single usage entry to the append-only JSONL log. diff --git a/backend/new.go b/backend/new.go index 4621795..61055c1 100644 --- a/backend/new.go +++ b/backend/new.go @@ -48,10 +48,7 @@ func loadEnvFile(path string) { // so the file acts as a default; variables already set in the environment take // precedence. // -// OLLIE_BACKEND ollama | openai | openrouter | anthropic | copilot | kiro | gemini -// OLLIE_OLLAMA_URL base URL for ollama (default: http://localhost:11434) -// OLLIE_OPENAI_URL base URL for openai-compatible backends -// OLLIE_OPENAI_KEY API key (required for openai/openrouter) +// OLLIE_OPENAI_KEY API key (required for openai/openrouter/gemini) // OLLIE_ANTHROPIC_KEY API key (required for anthropic) // OLLIE_GEMINI_KEY Google AI Studio API key (required for gemini; falls back to OLLIE_OPENAI_KEY) // OLLIE_COPILOT_TOKEN bearer token (required for copilot) @@ -63,18 +60,28 @@ func New() (Backend, error) { // NewWithName constructs a Backend for the given backend name, loading // env-file defaults for API keys/URLs but ignoring OLLIE_BACKEND. -// If name is empty, falls back to OLLIE_BACKEND; errors if neither is set. +// If name is empty, falls back to ~/.config/ollie/backend; errors if neither is set. func NewWithName(name string) (Backend, error) { loadEnvFile(paths.CfgDir() + "/env") if name == "" { - name = os.Getenv("OLLIE_BACKEND") + name = readDefaultBackend() if name == "" { - return nil, fmt.Errorf("no backend specified: set OLLIE_BACKEND or pass backend= to the session") + return nil, fmt.Errorf("no backend specified: set backend= in agent config or create ~/.config/ollie/backend") } } return newBackend(name) } +// readDefaultBackend reads the default backend from ~/.config/ollie/backend. +// Returns "" if the file doesn't exist or is empty. +func readDefaultBackend() string { + data, err := os.ReadFile(paths.CfgDir() + "/backend") + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + func newFromEnv(envFile string) (Backend, error) { loadEnvFile(envFile) @@ -89,14 +96,11 @@ func newFromEnv(envFile string) (Backend, error) { func newBackend(which string) (Backend, error) { switch which { case "ollama": - return NewOllama(os.Getenv("OLLIE_OLLAMA_URL")) - case "openai", "openrouter": - url := os.Getenv("OLLIE_OPENAI_URL") - key := os.Getenv("OLLIE_OPENAI_KEY") - if key == "" { - return nil, fmt.Errorf("OLLIE_OPENAI_KEY is required for %s backend", which) - } - return NewOpenAI(openAIName(which, url), url, key) + return NewOllama(os.Getenv("OLLAMA_HOST")) + case "openai": + return NewOpenAI("openai", "https://api.openai.com/v1", os.Getenv("OLLIE_OPENAI_KEY")) + case "openrouter": + return NewOpenAI("openrouter", "https://openrouter.ai/api/v1", os.Getenv("OLLIE_OPENAI_KEY")) case "anthropic": key := os.Getenv("OLLIE_ANTHROPIC_KEY") if key == "" { @@ -126,27 +130,3 @@ func newBackend(which string) (Backend, error) { func Backends() []string { return []string{"ollama", "openai", "openrouter", "anthropic", "copilot", "kiro", "gemini"} } - -// openAIName derives a short backend label from the OLLIE_BACKEND value and -// the base URL, so openai-compatible endpoints self-identify correctly. -func openAIName(which, url string) string { - url = strings.ToLower(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, "generativelanguage") || strings.Contains(url, "google"): - return "gemini" - case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): - return "local" - default: - return which - } -} diff --git a/backend/new_test.go b/backend/new_test.go index 2b01f10..aeb921a 100644 --- a/backend/new_test.go +++ b/backend/new_test.go @@ -32,7 +32,7 @@ func clearEnv(t *testing.T, keys ...string) { } func TestNewFromEnv_NoBackendErrors(t *testing.T) { - clearEnv(t, "OLLIE_BACKEND", "OLLIE_OLLAMA_URL") + clearEnv(t, "OLLIE_BACKEND") _, err := newFromEnv("/nonexistent") if err == nil { t.Fatal("expected error when OLLIE_BACKEND is unset, got nil") @@ -40,7 +40,7 @@ func TestNewFromEnv_NoBackendErrors(t *testing.T) { } func TestNewFromEnv_Ollama(t *testing.T) { - setEnv(t, map[string]string{"OLLIE_BACKEND": "ollama", "OLLIE_OLLAMA_URL": "http://myhost:11434"}) + setEnv(t, map[string]string{"OLLIE_BACKEND": "ollama"}) b, err := newFromEnv("/nonexistent") if err != nil { t.Fatal(err) @@ -51,7 +51,7 @@ func TestNewFromEnv_Ollama(t *testing.T) { } func TestNewFromEnv_OpenAI(t *testing.T) { - setEnv(t, map[string]string{"OLLIE_BACKEND": "openai", "OLLIE_OPENAI_URL": "https://api.openai.com", "OLLIE_OPENAI_KEY": "sk-test"}) + setEnv(t, map[string]string{"OLLIE_BACKEND": "openai", "OLLIE_OPENAI_KEY": "sk-test"}) b, err := newFromEnv("/nonexistent") if err != nil { t.Fatal(err) @@ -62,7 +62,7 @@ func TestNewFromEnv_OpenAI(t *testing.T) { } func TestNewFromEnv_OpenRouter(t *testing.T) { - setEnv(t, map[string]string{"OLLIE_BACKEND": "openrouter", "OLLIE_OPENAI_URL": "https://openrouter.ai/api", "OLLIE_OPENAI_KEY": "sk-or-test"}) + setEnv(t, map[string]string{"OLLIE_BACKEND": "openrouter", "OLLIE_OPENAI_KEY": "sk-or-test"}) b, err := newFromEnv("/nonexistent") if err != nil { t.Fatal(err) @@ -166,7 +166,6 @@ func TestNewFromEnv_EnvOverridesFile(t *testing.T) { os.WriteFile(path, []byte("OLLIE_BACKEND=anthropic\n"), 0644) setEnv(t, map[string]string{"OLLIE_BACKEND": "ollama"}) - clearEnv(t, "OLLIE_OLLAMA_URL") b, err := newFromEnv(path) if err != nil { t.Fatal(err) @@ -176,30 +175,6 @@ func TestNewFromEnv_EnvOverridesFile(t *testing.T) { } } -func TestOpenAIName(t *testing.T) { - tests := []struct { - which, url, want string - }{ - {"openai", "https://openrouter.ai/api", "openrouter"}, - {"openai", "https://api.together.xyz", "together"}, - {"openai", "https://api.groq.com", "groq"}, - {"openai", "https://api.mistral.ai", "mistral"}, - {"openai", "https://api.anthropic.com", "anthropic"}, - {"openai", "http://localhost:8080", "local"}, - {"openai", "http://127.0.0.1:1234", "local"}, - {"openai", "https://api.openai.com", "openai"}, - {"openrouter", "", "openrouter"}, - {"openai", "", "openai"}, - // case insensitive - {"openai", "https://API.GROQ.COM", "groq"}, - } - for _, tt := range tests { - if got := openAIName(tt.which, tt.url); got != tt.want { - t.Errorf("openAIName(%q, %q) = %q; want %q", tt.which, tt.url, got, tt.want) - } - } -} - func TestBackends(t *testing.T) { bs := Backends() if len(bs) != 7 { diff --git a/backend/oneshot.go b/backend/oneshot.go index 8a1c16c..3bc913f 100644 --- a/backend/oneshot.go +++ b/backend/oneshot.go @@ -5,7 +5,6 @@ package backend import ( "context" "fmt" - "os" "strings" ) @@ -45,97 +44,3 @@ func Generate(ctx context.Context, req GenerateRequest) (string, error) { } return sb.String(), nil } - -// RouteRequest is the input for model routing. -type RouteRequest struct { - Task string `json:"task"` - Backend string `json:"backend"` // optional: only consider models from this backend -} - -// RouteResult is the output of model routing. -type RouteResult struct { - Backend string - Model string -} - -// Route classifies a task description and selects the most appropriate model -// from the actually available models. Fetches the model list from configured -// backends, presents them to a cheap classifier, and returns the selection. -func Route(ctx context.Context, req RouteRequest) (RouteResult, error) { - if req.Task == "" { - return RouteResult{}, fmt.Errorf("provide a task description") - } - - // Classifier backend/model - classifyBackend := os.Getenv("OLLIE_ROUTE_BACKEND") - if classifyBackend == "" { - classifyBackend = "ollama" - } - classifyModel := os.Getenv("OLLIE_ROUTE_MODEL") - if classifyModel == "" { - classifyModel = "qwen3:8b" - } - cbe, err := NewWithName(classifyBackend) - if err != nil { - return RouteResult{}, fmt.Errorf("route backend: %w", err) - } - cbe.SetModel(classifyModel) - - // Fetch available models - type modelEntry struct { - backend string - model string - } - var entries []modelEntry - var modelList strings.Builder - - backends := Backends() - if req.Backend != "" { - backends = []string{req.Backend} - } - for _, name := range backends { - b, err := NewWithName(name) - if err != nil { - continue - } - for _, m := range b.Models(ctx) { - entries = append(entries, modelEntry{backend: name, model: m}) - fmt.Fprintf(&modelList, "%d. %s/%s\n", len(entries), name, m) - } - } - if len(entries) == 0 { - return RouteResult{}, fmt.Errorf("no models available") - } - - classifyPrompt := fmt.Sprintf(`You are a task router. Given a task and a numbered list of available models, select the single best model for the task. - -Available models: -%s -Reply with ONLY the number of the best model. Nothing else. - -Task: %s`, modelList.String(), req.Task) - - msgs := []Message{ - {Role: "user", Content: classifyPrompt}, - } - ch, err := cbe.ChatStream(ctx, msgs, nil, GenerationParams{}) - if err != nil { - return RouteResult{}, fmt.Errorf("route classification failed: %w", err) - } - var sb strings.Builder - for ev := range ch { - sb.WriteString(ev.Content) - } - result := strings.TrimSpace(sb.String()) - - // Parse the number from the response - var choice int - fmt.Sscanf(result, "%d", &choice) - if choice < 1 || choice > len(entries) { - // Default to first model if parsing fails - choice = 1 - } - picked := entries[choice-1] - - return RouteResult{Backend: picked.backend, Model: picked.model}, nil -} diff --git a/env.sample b/env.sample index f62f5a0..efa6373 100644 --- a/env.sample +++ b/env.sample @@ -6,11 +6,10 @@ # Supported: ollama | openai | openrouter | anthropic | copilot | kiro | gemini OLLIE_BACKEND=ollama -# Ollama -OLLIE_OLLAMA_URL=http://localhost:11434 +# Ollama (also accepts standard OLLAMA_HOST) +OLLAMA_HOST=http://localhost:11434 # OpenAI / OpenAI-compatible (openai, openrouter) -OLLIE_OPENAI_URL= OLLIE_OPENAI_KEY= # Anthropic diff --git a/env/env.go b/env/env.go index c2f3081..0283b83 100644 --- a/env/env.go +++ b/env/env.go @@ -1,11 +1,10 @@ // Package env manages the daemon-global environment for ollie. -// It owns the set of known OLLIE_* and SUPERPOWERD_* variables, provides -// defaults, and formats them for export to frontends via ollie/env. +// It loads $XDG_CONFIG_HOME/ollie/env and provides defaults, +// formatted for export to frontends via ollie/env. package env import ( "bufio" - "fmt" "os" "path/filepath" "strings" @@ -13,48 +12,10 @@ import ( "ollie/paths" ) -// managed is the ordered list of env vars that ollie exposes to frontends. -var managed = []string{ - "OLLIE", - "OLLIE_CFG_PATH", - "OLLIE_TOOLS_PATH", - "OLLIE_AGENTS_PATH", - "OLLIE_SKILLS_PATH", - "OLLIE_PROMPTS_PATH", - "OLLIE_MEMORY_PATH", - "OLLIE_TMP_PATH", - "OLLIE_TRANSCRIPT_PATH", - "OLLIE_ELEVATE_SOCKET", - "SUPERPOWERD_SESSION_TOKEN", - "SUPERPOWERD_SOCKET_DIR", -} - -// EnsureDefaults loads ~/.config/ollie/env and sets default values for any +// EnsureDefaults loads $XDG_CONFIG_HOME/ollie/env and sets default values for any // OLLIE_* vars not already present in the process environment. func EnsureDefaults() { - home, _ := os.UserHomeDir() - LoadFile(filepath.Join(home, ".config", "ollie", "env")) - xdgRuntime := os.Getenv("XDG_RUNTIME_DIR") - if xdgRuntime == "" { - xdgRuntime = fmt.Sprintf("/run/user/%d", os.Getuid()) - } - defaults := map[string]string{ - "OLLIE": filepath.Join(home, "mnt", "ollie"), - "OLLIE_CFG_PATH": filepath.Join(home, ".config", "ollie"), - "OLLIE_TOOLS_PATH": filepath.Join(home, ".config", "ollie", "tools"), - "OLLIE_AGENTS_PATH": filepath.Join(home, ".config", "ollie", "agents"), - "OLLIE_SKILLS_PATH": filepath.Join(home, ".config", "ollie", "skills"), - "OLLIE_PROMPTS_PATH": filepath.Join(home, ".config", "ollie", "prompts"), - "OLLIE_MEMORY_PATH": filepath.Join(home, ".config", "ollie", "memory"), - "OLLIE_TMP_PATH": filepath.Join(home, ".local", "share", "ollie", "tmp"), - "OLLIE_TRANSCRIPT_PATH": filepath.Join(home, ".config", "ollie", "transcript"), - "OLLIE_ELEVATE_SOCKET": filepath.Join(xdgRuntime, "ollie", "elevate.sock"), - } - for k, v := range defaults { - if os.Getenv(k) == "" { - os.Setenv(k, v) //nolint:errcheck - } - } + LoadFile(filepath.Join(paths.CfgDir(), "env")) } // Set sets a variable in the process environment. @@ -96,26 +57,3 @@ func LoadFile(path string) { // Get returns a variable from the process environment. func Get(k string) string { return os.Getenv(k) } - -// All returns the full process environment as a map. -func All() map[string]string { - pairs := os.Environ() - m := make(map[string]string, len(pairs)) - for _, pair := range pairs { - k, v, _ := strings.Cut(pair, "=") - m[k] = v - } - return m -} - -// Format returns the managed environment variables as NAME=VALUE lines, -// suitable for serving as ollie/env. -func Format() []byte { - var sb strings.Builder - for _, k := range managed { - if v := os.Getenv(k); v != "" { - fmt.Fprintf(&sb, "%s=%s\n", k, v) - } - } - return []byte(sb.String()) -} diff --git a/env/env_test.go b/env/env_test.go index 75a1fda..3eb5dfe 100644 --- a/env/env_test.go +++ b/env/env_test.go @@ -2,32 +2,9 @@ package env import ( "os" - "strings" "testing" ) -func TestEnsureDefaults(t *testing.T) { - // Clear managed vars so defaults apply. - for _, k := range managed { - t.Setenv(k, "") - os.Unsetenv(k) - } - EnsureDefaults() - for _, k := range []string{"OLLIE", "OLLIE_TOOLS_PATH", "OLLIE_AGENTS_PATH", "OLLIE_SKILLS_PATH", "OLLIE_PROMPTS_PATH", "OLLIE_MEMORY_PATH", "OLLIE_TMP_PATH", "OLLIE_TRANSCRIPT_PATH"} { - if v := os.Getenv(k); v == "" { - t.Errorf("%s not set after EnsureDefaults", k) - } - } -} - -func TestEnsureDefaultsNoOverwrite(t *testing.T) { - t.Setenv("OLLIE", "/custom") - EnsureDefaults() - if v := os.Getenv("OLLIE"); v != "/custom" { - t.Errorf("OLLIE = %q, want /custom (should not overwrite)", v) - } -} - func TestSetGet(t *testing.T) { t.Setenv("OLLIE_TEST_VAR", "") Set("OLLIE_TEST_VAR", "hello") @@ -36,27 +13,24 @@ func TestSetGet(t *testing.T) { } } -func TestAll(t *testing.T) { - t.Setenv("OLLIE_TEST_ALL", "val") - m := All() - if m["OLLIE_TEST_ALL"] != "val" { - t.Errorf("All() missing OLLIE_TEST_ALL") +func TestLoadFile(t *testing.T) { + tmp := t.TempDir() + path := tmp + "/env" + os.WriteFile(path, []byte("OLLIE_LOAD_TEST=from-file\n"), 0644) //nolint:errcheck + t.Setenv("OLLIE_LOAD_TEST", "") + LoadFile(path) + if v := os.Getenv("OLLIE_LOAD_TEST"); v != "from-file" { + t.Errorf("OLLIE_LOAD_TEST = %q, want from-file", v) } } -func TestFormat(t *testing.T) { - t.Setenv("OLLIE_TMP_PATH", "/tmp/test-fmt") - out := string(Format()) - if !strings.Contains(out, "OLLIE_TMP_PATH=/tmp/test-fmt") { - t.Errorf("Format() missing expected var, got:\n%s", out) - } - // Every line should be NAME=VALUE\n - for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") { - if line == "" { - continue - } - if !strings.Contains(line, "=") { - t.Errorf("malformed line: %q", line) - } +func TestLoadFileNoOverwrite(t *testing.T) { + t.Setenv("OLLIE_NO_OVERWRITE", "existing") + tmp := t.TempDir() + path := tmp + "/env" + os.WriteFile(path, []byte("OLLIE_NO_OVERWRITE=from-file\n"), 0644) //nolint:errcheck + LoadFile(path) + if v := os.Getenv("OLLIE_NO_OVERWRITE"); v != "existing" { + t.Errorf("OLLIE_NO_OVERWRITE = %q, want existing (should not overwrite)", v) } } diff --git a/log/log.go b/log/log.go index 0000bef..e8516c2 100644 --- a/log/log.go +++ b/log/log.go @@ -93,10 +93,10 @@ func (s *Sink) Logger(tag string, level Level) *Logger { } // NewLogger creates a Logger from this Sink, reading the level from -// OLLIE_{TAG}_LOG and falling back to the Sink's default level. +// OLLIE_LOG and falling back to the Sink's default level. func (s *Sink) NewLogger(tag string) *Logger { l := s.level - if env := os.Getenv("OLLIE_" + strings.ToUpper(tag) + "_LOG"); env != "" { + if env := os.Getenv("OLLIE_LOG"); env != "" { l = ParseLevel(env, l) } return s.Logger(tag, l) @@ -133,12 +133,7 @@ func (l *Logger) Warn(format string, args ...any) { l.emit(LevelWarn, format, a func (l *Logger) Error(format string, args ...any) { l.emit(LevelError, format, args...) } // Sub returns a new Logger with the given tag sharing the same output writers -// and inheriting this logger's level. The level can be overridden independently -// via the OLLIE_{TAG}_LOG environment variable. +// and inheriting this logger's level. func (l *Logger) Sub(tag string) *Logger { - level := l.level - if env := os.Getenv("OLLIE_" + strings.ToUpper(tag) + "_LOG"); env != "" { - level = ParseLevel(env, level) - } - return &Logger{tag: tag, level: level, out: l.out, errout: l.errout} + return &Logger{tag: tag, level: l.level, out: l.out, errout: l.errout} } diff --git a/log/log_test.go b/log/log_test.go index 43069bd..70b2668 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -114,7 +114,7 @@ func TestSinkFlush(t *testing.T) { func TestSinkNewLogger(t *testing.T) { var out bytes.Buffer s := NewSink(&out, &out, LevelWarn) - t.Setenv("OLLIE_MYTEST_LOG", "debug") + t.Setenv("OLLIE_LOG", "debug") l := s.NewLogger("mytest") l.Debug("visible") s.Flush() @@ -126,7 +126,7 @@ func TestSinkNewLogger(t *testing.T) { func TestSinkNewLoggerDefault(t *testing.T) { var out bytes.Buffer s := NewSink(&out, &out, LevelError) - t.Setenv("OLLIE_NOTSET_LOG", "") + t.Setenv("OLLIE_LOG", "") l := s.NewLogger("notset") l.Warn("skip") s.Flush() diff --git a/paths/paths.go b/paths/paths.go index 7a0c5f5..eda139c 100644 --- a/paths/paths.go +++ b/paths/paths.go @@ -2,6 +2,8 @@ package paths import ( "os" + "path/filepath" + "strconv" "strings" ) @@ -15,20 +17,63 @@ func ExpandHome(path string) string { return path } -// CfgDir returns the ollie config root from OLLIE_CFG_PATH, defaulting to ~/.config/ollie. +// CfgDir returns the ollie config root: ~/.config/ollie. func CfgDir() string { - if p := os.Getenv("OLLIE_CFG_PATH"); p != "" { - return p - } home, _ := os.UserHomeDir() return home + "/.config/ollie" } -// DataDir returns the ollie data root from OLLIE_DATA_PATH, defaulting to ~/.local/share/ollie. +// DataDir returns the ollie data root: ~/.local/share/ollie. func DataDir() string { - if p := os.Getenv("OLLIE_DATA_PATH"); p != "" { - return p - } home, _ := os.UserHomeDir() return home + "/.local/share/ollie" } + +// ToolsDir returns the tools directory: CfgDir()/tools. +func ToolsDir() string { + return CfgDir() + "/tools" +} + +// PromptsDirs returns prompt directories: [CfgDir()/prompts]. +func PromptsDirs() []string { + return []string{CfgDir() + "/prompts"} +} + +// AgentsDirs returns agent directories: [CfgDir()/agents]. +func AgentsDirs() []string { + return []string{CfgDir() + "/agents"} +} + +// SkillsDir returns the skills directory: CfgDir()/skills. +func SkillsDir() string { + return CfgDir() + "/skills" +} + +// MemoryDir returns the memory directory: CfgDir()/memory. +func MemoryDir() string { + return CfgDir() + "/memory" +} + +// TranscriptDir returns the transcript directory: CfgDir()/transcript. +func TranscriptDir() string { + return CfgDir() + "/transcript" +} + +// TmpDir returns the temporary working directory: $TMPDIR/ollie. +func TmpDir() string { + return filepath.Join(os.TempDir(), "ollie") +} + +// UsageLogFile returns the usage log file: DataDir()/usage.jsonl. +func UsageLogFile() string { + return filepath.Join(DataDir(), "usage.jsonl") +} + +// ElevateSocket returns the elevation broker socket: XDG_RUNTIME_DIR/ollie/elevate.sock. +func ElevateSocket() string { + xdgRuntime := os.Getenv("XDG_RUNTIME_DIR") + if xdgRuntime == "" { + xdgRuntime = filepath.Join("/run/user", strconv.FormatInt(int64(os.Getuid()), 10)) + } + return filepath.Join(xdgRuntime, "ollie", "elevate.sock") +} diff --git a/paths/paths_test.go b/paths/paths_test.go index e96151f..ba4178f 100644 --- a/paths/paths_test.go +++ b/paths/paths_test.go @@ -5,30 +5,14 @@ import ( "testing" ) -func TestCfgDirFromEnv(t *testing.T) { - t.Setenv("OLLIE_CFG_PATH", "/custom/cfg") - if got := CfgDir(); got != "/custom/cfg" { - t.Errorf("CfgDir() = %q; want /custom/cfg", got) - } -} - func TestCfgDirDefault(t *testing.T) { - t.Setenv("OLLIE_CFG_PATH", "") home, _ := os.UserHomeDir() if got := CfgDir(); got != home+"/.config/ollie" { t.Errorf("CfgDir() = %q; want %s/.config/ollie", got, home) } } -func TestDataDirFromEnv(t *testing.T) { - t.Setenv("OLLIE_DATA_PATH", "/custom/data") - if got := DataDir(); got != "/custom/data" { - t.Errorf("DataDir() = %q; want /custom/data", got) - } -} - func TestDataDirDefault(t *testing.T) { - t.Setenv("OLLIE_DATA_PATH", "") home, _ := os.UserHomeDir() if got := DataDir(); got != home+"/.local/share/ollie" { t.Errorf("DataDir() = %q; want %s/.local/share/ollie", got, home) diff --git a/sandbox/config.go b/sandbox/config.go index 47e0d11..54e5731 100644 --- a/sandbox/config.go +++ b/sandbox/config.go @@ -101,10 +101,6 @@ func expandPath(pattern, cwd string, getenv EnvFunc) string { return xdg } return fmt.Sprintf("/run/user/%d", os.Getuid()) - case "OLLIE_CFG_PATH": - return paths.CfgDir() - case "OLLIE_DATA_PATH": - return paths.DataDir() } if val := getenv(varName); val != "" { diff --git a/sandbox/sandbox_test.go b/sandbox/sandbox_test.go index 8f6019e..d20e8a3 100644 --- a/sandbox/sandbox_test.go +++ b/sandbox/sandbox_test.go @@ -363,7 +363,7 @@ filesystem: rw: - "{CWD}" rwx: - - "{OLLIE_CFG_PATH}" + - "{XDG_CONFIG_HOME}/ollie" network: enabled: true unrestricted: false @@ -397,8 +397,8 @@ advanced: if !containsStr(cfg.Filesystem.RW, "{CWD}") { t.Error("RW missing {CWD}") } - if !containsStr(cfg.Filesystem.RWX, "{OLLIE_CFG_PATH}") { - t.Error("RWX missing {OLLIE_CFG_PATH}") + if !containsStr(cfg.Filesystem.RWX, "{XDG_CONFIG_HOME}/ollie") { + t.Error("RWX missing {XDG_CONFIG_HOME}/ollie") } if !cfg.Network.Enabled { t.Error("Network.Enabled should be true") @@ -511,14 +511,18 @@ func TestExpandPath_XDG_FromEnv(t *testing.T) { } } -func TestExpandPath_OlliePaths(t *testing.T) { - got1 := expandPath("{OLLIE_CFG_PATH}", "/cwd", os.Getenv) - if got1 == "{OLLIE_CFG_PATH}" || got1 == "" { - t.Errorf("OLLIE_CFG_PATH not expanded: %q", got1) +func TestExpandPath_XDGPaths(t *testing.T) { + // XDG_CONFIG_HOME expansion + t.Setenv("XDG_CONFIG_HOME", "/custom/config") + got1 := expandPath("{XDG_CONFIG_HOME}/ollie", "/cwd", os.Getenv) + if got1 != "/custom/config/ollie" { + t.Errorf("XDG_CONFIG_HOME/ollie not expanded: %q", got1) } - got2 := expandPath("{OLLIE_DATA_PATH}", "/cwd", os.Getenv) - if got2 == "{OLLIE_DATA_PATH}" || got2 == "" { - t.Errorf("OLLIE_DATA_PATH not expanded: %q", got2) + // XDG_DATA_HOME expansion + t.Setenv("XDG_DATA_HOME", "/custom/data") + got2 := expandPath("{XDG_DATA_HOME}/ollie", "/cwd", os.Getenv) + if got2 != "/custom/data/ollie" { + t.Errorf("XDG_DATA_HOME/ollie not expanded: %q", got2) } } @@ -625,69 +629,20 @@ func TestWrapCommand_SortTiebreaker(t *testing.T) { } } -func TestWrapCommand_ColonSeparatedPaths(t *testing.T) { +func TestWrapCommand_EnvVarExpansion(t *testing.T) { tmpDir := t.TempDir() - dir1 := filepath.Join(tmpDir, "skills1") - dir2 := filepath.Join(tmpDir, "skills2") - os.MkdirAll(dir1, 0755) - os.MkdirAll(dir2, 0755) + skillsDir := filepath.Join(tmpDir, "skills") + os.MkdirAll(skillsDir, 0755) - // Simulate OLLIE_SKILLS_PATH="/path1:/path2" - t.Setenv("OLLIE_SKILLS_PATH", dir1+":"+dir2) + t.Setenv("XDG_CONFIG_HOME", tmpDir) cfg := &Config{ Filesystem: FilesystemConfig{ - RWX: []string{"{OLLIE_SKILLS_PATH}"}, + RWX: []string{"{XDG_CONFIG_HOME}/skills"}, }, } got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir) - assertFlagValue(t, got, "--rwx", dir1) - assertFlagValue(t, got, "--rwx", dir2) -} - -func TestCheckPath_ColonSeparatedPaths(t *testing.T) { - tmpDir := t.TempDir() - dir1 := filepath.Join(tmpDir, "skills1") - dir2 := filepath.Join(tmpDir, "skills2") - os.MkdirAll(dir1, 0755) - os.MkdirAll(dir2, 0755) - - file1 := filepath.Join(dir1, "test.md") - os.WriteFile(file1, []byte("hi"), 0644) - file2 := filepath.Join(dir2, "test.md") - os.WriteFile(file2, []byte("hi"), 0644) - - getenv := func(k string) string { - if k == "OLLIE_SKILLS_PATH" { - return dir1 + ":" + dir2 - } - if k == "HOME" { - return tmpDir - } - return "" - } - - cfg := &Config{ - Filesystem: FilesystemConfig{ - RWX: []string{"{OLLIE_SKILLS_PATH}"}, - }, - } - - // Both paths should be allowed for read - if err := checkPath(cfg, file1, false, tmpDir, getenv); err != nil { - t.Errorf("file in dir1 should be allowed: %v", err) - } - if err := checkPath(cfg, file2, false, tmpDir, getenv); err != nil { - t.Errorf("file in dir2 should be allowed: %v", err) - } - - // Path outside both should be denied - outside := filepath.Join(tmpDir, "outside", "file.txt") - os.MkdirAll(filepath.Dir(outside), 0755) - os.WriteFile(outside, []byte("x"), 0644) - if err := checkPath(cfg, outside, false, tmpDir, getenv); err == nil { - t.Error("path outside both dirs should be denied") - } + assertFlagValue(t, got, "--rwx", skillsDir) } // ---- helpers ---- diff --git a/sandbox/wrapper.go b/sandbox/wrapper.go index 4831229..f11c1ff 100644 --- a/sandbox/wrapper.go +++ b/sandbox/wrapper.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "sort" - "strings" ) // pathEntry holds a path with its permission type @@ -48,12 +47,9 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string, getenv EnvFunc) var entries []pathEntry addPaths := func(paths []string, flag string) { for _, path := range paths { - expanded := expandPath(path, cwd, getenv) - // Handle colon-separated paths (e.g. OLLIE_SKILLS_PATH) - for _, p := range strings.Split(expanded, ":") { - if p != "" && pathExists(p) { - entries = append(entries, pathEntry{p, flag}) - } + p := expandPath(path, cwd, getenv) + if p != "" && pathExists(p) { + entries = append(entries, pathEntry{p, flag}) } } } diff --git a/session/session.go b/session/session.go index e0146fc..7d3606f 100644 --- a/session/session.go +++ b/session/session.go @@ -87,10 +87,7 @@ func NextUncheckedStep(data []byte) string { var sweepTmpOnce sync.Once func ollieTmpDir() string { - if p := os.Getenv("OLLIE_TMP_PATH"); p != "" { - return p - } - return filepath.Join(os.TempDir(), "ollie") + return paths.TmpDir() } func sweepStaleTmpDirs() { diff --git a/skills/registry.go b/skills/registry.go index fcd4d25..7eb6883 100644 --- a/skills/registry.go +++ b/skills/registry.go @@ -9,16 +9,13 @@ import ( "sort" "strings" "sync" + + "ollie/paths" ) -// SkillsPath returns the directory to search for skill modules. -// Resolved from OLLIE_SKILLS_PATH, then ~/.config/ollie/skills. +// SkillsPath returns the skills directory: CfgDir()/skills. func SkillsPath() string { - if p := os.Getenv("OLLIE_SKILLS_PATH"); p != "" { - return p - } - home, _ := os.UserHomeDir() - return home + "/.config/ollie/skills" + return paths.SkillsDir() } // SkillInfo describes an available skill module. diff --git a/toolsrv/bootstrap.sh b/toolsrv/bootstrap.sh index cac9aa9..6faa629 100644 --- a/toolsrv/bootstrap.sh +++ b/toolsrv/bootstrap.sh @@ -9,7 +9,7 @@ CACHE_BIN="$CACHE_DIR/ollie-remote" CACHE_HASH="$CACHE_DIR/ollie-remote.hash" # if [ -x "$CACHE_BIN" ] && [ -f "$CACHE_HASH" ] && [ "$(cat "$CACHE_HASH")" = "@@HASH@@" ]; then - echo "OLLIE_LOADER_START {\"need_download\":false}" >&2 + echo "LoaderStart {\"need_download\":false}" >&2 else # Find base64 decoder if command -v base64 >/dev/null 2>&1; then @@ -32,12 +32,12 @@ else command -v gunzip >/dev/null 2>&1 || GUNZIP_CMD="gzip -d" # mkdir -p "$CACHE_DIR" - echo "OLLIE_LOADER_START {\"need_download\":true}" >&2 + echo "LoaderStart {\"need_download\":true}" >&2 # # Read download size, then binary data while IFS= read -r line; do case "$line" in - OLLIE_DOWNLOAD\ *) BINARY_LENGTH="${line#OLLIE_DOWNLOAD }"; break ;; + Download\ *) BINARY_LENGTH="${line#Download }"; break ;; esac done # @@ -50,7 +50,7 @@ else chmod +x "$CACHE_TMP" mv "$CACHE_TMP" "$CACHE_BIN" echo "@@HASH@@" > "$CACHE_HASH" - echo "OLLIE_LOADER_READY" >&2 + echo "LoaderReady" >&2 fi # exec "$CACHE_BIN" serve --cwd @@CWD@@ diff --git a/toolsrv/discover.go b/toolsrv/discover.go index 63a13cc..87927a7 100644 --- a/toolsrv/discover.go +++ b/toolsrv/discover.go @@ -9,17 +9,9 @@ import ( "ollie/paths" ) -// ToolsPath returns the directory to search for named tool scripts. -// Resolved in order: first entry of OLLIE_TOOLS_PATH (colon-separated), -// then ~/.config/ollie/tools. +// ToolsPath returns the tools directory: CfgDir()/tools. func ToolsPath() string { - if p := os.Getenv("OLLIE_TOOLS_PATH"); p != "" { - if i := strings.Index(p, ":"); i >= 0 { - p = p[:i] - } - return p - } - return paths.CfgDir() + "/tools" + return paths.ToolsDir() } diff --git a/toolsrv/remote.go b/toolsrv/remote.go index 1ee3731..cbbf3ce 100644 --- a/toolsrv/remote.go +++ b/toolsrv/remote.go @@ -137,14 +137,14 @@ func RemoteDial(ctx context.Context, cfg RemoteConfig) (*RemoteServer, error) { go func() { for stderrScanner.Scan() { line := strings.TrimSpace(stderrScanner.Text()) - if after, ok := strings.CutPrefix(line, "OLLIE_LOADER_START "); ok { + if after, ok := strings.CutPrefix(line, "LoaderStart "); ok { var msg loaderMsg json.Unmarshal([]byte(after), &msg) loaderCh <- msg if !msg.NeedDownload { close(readyCh) } - } else if line == "OLLIE_LOADER_READY" { + } else if line == "LoaderReady" { close(readyCh) } } @@ -229,7 +229,7 @@ func transferBinary(stdin io.Writer, binData []byte) error { return err } encoded := base64.StdEncoding.EncodeToString(compressed.Bytes()) - if _, err := fmt.Fprintf(stdin, "OLLIE_DOWNLOAD %d\n", len(encoded)); err != nil { + if _, err := fmt.Fprintf(stdin, "Download %d\n", len(encoded)); err != nil { return err } if _, err := stdin.Write([]byte(encoded)); err != nil { diff --git a/toolsrv/server.go b/toolsrv/server.go index 748c779..eb5777b 100644 --- a/toolsrv/server.go +++ b/toolsrv/server.go @@ -328,7 +328,7 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra workDir := e.cwd e.wdMu.RUnlock() // Broker protocol has no stdin support; pipe JSON via heredoc. - elevatedCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", code, stdinData) + elevatedCode := fmt.Sprintf("cat <<'EOF' | %s\n%s\nEOF", code, stdinData) result, err = e.executeElevated(ctx, elevatedCode, workDir, 30) } else { result, err = e.executeWithStdin(ctx, code, "bash", 30, "default", false, stdinData) diff --git a/toolsrv/shell.go b/toolsrv/shell.go index bb7920d..fd096e8 100644 --- a/toolsrv/shell.go +++ b/toolsrv/shell.go @@ -117,14 +117,7 @@ func (lw *limitedWriter) Write(p []byte) (n int, err error) { // Connects to the broker socket, sends the request with the current env, // and streams the framed response back. func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) { - sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET") - if sockPath == "" { - xdg := os.Getenv("XDG_RUNTIME_DIR") - if xdg == "" { - return "", fmt.Errorf("elevation not available: no XDG_RUNTIME_DIR") - } - sockPath = filepath.Join(xdg, "ollie", "elevate.sock") - } + sockPath := paths.ElevateSocket() wantDetach := len(doDetach) > 0 && doDetach[0] @@ -502,7 +495,6 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti } } cmd.Env = prependOlliePath(filtered, paths.CfgDir()) - cmd.Env = append(cmd.Env, "OLLIE_TOOLS_PATH="+ToolsPath()) for k, v := range e.envExtra { cmd.Env = append(cmd.Env, k+"="+v) } @@ -644,7 +636,7 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti } } -// prependOlliePath returns env with $OLLIE_CFG_PATH/scripts/x/ prepended to PATH so wrapper +// prependOlliePath returns env with $XDG_CONFIG_HOME/ollie/scripts/x/ prepended to PATH so wrapper // scripts placed there shadow system binaries. func prependOlliePath(env []string, cfgDir string) []string { if cfgDir == "" {