From ea3db9eb0888292464875b75ec913facbc67d119 Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Thu, 30 Jul 2026 20:19:32 +0200 Subject: [PATCH] remove httpgw and webui; update AGENTS.md to reflect current structure - Remove cmd/ollie-httpgw/ directory and webui/ submodule - Update justfile: remove httpgw/webui build targets - Update README.md: remove httpgw/webui references - Update doc/ARCHITECTURE.md: remove httpgw/webui references - Update doc/EVOLUTION.md: remove httpgw/webui references - Update doc/USAGE.md: remove httpgw/webui sections - Update AGENTS.md: fix repo layout, build targets, testing, architecture paths, key files, adding tools/prompts, submodule workflow --- .gitmodules | 3 - .plan.md | 11 + AGENTS.md | 167 +++------ README.md | 6 - cmd/ollie-httpgw/.gitignore | 1 - cmd/ollie-httpgw/README.md | 93 ----- cmd/ollie-httpgw/dbus.go | 662 ---------------------------------- cmd/ollie-httpgw/main.go | 134 ------- cmd/ollie-httpgw/openapi.json | 366 ------------------- doc/ARCHITECTURE.md | 122 ------- doc/EVOLUTION.md | 89 +---- doc/USAGE.md | 153 -------- justfile | 31 +- webui | 1 - 14 files changed, 80 insertions(+), 1759 deletions(-) create mode 100644 .plan.md delete mode 100644 cmd/ollie-httpgw/.gitignore delete mode 100644 cmd/ollie-httpgw/README.md delete mode 100644 cmd/ollie-httpgw/dbus.go delete mode 100644 cmd/ollie-httpgw/main.go delete mode 100644 cmd/ollie-httpgw/openapi.json delete mode 160000 webui diff --git a/.gitmodules b/.gitmodules index 19ba2d3..642955b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "webui"] - path = webui - url = ../ollie-webui.git [submodule "kde"] path = kde url = ../ollie-kde.git diff --git a/.plan.md b/.plan.md new file mode 100644 index 0000000..bf83777 --- /dev/null +++ b/.plan.md @@ -0,0 +1,11 @@ +# Plan: Remove httpgw + webui + +1. Remove cmd/ollie-httpgw/ directory ✓ (git rm) +2. Remove webui/ submodule ✓ (git rm) +3. Update justfile ✓ +4. Update README.md ✓ +5. Update doc/ARCHITECTURE.md ✓ +6. Update doc/EVOLUTION.md ✓ +7. Update doc/USAGE.md ✓ +8. Update AGENTS.md — repo layout, build system, testing, architecture, key files, submodule workflow ← IN PROGRESS +9. Commit + push diff --git a/AGENTS.md b/AGENTS.md index 8654bab..e0b56cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,27 +1,21 @@ # AGENTS.md - Project-level context for AI agents working in this repository. - ## Project Overview - Ollie is an AI agent runtime inspired by Plan 9: agent state and behaviors are exposed as files in a 9P namespace. Orchestration, scheduling, and UIs are external — shell scripts, editors, web apps. The core is minimal; capabilities come from composing scripts. - ## Repository Layout - Monorepo with Git submodules. Each submodule has its own Go module (except `el` which is Elisp and `kde` which is C++/Qt). - ``` ollie/ ← you are here -├── core/ (Go) Core library: agent loop, backends, tools, config -├── 9p/ (Go) 9P filesystem server (olliesrv) -├── dbus/ (Go) D-Bus session manager daemon (ollied) -├── acme/ (Go) Plan 9 acme editor frontend -├── httpgw/ (Go) HTTP-to-9P gateway -├── webui/ (TypeScript) Preact SPA browser frontend -├── kde/ (C++/Qt6) KDE integration: plasmoid, standalone GUI, Kate plugin, KRunner, tray +├── agent/ (Go) Agent loop, history, hooks, commands +├── toolsrv/ (Go) Tool server, registry, sandboxed execution +├── session/ (Go) Session lifecycle, config, persistence +├── fs/session/ (Go) 9P filesystem tree +├── dbus/ (Go) D-Bus adapter +├── cmd/ (Go) Binaries (olliesrv, ollie-9p, ollie-remote) +├── kde/ (C++/Qt6) KDE integration: plasmoid, GUI, Kate plugin, KRunner, tray ├── el/ (Elisp) Emacs frontend (ellie.el) ├── agents/ Agent config JSONs (loaded at runtime) -├── prompts/ System prompt templates (markdown) ← NOT used; see note below +├── prompts/ System prompt templates (markdown) ├── tools/ Tool scripts (file_read, lsp_*, memory_*, etc.) ├── sandbox/ Landlock sandbox config YAML ├── scripts/ User-facing CLI scripts: @@ -32,162 +26,119 @@ ollie/ ← you are here ├── contrib/ Community scripts └── experiments/ Trial notes ``` - ### Canonical source for prompts/tools/skills - -Three locations have prompt and tool files. The **source of truth** depends on which component you're editing: - +Prompt and tool files live in `contrib/`. The `just install-data` target copies them to `~/.config/ollie/`. **Do not edit `~/.config/ollie/` directly** — changes are overwritten on next install. | Location | Purpose | Deployed by | |----------|---------|-------------| -| `9p/prompts/`, `9p/tools/` | Canonical source for the 9P server | `just install-data` | -| `9p/skills/`, `kde/skills/` | Shared `agent-skills` submodule (same repo in both) | `just install-data` | -| `kde/prompts/`, `kde/tools/` | KDE-specific copies (may diverge for KDE-only tools like `gui_*`) | `just install-kde` | -| Root `prompts/`, `tools/` | Top-level copies installed by `just install-data` | `just install-data` | - -The `just install-data` target copies `agents/`, `prompts/`, `tools/`, `sandbox/` (and `skills/` from submodules) into `~/.config/ollie/`. **Do not edit `~/.config/ollie/` directly** — changes are overwritten on next install. - -When adding a new prompt/tool that should be available to all frontends, add it to both `9p/prompts/` (or `9p/tools/`) and the root `prompts/` (or `tools/`) directory. - +| `contrib/prompts/`, `contrib/tools/` | Canonical source for all prompts, tools, and skills | `just install-data` | +| `contrib/skills/` | Domain knowledge modules (markdown) | `just install-data` | +| `kde/` | KDE-specific tool scripts (`gui_*`) | `just install-kde` | ## Build System - ```bash -# From monorepo root (pick one): -just # 9P path (recommended): core, 9p, acme, kde, httpgw, webui, emacs -just dbus # D-Bus path (KDE-only): core, ollied, kde, httpgw, webui - -# Individual build targets: -just core # core library -just ninep # 9P server +# Build everything: +just +# Individual targets: +just ninep # olliesrv + ollie-9p just acme # acme frontend just kde # KDE integration (cmake with ~/.local prefix) -just httpgw # HTTP gateway -just webui # web UI (needs npm) -just dbus-bin # D-Bus daemon (ollied) - +just ollie-remote # remote execution binary # Install targets (run automatically by the top-level paths): just install-data # agents, prompts, tools, skills → ~/.config/ollie/ just install-scripts # CLI scripts → ~/.config/ollie/scripts/ just install-contrib # contrib scripts → ~/bin/ just install-kde # KDE plugins, desktop file, env → ~/.local/ just install-el # ellie.el → ~/.config/emacs/ellie/ -just install-dbus-bin # ollied + autostart → ~/.local/bin/, ~/.config/autostart/ - # Test: -just test # run all tests (core + 9p) +just test # run all tests just test-core # core tests only just test-9p # 9p tests only - # Lifecycle: just uninstall # remove all installed files just clean # remove build artifacts ``` - Requires [just](https://github.com/casey/just): `cargo install just` - ## Testing - ```bash -# Go tests (from any Go submodule): +# All Go tests: go test ./... - -# Core has the most tests: -cd core && go test ./... - -# 9P server tests: -cd 9p && go test ./... - # KDE has integration test scripts: cd kde && ./test-e2e.sh ``` - ## Language & Conventions -- **Go** (core, 9p, dbus, acme, httpgw): Go 1.25+, standard library preferred, minimal dependencies. Modules are `ollie` (core), `olliesrv` (9p), `ollie-dbus`, `ollie-acme`, `ollie-httpgw`. +- **Go** (root module): Go 1.25+, standard library preferred, minimal dependencies. - **C++20/Qt6/KF6** (kde): CMake build, dual Qt5/Qt6 support where noted. -- **TypeScript** (webui): Vite + Preact. - **Elisp** (el): single file `ellie.el`. - **Tool scripts**: Python 3 or Bash. Must be executable. Header comments declare metadata (`# ollie:parallel read`, `# ollie:tier cold`, `# description:`, `# args:`). - ### Code style - Go: `gofmt`, short variable names, error returns (no panics), table-driven tests. - Tool scripts: emit structured output (`STATUS=ok`, `STATUS=error`). Image/LSP tools return JSON content blocks. - Prompts: markdown, concise, example-driven. Follow the pattern in existing `tools-*.md` files. - ## Architecture (key concepts) - -1. **Two integration surfaces**: 9P filesystem (sessions at `s/{name}/`) and D-Bus (`org.ollie.SessionManager`). Tools, skills, memory on physical filesystem via env vars. -2. **Agent loop** (`core/pkg/agent/`): Streaming LLM call → parse tool calls → dispatch → loop until no more tool calls or max steps. -3. **Tool dispatch** (`core/pkg/tools/`): `shell` is built-in. All others are external scripts resolved from `OLLIE_TOOLS_PATH`. -4. **Sandbox** (`core/internal/sandbox/`): Landlock-based. Config in `sandbox/*.yaml` defines filesystem access per profile. -5. **Backends** (`core/pkg/backend/`): Ollama, OpenAI-compatible, Anthropic, Copilot, Kiro. Selectable per-session. +1. **Two integration surfaces**: 9P filesystem (sessions at `session/{id}/agent/{aid}/`) and D-Bus (`org.ollie.SessionManager`). Tools, skills, memory on physical filesystem via env vars. +2. **Agent loop** (`agent/loop.go`): Streaming LLM call → parse tool calls → dispatch → loop until no more tool calls or max steps. +3. **Tool dispatch** (`toolsrv/`): `shell` is built-in. All others are external scripts resolved from `OLLIE_TOOLS_PATH`. +4. **Sandbox** (`sandbox/`): Landlock-based. Config in `sandbox/*.yaml` defines filesystem access per profile. +5. **Backends** (`backend/`): Ollama, OpenAI-compatible, Anthropic, Copilot, Kiro, Gemini, CodeWhisperer. Selectable per-session. 6. **Prompts assembled at runtime**: Agent JSON `prompt` array runs shell commands (via `x/prime`) that cat markdown files together. No static prompt file is used directly. -7. **D-Bus** (kde/dbus only): `ollied` exposes `org.ollie.SessionManager` for KDE frontends. It's a separate daemon from `olliesrv`. - +7. **D-Bus** embedded in `olliesrv`: `org.ollie.SessionManager` exposed by the same binary as the 9P server. ## Key Files - | What | Where | |------|-------| -| Agent loop | `core/pkg/agent/loop.go` | -| Tool dispatcher | `core/pkg/tools/execute/` | -| Remote execution client | `core/pkg/remote/remote.go` | -| Remote execution server | `remote/main.go` | -| Sandbox enforcement | `core/internal/sandbox/` | -| 9P filesystem | `9p/fs/` | -| Session management | `9p/session/` | -| System prompt template | Embedded in binary (`system_prompt.md`) | +| Agent loop | `agent/loop.go` | +| Tool server | `toolsrv/server.go` | +| Remote execution | `toolsrv/remote.go`, `cmd/ollie-remote/` | +| Sandbox enforcement | `sandbox/` | +| 9P filesystem | `fs/session/` | +| Session management | `session/session.go` | +| System prompt template | Embedded in binary | | Agent configs | `agents/*.json` | -| D-Bus daemon | `dbus/main.go` | +| D-Bus adapter | `dbus/` | | KDE D-Bus client | `kde/plasmoid/plugin/olliedbusclient.cpp` | | Standalone GUI | `kde/gui/` | | Kate plugin | `kde/kate/` | - ## Environment - Config lives in `~/.config/ollie/env`. Key variables: - `OLLIE_BACKEND` — default backend (ollama, openai, anthropic, copilot, kiro) - `OLLIE_MODEL` — default model - `OLLIE_TOOLS_PATH` — where tool scripts live (default: `~/.config/ollie/tools`) - `OLLIE_MEMORY_PATH` — persistent memory directory - `OLLIE_ENABLED_BACKENDS` — backends available for routing - - ## Adding a new tool - -1. Create an executable script in `tools/` (and `9p/tools/`, `kde/tools/` if needed) +1. Create an executable script in `contrib/tools/` 2. Add header comments: `# description:`, `# args:`, optionally `# ollie:parallel read` or `# ollie:tier cold` -3. Create a matching `prompts/tools-*.md` documentation file (and in `9p/prompts/`, `kde/prompts/`) -4. Run `just install-data` to install - +3. Run `just install-data` to install ## Adding a new prompt - -1. Write the markdown file in `9p/prompts/` and root `prompts/` +1. Write the markdown file in `contrib/prompts/` 2. If it should be loaded by default, add a `$OLLIE_CFG_PATH/scripts/x/prime ` entry to `agents/default.json` 3. Run `just install-data` to install - ## Submodule workflow - +The only remaining submodule is `kde/`. For KDE: ```bash -# Update all submodules: -git submodule update --remote --merge - -# Work in a submodule (e.g., core): -cd core +# Update KDE submodule: +git submodule update --remote --merge kde +# Work in the KDE submodule: +cd kde # ... make changes, commit ... git push cd .. -git add core -git commit -m "update core submodule" +git add kde +git commit -m "update kde submodule" ``` - Each submodule has its own remote at `ssh://lkn@lneely.de:44220/lkn/ollie-{name}.git`. - -### Nested submodules - -Both `9p/` and `kde/` contain a nested `skills/` submodule pointing to the same repo (`agent-skills.git`). There is no root-level `skills/` directory. - +When cloning, use `--recurse-submodules` or run `git submodule update --init --recursive`. +## Environment +The only submodules are `kde/` and `webui/` (now removed). For KDE: +```bash +# Update KDE submodule: +git submodule update --remote --merge kde +# Work in the KDE submodule: +cd kde +# ... make changes, commit ... +git push +cd .. +git add kde +git commit -m "update kde submodule" ``` -9p/skills/ → ssh://lkn@lneely.de:44220/lkn/agent-skills.git -kde/skills/ → ssh://lkn@lneely.de:44220/lkn/agent-skills.git (same repo) -``` - -When cloning, use `--recurse-submodules` or run `git submodule update --init --recursive` to get these. +Each submodule has its own remote at `ssh://lkn@lneely.de:44220/lkn/ollie-{name}.git`. +When cloning, use `--recurse-submodules` or run `git submodule update --init --recursive`. diff --git a/README.md b/README.md index d3393f9..68c50cf 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,8 @@ Single Go module with two Git submodules for frontends. | `sandbox/` | Go | Landlock sandbox config | | `cmd/olliesrv/` | Go | The main binary | | `cmd/ollie-9p/` | Go | 9P client | -| `cmd/ollie-httpgw/` | Go | HTTP-to-9P gateway | | `cmd/ollie-remote/` | Go | Remote execution binary | | `kde/` | C++/Qt6 | KDE plasmoid, GUI, Kate plugin, KRunner, tray *(submodule)* | -| `webui/` | TypeScript | Preact SPA browser frontend *(submodule)* | | `agents/` | JSON | Agent configs | | `prompts/` | Markdown | System prompt templates | | `tools/` | Python/Bash | Tool scripts (file_read, lsp_*, memory_*, etc.) | @@ -88,7 +86,6 @@ graph TB ACME[Plan 9 acme] EMACS[Emacs
ellie.el] KDE[KDE
plasmoid / GUI / Kate
KRunner / tray] - WEB[Web UI
Preact SPA] end subgraph "olliesrv" @@ -115,11 +112,8 @@ graph TB RTOOL[Remote Tools] end - HTTPGW[ollie-httpgw
HTTP→9P gateway] - TER & ACME & EMACS -->|9P| P9 KDE -->|D-Bus| DBUS - WEB --> HTTPGW --> P9 P9 --> SM DBUS --> SM diff --git a/cmd/ollie-httpgw/.gitignore b/cmd/ollie-httpgw/.gitignore deleted file mode 100644 index 216421a..0000000 --- a/cmd/ollie-httpgw/.gitignore +++ /dev/null @@ -1 +0,0 @@ -ollie-httpgw diff --git a/cmd/ollie-httpgw/README.md b/cmd/ollie-httpgw/README.md deleted file mode 100644 index 473aced..0000000 --- a/cmd/ollie-httpgw/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# ollie-httpgw - -HTTP gateway for ollie. Translates REST requests to D-Bus calls against `org.ollie.SessionManager`, allowing any HTTP client to manage sessions without a D-Bus library. Also serves as the backend for the web UI. - -## Building - -```sh -mk -``` - -Installs `ollie-httpgw` to `$HOME/bin`. - -## Usage - -```sh -ollie-httpgw start # start daemon (backgrounds itself) -ollie-httpgw fgstart # start in foreground -ollie-httpgw stop # stop daemon -ollie-httpgw status # check if running -``` - -The gateway listens on `:8011` by default. Override with `-listen`: - -```sh -ollie-httpgw start -listen :9090 -``` - -Requires either `olliesrv` or `ollie-dbus` running (provides the D-Bus service). - -## API - -All responses are JSON. An OpenAPI spec is served at `GET /openapi.json`. - -### Sessions - -| Method | Path | Action | -|--------|------|--------| -| `GET` | `/sessions` | List all sessions | -| `POST` | `/sessions` | Create a session (body: `{cwd, backend, model, agent}`) | -| `GET` | `/sessions/{id}` | Get session info | -| `DELETE` | `/sessions/{id}` | Kill a session | -| `PATCH` | `/sessions/{id}` | Rename a session (body: `{name}`) | - -### Interaction - -| Method | Path | Action | -|--------|------|--------| -| `POST` | `/sessions/{id}/submit` | Submit a prompt (body: `{prompt}`) | -| `POST` | `/sessions/{id}/interrupt` | Interrupt the current turn | - -### State queries - -| Method | Path | Action | -|--------|------|--------| -| `GET` | `/sessions/{id}/state` | Get session state | -| `GET` | `/sessions/{id}/chat?offset=N` | Get chat text from offset | -| `GET` | `/sessions/{id}/usage` | Get token usage | -| `GET` | `/sessions/{id}/cost` | Get cost | -| `GET` | `/sessions/{id}/config` | Get config | -| `POST` | `/sessions/{id}/config` | Set config (body: `{key, value}`) | -| `GET` | `/sessions/{id}/context` | Get message history (JSONL) | -| `GET` | `/sessions/{id}/models` | List models for session's backend | - -### Peers - -| Method | Path | Action | -|--------|------|--------| -| `GET` | `/sessions/{id}/peers` | List peers | -| `POST` | `/sessions/{id}/peers` | Add peer (body: `{peer_id}`) | -| `DELETE` | `/sessions/{id}/peers/{peer_id}` | Remove peer | -| `POST` | `/sessions/{id}/peers/{peer_id}/submit` | Submit to peer (body: `{prompt}`) | - -### Enumeration - -| Method | Path | Action | -|--------|------|--------| -| `GET` | `/backends` | List available backends | -| `GET` | `/agents` | List available agents | - -## Example - -```sh -# Create a session -curl -X POST http://localhost:8011/sessions \ - -d '{"cwd": "/home/user/project", "agent": "default"}' - -# Submit a prompt -curl -X POST http://localhost:8011/sessions//submit \ - -d '{"prompt": "list files in cwd"}' - -# Poll chat -curl http://localhost:8011/sessions//chat?offset=0 -``` diff --git a/cmd/ollie-httpgw/dbus.go b/cmd/ollie-httpgw/dbus.go deleted file mode 100644 index 202c694..0000000 --- a/cmd/ollie-httpgw/dbus.go +++ /dev/null @@ -1,662 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "sort" - "strconv" - "strings" - - "github.com/godbus/dbus/v5" -) - -const ( - dbusService = "org.ollie.SessionManager" - dbusPath = "/org/ollie/SessionManager" - dbusIface = "org.ollie.SessionManager" -) - -func dbusConn() (*dbus.Conn, error) { - return dbus.ConnectSessionBus() -} - -func dbusCall(method string, args ...interface{}) (*dbus.Call, error) { - conn, err := dbusConn() - if err != nil { - return nil, fmt.Errorf("dbus connect: %w", err) - } - defer conn.Close() - - obj := conn.Object(dbusService, dbusPath) - call := obj.Call(dbusIface+"."+method, 0, args...) - if call.Err != nil { - return nil, call.Err - } - return call, nil -} - -// dbusHandler routes HTTP requests to D-Bus method calls. -// -// API: -// -// GET /sessions - ListSessions -// POST /sessions - CreateSession -// DELETE /sessions/{id} - KillSession -// PATCH /sessions/{id} - RenameSession -// -// POST /sessions/{id}/submit - Submit prompt -// POST /sessions/{id}/interrupt - Interrupt -// -// GET /sessions/{id}/state - GetState -// GET /sessions/{id}/chat - GetChat (query: offset) -// GET /sessions/{id}/usage - GetUsage -// GET /sessions/{id}/cost - GetCost -// GET /sessions/{id}/config - GetConfig -// POST /sessions/{id}/config - SetConfig -// GET /sessions/{id}/context - GetContext -// -// GET /backends - ListBackends -// GET /sessions/{id}/models - ListModels -// GET /agents - ListAgents -// -// GET /sessions/{id}/peers - PeerList -// POST /sessions/{id}/peers - PeerAdd -// DELETE /sessions/{id}/peers/{peer_id} - PeerRemove -// POST /sessions/{id}/peers/{peer_id}/submit - PeerSubmit -func dbusHandler(w http.ResponseWriter, r *http.Request) { - p := strings.TrimPrefix(r.URL.Path, "/") - p = strings.TrimSuffix(p, "/") - - parts := strings.Split(p, "/") - - switch { - // GET/POST /sessions - case len(parts) == 1 && parts[0] == "sessions": - switch r.Method { - case http.MethodGet: - dbusListSessions(w) - case http.MethodPost: - dbusCreateSession(w, r) - default: - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - } - - // /sessions/{id} - case len(parts) == 2 && parts[0] == "sessions": - sessionID := parts[1] - switch r.Method { - case http.MethodGet: - dbusGetSession(w, sessionID) - case http.MethodDelete: - dbusKillSession(w, sessionID) - case http.MethodPatch: - dbusRenameSession(w, r, sessionID) - default: - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) - } - - // /sessions/{id}/{action} - case len(parts) == 3 && parts[0] == "sessions": - sessionID := parts[1] - action := parts[2] - switch action { - case "submit": - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) - return - } - dbusSubmit(w, r, sessionID) - case "interrupt": - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) - return - } - dbusInterrupt(w, sessionID) - case "state": - if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) - return - } - dbusGetState(w, sessionID) - case "chat": - if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) - return - } - dbusGetChat(w, r, sessionID) - case "usage": - if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) - return - } - dbusGetUsage(w, sessionID) - case "cost": - if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) - return - } - dbusGetCost(w, sessionID) - case "config": - switch r.Method { - case http.MethodGet: - dbusGetConfig(w, sessionID) - case http.MethodPost: - dbusSetConfig(w, r, sessionID) - default: - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST"}) - } - case "context": - if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) - return - } - dbusGetContext(w, sessionID) - case "models": - if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) - return - } - dbusListModels(w, sessionID) - case "peers": - switch r.Method { - case http.MethodGet: - dbusPeerList(w, sessionID) - case http.MethodPost: - dbusPeerAdd(w, r, sessionID) - default: - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST"}) - } - default: - writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) - } - - // /sessions/{id}/peers/{peer_id} - case len(parts) == 4 && parts[0] == "sessions" && parts[2] == "peers": - sessionID := parts[1] - peerID := parts[3] - switch r.Method { - case http.MethodDelete: - dbusPeerRemove(w, sessionID, peerID) - default: - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "DELETE only"}) - } - - // /sessions/{id}/peers/{peer_id}/submit - case len(parts) == 5 && parts[0] == "sessions" && parts[2] == "peers" && parts[4] == "submit": - sessionID := parts[1] - peerID := parts[3] - if r.Method != http.MethodPost { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST only"}) - return - } - dbusPeerSubmit(w, r, sessionID, peerID) - - // GET /backends - case len(parts) == 1 && parts[0] == "backends": - if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) - return - } - dbusListBackends(w) - - // GET /agents - case len(parts) == 1 && parts[0] == "agents": - if r.Method != http.MethodGet { - writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"}) - return - } - dbusListAgents(w) - - default: - writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) - } -} - -// --- Sessions --- - -func dbusListSessions(w http.ResponseWriter) { - call, err := dbusCall("ListSessions") - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var sessions []string - if err := call.Store(&sessions); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - var result []map[string]string - for _, entry := range sessions { - parts := strings.SplitN(entry, "\t", 4) - if len(parts) >= 4 { - result = append(result, map[string]string{ - "id": parts[0], - "state": parts[1], - "model": parts[2], - "agent": parts[3], - }) - } - } - if result == nil { - result = []map[string]string{} - } - sort.Slice(result, func(i, j int) bool { - return result[i]["id"] < result[j]["id"] - }) - writeJSON(w, http.StatusOK, result) -} - -func dbusCreateSession(w http.ResponseWriter, r *http.Request) { - var req struct { - Name string `json:"name"` - CWD string `json:"cwd"` - Backend string `json:"backend"` - Model string `json:"model"` - Agent string `json:"agent"` - Remote string `json:"remote"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - - call, err := dbusCall("CreateSession", req.CWD, req.Backend, req.Model, req.Agent, "", req.Remote) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var sessionID string - if err := call.Store(&sessionID); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if sessionID == "" { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "session creation failed"}) - return - } - - // Rename if a name was provided - if req.Name != "" { - if call, err := dbusCall("RenameSession", sessionID, req.Name); err == nil { - var success bool - if call.Store(&success) == nil && success { - sessionID = req.Name - } - } - } - - writeJSON(w, http.StatusCreated, map[string]string{"id": sessionID}) -} - -func dbusGetSession(w http.ResponseWriter, sessionID string) { - // Return config as the session detail view - dbusGetConfig(w, sessionID) -} - -func dbusKillSession(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("KillSession", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var success bool - if err := call.Store(&success); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !success { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func dbusRenameSession(w http.ResponseWriter, r *http.Request, sessionID string) { - var req struct { - Name string `json:"name"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - call, err := dbusCall("RenameSession", sessionID, req.Name) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var success bool - if err := call.Store(&success); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !success { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "rename failed"}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// --- Interaction --- - -func dbusSubmit(w http.ResponseWriter, r *http.Request, sessionID string) { - var req struct { - Prompt string `json:"prompt"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - call, err := dbusCall("Submit", sessionID, req.Prompt) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var success bool - if err := call.Store(&success); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !success { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"submitted": true}) -} - -func dbusInterrupt(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("Interrupt", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var success bool - if err := call.Store(&success); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !success { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "session not found"}) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"interrupted": true}) -} - -// --- State queries --- - -func dbusGetState(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("GetState", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var state string - if err := call.Store(&state); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"state": state}) -} - -func dbusGetChat(w http.ResponseWriter, r *http.Request, sessionID string) { - offsetStr := r.URL.Query().Get("offset") - var offset int64 - if offsetStr != "" { - offset, _ = strconv.ParseInt(offsetStr, 10, 64) - } - - conn, err := dbusConn() - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - defer conn.Close() - - obj := conn.Object(dbusService, dbusPath) - call := obj.Call(dbusIface+".GetChat", 0, sessionID, offset) - if call.Err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": call.Err.Error()}) - return - } - - var text string - var newOffset int64 - if err := call.Store(&text, &newOffset); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("X-Chat-Offset", strconv.FormatInt(newOffset, 10)) - w.Write([]byte(text)) -} - -func dbusGetUsage(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("GetUsage", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var usage string - if err := call.Store(&usage); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"usage": usage}) -} - -func dbusGetCost(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("GetCost", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var cost string - if err := call.Store(&cost); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, map[string]string{"cost": cost}) -} - -// --- Config --- - -func dbusGetConfig(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("GetConfig", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var config string - if err := call.Store(&config); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - // Parse key=value\n into JSON object - m := make(map[string]string) - for _, line := range strings.Split(strings.TrimRight(config, "\n"), "\n") { - if line == "" { - continue - } - k, v, _ := strings.Cut(line, "=") - m[k] = v - } - writeJSON(w, http.StatusOK, m) -} - -func dbusSetConfig(w http.ResponseWriter, r *http.Request, sessionID string) { - var req struct { - Key string `json:"key"` - Value string `json:"value"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - call, err := dbusCall("SetConfig", sessionID, req.Key, req.Value) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var success bool - if err := call.Store(&success); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !success { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "set config failed"}) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -// --- Context --- - -func dbusGetContext(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("GetContext", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var context string - if err := call.Store(&context); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - // Already JSONL — return as-is with appropriate content type - w.Header().Set("Content-Type", "application/x-ndjson") - w.Write([]byte(context)) -} - -// --- Backends/Models/Agents --- - -func dbusListBackends(w http.ResponseWriter) { - call, err := dbusCall("ListBackends") - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var backends []string - if err := call.Store(&backends); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, backends) -} - -func dbusListModels(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("ListModels", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var models []string - if err := call.Store(&models); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, models) -} - -func dbusListAgents(w http.ResponseWriter) { - call, err := dbusCall("ListAgents") - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var agents []string - if err := call.Store(&agents); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - writeJSON(w, http.StatusOK, agents) -} - -// --- Peers --- - -func dbusPeerList(w http.ResponseWriter, sessionID string) { - call, err := dbusCall("PeerList", sessionID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var peers []string - if err := call.Store(&peers); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if peers == nil { - peers = []string{} - } - writeJSON(w, http.StatusOK, peers) -} - -func dbusPeerAdd(w http.ResponseWriter, r *http.Request, sessionID string) { - var req struct { - PeerID string `json:"peer_id"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - call, err := dbusCall("PeerAdd", sessionID, req.PeerID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var success bool - if err := call.Store(&success); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !success { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "peer add failed"}) - return - } - writeJSON(w, http.StatusCreated, map[string]bool{"added": true}) -} - -func dbusPeerRemove(w http.ResponseWriter, sessionID, peerID string) { - call, err := dbusCall("PeerRemove", sessionID, peerID) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var success bool - if err := call.Store(&success); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !success { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer not found"}) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func dbusPeerSubmit(w http.ResponseWriter, r *http.Request, sessionID, peerID string) { - var req struct { - Prompt string `json:"prompt"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - call, err := dbusCall("PeerSubmit", sessionID, peerID, req.Prompt) - if err != nil { - writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) - return - } - var success bool - if err := call.Store(&success); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) - return - } - if !success { - writeJSON(w, http.StatusNotFound, map[string]string{"error": "peer submit failed"}) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"submitted": true}) -} diff --git a/cmd/ollie-httpgw/main.go b/cmd/ollie-httpgw/main.go deleted file mode 100644 index edeecde..0000000 --- a/cmd/ollie-httpgw/main.go +++ /dev/null @@ -1,134 +0,0 @@ -// ollie-httpgw - HTTP gateway for ollie session management. -// -// Translates HTTP requests to D-Bus calls against org.ollie.SessionManager. -// -// Usage: -// -// ollie-httpgw [start|fgstart|stop|status] -package main - -import ( - _ "embed" - "encoding/json" - "flag" - "fmt" - "log" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "syscall" -) - -//go:embed openapi.json -var openapiSpec []byte - -var listen = flag.String("listen", ":8011", "HTTP listen address") - -const pidFile = "ollie-httpgw.pid" - -func main() { - args := os.Args[1:] - - if len(args) >= 1 { - subcmd := args[0] - switch subcmd { - case "start", "fgstart", "stop", "status": - flag.CommandLine.Parse(args[1:]) //nolint:errcheck - ns := os.Getenv("NAMESPACE") - if ns == "" { - ns = fmt.Sprintf("/tmp/ns.%s.:0", os.Getenv("USER")) - } - pidPath := filepath.Join(ns, pidFile) - switch subcmd { - case "start": - if gwRunning() { - fmt.Println("ollie-httpgw already running") - os.Exit(0) - } - daemonize() - case "fgstart": - if gwRunning() { - fmt.Println("ollie-httpgw already running") - os.Exit(0) - } - runGateway(pidPath) - case "stop": - stopGateway(pidPath) - case "status": - if gwRunning() { - fmt.Println("ollie-httpgw running") - } else { - fmt.Println("ollie-httpgw not running") - os.Exit(1) - } - } - return - } - } - - // No subcommand — run in foreground directly (no pid file). - flag.CommandLine.Parse(args) //nolint:errcheck - serve() -} - -func gwRunning() bool { - conn, err := net.Dial("tcp", *listen) - if err == nil { - conn.Close() - return true - } - return false -} - -func daemonize() { - exe, _ := os.Executable() - args := []string{"fgstart", "-listen", *listen} - cmd := exec.Command(exe, args...) - cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} - if err := cmd.Start(); err != nil { - fmt.Fprintf(os.Stderr, "failed to start: %v\n", err) - os.Exit(1) - } - fmt.Printf("ollie-httpgw started (pid %d)\n", cmd.Process.Pid) -} - -func stopGateway(pidPath string) { - data, err := os.ReadFile(pidPath) - if err != nil { - fmt.Println("ollie-httpgw not running") - return - } - var pid int - fmt.Sscanf(string(data), "%d", &pid) - if pid > 0 { - syscall.Kill(pid, syscall.SIGTERM) //nolint:errcheck - } - os.Remove(pidPath) //nolint:errcheck - fmt.Println("ollie-httpgw stopped") -} - -func runGateway(pidPath string) { - os.WriteFile(pidPath, fmt.Appendf(nil, "%d", os.Getpid()), 0644) //nolint:errcheck - serve() - os.Remove(pidPath) //nolint:errcheck -} - -func serve() { - http.HandleFunc("/openapi.json", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(openapiSpec) - }) - - http.HandleFunc("/", dbusHandler) - - log.Printf("ollie-httpgw listening on %s", *listen) - log.Fatal(http.ListenAndServe(*listen, nil)) -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(v) -} diff --git a/cmd/ollie-httpgw/openapi.json b/cmd/ollie-httpgw/openapi.json deleted file mode 100644 index ddbb34e..0000000 --- a/cmd/ollie-httpgw/openapi.json +++ /dev/null @@ -1,366 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "ollie-httpgw", - "description": "HTTP gateway for ollie session management. Supports D-Bus (default) and 9P backend modes.", - "version": "2.0.0" - }, - "servers": [ - { "url": "http://localhost:8011", "description": "Default local gateway" } - ], - "paths": { - "/sessions": { - "get": { - "tags": ["dbus"], - "summary": "List sessions", - "description": "Returns all active sessions with their id, state, model, and agent.", - "responses": { - "200": { - "description": "Session list", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { "$ref": "#/components/schemas/SessionSummary" } - } - } - } - }, - "502": { "$ref": "#/components/responses/Error" } - } - }, - "post": { - "tags": ["dbus"], - "summary": "Create a new session", - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/CreateSessionRequest" } - } - } - }, - "responses": { - "201": { - "description": "Session created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { "id": { "type": "string" } }, - "required": ["id"] - } - } - } - }, - "400": { "$ref": "#/components/responses/Error" }, - "502": { "$ref": "#/components/responses/Error" } - } - } - }, - "/sessions/{id}": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "get": { - "tags": ["dbus"], - "summary": "Get session config/detail", - "responses": { - "200": { - "description": "Session config as key-value object", - "content": { "application/json": { "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } - } - } - }, - "delete": { - "tags": ["dbus"], - "summary": "Kill session", - "responses": { - "204": { "description": "Session killed" }, - "404": { "$ref": "#/components/responses/Error" } - } - }, - "patch": { - "tags": ["dbus"], - "summary": "Rename session", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { "name": { "type": "string" } }, - "required": ["name"] - } - } - } - }, - "responses": { - "204": { "description": "Renamed" }, - "404": { "$ref": "#/components/responses/Error" } - } - } - }, - "/sessions/{id}/submit": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "post": { - "tags": ["dbus"], - "summary": "Submit a prompt to the session", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { "prompt": { "type": "string" } }, - "required": ["prompt"] - } - } - } - }, - "responses": { - "200": { "description": "Submitted", "content": { "application/json": { "schema": { "type": "object", "properties": { "submitted": { "type": "boolean" } } } } } }, - "404": { "$ref": "#/components/responses/Error" } - } - } - }, - "/sessions/{id}/interrupt": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "post": { - "tags": ["dbus"], - "summary": "Interrupt the running session", - "responses": { - "200": { "description": "Interrupted", "content": { "application/json": { "schema": { "type": "object", "properties": { "interrupted": { "type": "boolean" } } } } } }, - "404": { "$ref": "#/components/responses/Error" } - } - } - }, - "/sessions/{id}/state": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "get": { - "tags": ["dbus"], - "summary": "Get session state", - "responses": { - "200": { "description": "State", "content": { "application/json": { "schema": { "type": "object", "properties": { "state": { "type": "string" } } } } } } - } - } - }, - "/sessions/{id}/chat": { - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, - { "name": "offset", "in": "query", "required": false, "schema": { "type": "integer", "format": "int64", "default": 0 }, "description": "Byte offset to resume from" } - ], - "get": { - "tags": ["dbus"], - "summary": "Get chat transcript", - "description": "Returns chat text from the given offset. The X-Chat-Offset response header contains the new offset for subsequent requests.", - "responses": { - "200": { - "description": "Chat text", - "headers": { - "X-Chat-Offset": { "schema": { "type": "integer", "format": "int64" }, "description": "New byte offset for next poll" } - }, - "content": { "text/plain": { "schema": { "type": "string" } } } - } - } - } - }, - "/sessions/{id}/usage": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "get": { - "tags": ["dbus"], - "summary": "Get token usage", - "responses": { - "200": { "description": "Usage string", "content": { "application/json": { "schema": { "type": "object", "properties": { "usage": { "type": "string" } } } } } } - } - } - }, - "/sessions/{id}/cost": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "get": { - "tags": ["dbus"], - "summary": "Get session cost", - "responses": { - "200": { "description": "Cost string", "content": { "application/json": { "schema": { "type": "object", "properties": { "cost": { "type": "string" } } } } } } - } - } - }, - "/sessions/{id}/config": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "get": { - "tags": ["dbus"], - "summary": "Get session config", - "responses": { - "200": { "description": "Config as key-value", "content": { "application/json": { "schema": { "type": "object", "additionalProperties": { "type": "string" } } } } } - } - }, - "post": { - "tags": ["dbus"], - "summary": "Set a config value", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "key": { "type": "string" }, - "value": { "type": "string" } - }, - "required": ["key", "value"] - } - } - } - }, - "responses": { - "200": { "description": "Success", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } }, - "400": { "$ref": "#/components/responses/Error" } - } - } - }, - "/sessions/{id}/context": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "get": { - "tags": ["dbus"], - "summary": "Get message context (JSONL)", - "responses": { - "200": { "description": "JSONL messages", "content": { "application/x-ndjson": { "schema": { "type": "string" } } } } - } - } - }, - "/sessions/{id}/models": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "get": { - "tags": ["dbus"], - "summary": "List models for session's backend", - "responses": { - "200": { "description": "Model list", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } } - } - } - }, - "/sessions/{id}/peers": { - "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], - "get": { - "tags": ["dbus"], - "summary": "List peers", - "responses": { - "200": { "description": "Peer IDs", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } } - } - }, - "post": { - "tags": ["dbus"], - "summary": "Add a peer", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { "peer_id": { "type": "string" } }, - "required": ["peer_id"] - } - } - } - }, - "responses": { - "201": { "description": "Peer added" }, - "400": { "$ref": "#/components/responses/Error" } - } - } - }, - "/sessions/{id}/peers/{peer_id}": { - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, - { "name": "peer_id", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "delete": { - "tags": ["dbus"], - "summary": "Remove a peer", - "responses": { - "204": { "description": "Peer removed" }, - "404": { "$ref": "#/components/responses/Error" } - } - } - }, - "/sessions/{id}/peers/{peer_id}/submit": { - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, - { "name": "peer_id", "in": "path", "required": true, "schema": { "type": "string" } } - ], - "post": { - "tags": ["dbus"], - "summary": "Submit prompt to a peer session", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { "prompt": { "type": "string" } }, - "required": ["prompt"] - } - } - } - }, - "responses": { - "200": { "description": "Submitted" }, - "404": { "$ref": "#/components/responses/Error" } - } - } - }, - "/backends": { - "get": { - "tags": ["dbus"], - "summary": "List available backends", - "responses": { - "200": { "description": "Backend names", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } } - } - } - }, - "/agents": { - "get": { - "tags": ["dbus"], - "summary": "List available agents", - "responses": { - "200": { "description": "Agent names", "content": { "application/json": { "schema": { "type": "array", "items": { "type": "string" } } } } } - } - } - } - }, - "components": { - "schemas": { - "SessionSummary": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "state": { "type": "string" }, - "model": { "type": "string" }, - "agent": { "type": "string" } - }, - "required": ["id", "state", "model", "agent"] - }, - "CreateSessionRequest": { - "type": "object", - "properties": { - "cwd": { "type": "string", "description": "Working directory (defaults to $HOME)" }, - "backend": { "type": "string", "description": "Backend name (e.g. ollama, anthropic)" }, - "model": { "type": "string", "description": "Model name" }, - "agent": { "type": "string", "description": "Agent name (default: 'default')" } - } - }, - "Error": { - "type": "object", - "properties": { "error": { "type": "string" } }, - "required": ["error"] - } - }, - "responses": { - "Error": { - "description": "Error", - "content": { - "application/json": { - "schema": { "$ref": "#/components/schemas/Error" } - } - } - } - } - } -} diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index d065cd8..6839aea 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -1,23 +1,15 @@ # ollie Architecture - ## Philosophy - ollie's design philosophy is Emacs: a small, extensible core. The Go runtime (`agent.Agent`) defines what an agent *is* — an event loop, a backend connection, a message history, and three built-in primitives (`shell`, tool registry, skill registry). Everything else — orchestration, scheduling, workflows, UIs — is pushed out to the surrounding environment. - The primary integration philosophy is Plan 9's "everything is a file." `olliesrv` exposes agent state and behaviors as files in a 9P namespace. Any program that can read and write files can drive an agent: shell scripts, editors, web apps, cron, containers. - For desktop-native integration, `olliesrv` embeds a D-Bus adapter (`org.ollie.SessionManager`) alongside its 9P interface. The KDE GUI, plasmoid, and web UI connect this way. For D-Bus-only deployments, `-no9p` disables the 9P listener entirely. - Design principles: - **Small extensible core.** The agent runtime is minimal; capabilities come from composing external scripts. - **Three built-in primitives.** `shell`, tool registry (`tool_load`/`tool_list`/`tool_active`), and skill registry (`skill_load`/`skill_list`/`skill_active`) are the only tools compiled into the core. Everything else is a script. - **Two integration paths.** 9P filesystem (canonical) and/or D-Bus (conventional). Same core, different surfaces, selectable via flags. - **No framework lock-in.** Frontends are decoupled via whichever interface they prefer; the core doesn't know or care. - ## Repository Structure - Single Go module (`ollie`) with two Git submodules for decoupled frontends: - ``` ollie/ ├── agent/ Agent loop, history, hooks, prompt resolution, commands @@ -36,10 +28,8 @@ ollie/ ├── cmd/ Binaries: │ ├── olliesrv/ 9P server │ ├── ollie-9p/ 9P client -│ ├── ollie-httpgw/ HTTP-to-9P gateway │ └── ollie-remote/ Remote execution server ├── kde/ KDE integration (submodule) — plasmoid, GUI, Kate plugin, KRunner, tray -├── webui/ Preact SPA browser frontend (submodule) ├── agents/ Agent config JSONs (default, coding, orchestrator, worker, ...) ├── prompts/ Prompt templates (markdown) ├── tools/ Tool scripts (file_read, lsp_*, memory_*, subagent_*, ...) @@ -48,9 +38,7 @@ ollie/ ├── sandbox/ Sandbox config YAML files └── doc/ Documentation ``` - ## System Overview - ```mermaid flowchart TB subgraph Frontends["Frontends"] @@ -58,27 +46,22 @@ flowchart TB ACME["acme (Plan 9)"] ELLIE["ellie (Emacs)"] KDE["KDE GUI / Plasmoid"] - WEB["Web UI"] end - subgraph Integration["Integration Layer"] direction LR P9["9P Filesystem"] DBUS["org.ollie.SessionManager (D-Bus)"] end - subgraph Server["olliesrv"] direction TB NS["9P Namespace\nsession/ · backends · models"] DBA["Embedded D-Bus Adapter"] end - subgraph Core["Agent Engine (per session)"] LOOP["Agent Loop\n(agent/loop.go)"] SESS["Session State\n(agent/history.go)"] TRV["toolsrv.Server\n· shell execution\n· tool registry\n· landlock sandbox"] end - subgraph Backends["LLM Backends"] OLLAMA["Ollama"] OPENAI["OpenAI / OpenRouter"] @@ -86,33 +69,24 @@ flowchart TB GEMINI["Gemini"] CW["CodeWhisperer"] end - TUI --> P9 ACME --> P9 ELLIE --> P9 KDE --> DBUS WEB --> DBUS - P9 --> NS DBUS --> DBA - NS --> LOOP DBA --> LOOP - LOOP <--> SESS LOOP --> TRV LOOP <--> Backends - TRV --> Shell["sandboxed shell\n(landrun)"] TRV --> Tools["tool scripts\n(OLLIE_TOOLS_PATH)"] ``` - ## Core Library - The core is a single Go module (`ollie`) with no binary. Binaries live in `cmd/`. - ### Package Layout - | Package | Purpose | |---|---| | `agent/` | Agent struct, loop, history, compaction, hooks, commands, prompt resolution, state | @@ -125,15 +99,12 @@ The core is a single Go module (`ollie`) with no binary. Binaries live in `cmd/` | `env/` | Session environment helpers | | `log/` | Structured logging | | `paths/` | XDG path resolution | - ### Key Types - **`agent.Agent`** — the concrete struct frontends drive: ```go type Agent struct { // unexported fields — no public interface } - func New(cfg AgentConfig) *Agent func (a *Agent) Submit(ctx context.Context, input string) func (a *Agent) Interrupt(cause error) bool @@ -143,7 +114,6 @@ func (a *Agent) WaitChange(ctx context.Context, field, current string) (string, func (a *Agent) Close() // ... model/backend info, CWD, usage, cost, etc. ``` - **`backend.Backend`** — LLM provider contract: ```go type Backend interface { @@ -155,7 +125,6 @@ type Backend interface { Models(ctx context.Context) []string } ``` - **`toolsrv.Runner`** — minimal tool execution interface: ```go type Runner interface { @@ -163,21 +132,16 @@ type Runner interface { CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) } ``` - **`toolsrv.Server`** — the concrete tool server (implements Runner): ```go type Server struct { /* ... */ } ``` - ### Agent Loop (`agent/loop.go`) - The loop is the heart of the system. On each turn: - 1. **Stream** — call `backend.ChatStream()` with messages + tool definitions 2. **Dispatch** — if the model returns tool calls, execute them via `toolsrv.Server` 3. **Update** — append assistant message + tool results to session history 4. **Repeat** — loop until the model produces a final text response (no tool calls) - Safety mechanisms: - **Consecutive error limits**: soft nudge at 5, hard abort at 10 - **Replan gate**: after 8 rounds without a PLAN: block, inject a replan nudge @@ -185,11 +149,8 @@ Safety mechanisms: - **Max steps**: configurable per-agent; soft exit when reached - **Transient retry**: up to 3 retries with exponential backoff for rate limits and 5xx - **Context overflow**: triggers automatic compaction and retry - ### Session & Context Management (`agent/history.go`) - `History` owns the message history as a flat `[]backend.Message` slice. Key behaviors: - - **TaskState**: structured JSON overlay (objective, plan_step, constraints, last_action, next_decision) injected at the top of every turn - **Compaction**: three-zone strategy when context reaches 50% of model limit: - **Cold zone**: structured task state summary @@ -197,11 +158,8 @@ Safety mechanisms: - **Hot zone**: last 8 messages verbatim - **Result tiers**: tool results classified as Hot (verbatim), Warm (summarized on compaction), or Cold (immediately collapsed) - **Persistence**: sessions saved as JSON to `{sessionsDir}/{id}.json` - ### Hooks (`agent/hooks.go`) - Lifecycle callbacks executed as shell commands with JSON payload on stdin: - | Hook | When | Exit codes | |---|---|---| | `agentSpawn` | Session creation | 0=ok, 2=block | @@ -212,17 +170,11 @@ Lifecycle callbacks executed as shell commands with JSON payload on stdin: | `preCompact` | Before compaction | 0=ok | | `postCompact` | After compaction | 0=ok | | `turnError` | On backend error | 0=handled (skip retries) | - ### Prompt Resolution (`agent/prompt_resolver.go`) - Agent configs declare prompts as a JSON array of shell commands. Each command is executed with `$OLLIE` and `$PWD` available; stdout is concatenated to form the system prompt. This enables dynamic, composable prompts assembled from template fragments. - ## 9P Server (`cmd/olliesrv/`) - `olliesrv` is the central daemon. It implements a 9P2000 file server that exposes the entire agent namespace: - ### Namespace Layout - ``` session/ ├── new write key=value to create session @@ -261,24 +213,18 @@ session/ │ ├── tail exec helper │ └── proc/ detached background processes ``` - ### 9P Interaction Model - All agent interaction uses the `ollie-9p` client, which auto-discovers the server via `$NAMESPACE` and identifies the caller via `$OLLIE_UNAME`. The 9P filesystem exposes session state, control, and inter-agent communication as files: - Sessions are directories under `session/` - Prompts, state, chat history, and config are readable/writable files - Permission enforcement uses 9P user principals - ### Permission Model (`fs/session/perm.go`) - All permissions are declared in a single registry (`Perms`). Key design: - `prompt` is mode `0666` — group+other can write, owner (the agent) cannot - `chat` is mode `0444` — read-only for everyone - `ctl` is mode `0666` — anyone can send control commands - `plan` is mode `0666` — readable by peer agents - ### Session Management (`fs/session/`) - The `*fs.Tree` IS the session collection. Package functions manage lifecycle: - `NewRoot(cfg)` — create the root tree - `Lookup(tree, id)` — find a session by ID @@ -287,25 +233,16 @@ The `*fs.Tree` IS the session collection. Package functions manage lifecycle: - `KillFromRoot(tree, id)` — kill a session - `RenameFromRoot(tree, old, new)` — rename a session - `Shutdown(tree)` — clean shutdown - ### Embedded D-Bus Adapter - `olliesrv` includes an embedded D-Bus adapter that claims `org.ollie.SessionManager` on the session bus. This provides: - The same method/signal interface used by KDE frontends - `SessionCreated`, `SessionKilled`, `StateChanged`, `ChatUpdated` signals -- Allows KDE GUI, plasmoid, and web UI (via `httpgw`) to interact with sessions without speaking 9P - Lifecycle watchers that bridge session state changes to D-Bus signals - The adapter is best-effort: if no session bus is available (e.g., headless/container), `olliesrv` continues functioning as a pure 9P server. - ## D-Bus Adapter (`dbus/`) - The D-Bus adapter is embedded in `olliesrv` (no separate daemon). For D-Bus-only deployments, run `olliesrv -no9p`. This disables the filesystem listener while keeping the full D-Bus interface. Session persistence uses the same `~/.local/share/ollie/sessions/active/` directory. - ## KDE Integration (`kde/`) - The KDE submodule provides several Qt/QML components that connect to `org.ollie.SessionManager` over D-Bus: - | Component | Path | Purpose | |---|---|---| | GUI | `kde/gui/` | Standalone Qt window for chat interaction | @@ -313,27 +250,18 @@ The KDE submodule provides several Qt/QML components that connect to `org.ollie. | Kate plugin | `kde/kate/` | In-editor chat pane and ghost completion | | KRunner | `kde/krunner/` | Quick-launch sessions from KRunner | | System tray | `kde/tray/` | Tray icon with session status | - All KDE components use `QDBusServiceWatcher` to handle daemon restarts gracefully — recreating the `QDBusInterface` when the service reappears and refreshing the session list. - ## Tool System - ### Architecture - The tool system has exactly one built-in server (`toolsrv.Server`) that exposes three primitives: - | Primitive | Purpose | |---|---| | `shell` | Run a bash command in a sandbox | | `tool_load`/`tool_list`/`tool_active` | Dynamic tool registry | | `skill_load`/`skill_list`/`skill_active` | Dynamic skill registry | - All other capabilities are **tool scripts** — executable files discovered from `OLLIE_TOOLS_PATH` (default: `~/.config/ollie/tools/`). - ### Tool Scripts - Tool scripts are plain executables with a structured header comment declaring their name, description, parameters, and parallelism annotation: - - `file_read`, `file_write`, `file_edit`, `file_glob`, `file_grep` — filesystem I/O - `lsp_definition`, `lsp_references`, `lsp_rename`, `lsp_symbols`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion` — LSP bridge (Python) - `memory_remember`, `memory_recall` — persistent memory @@ -341,13 +269,9 @@ Tool scripts are plain executables with a structured header comment declaring th - `subagent_spawn`, `subagent_generate` — sub-agent lifecycle - `route` — orchestrator dispatch - `gui_*` — KDE desktop automation (screenshot, windows, clipboard, input, ...) - Scripts annotated `ollie:parallel read` can be fanned out concurrently by the agent loop. - ### Sandboxing - Every code execution step is wrapped with [landrun](https://github.com/landlock-lsm/landrun) (Landlock LSM). Configuration is YAML-based: - ```yaml general: best_effort: true @@ -359,13 +283,9 @@ network: enabled: true unrestricted: true ``` - Template variables (`{CWD}`, `{HOME}`, `{XDG_*}`, `{OLLIE_*}`) are expanded at runtime. **landrun is mandatory** — there is no unsandboxed fallback. Steps marked `elevated: true` bypass the sandbox via the elevation backend (`x/elevate`). - ## LLM Backends (`backend/`) - All backends implement the streaming-only `Backend` interface: - | Backend | Wire Format | Notes | |---|---|---| | `OllamaBackend` | `/api/chat` | Local models, context length from `/api/show` | @@ -374,63 +294,43 @@ All backends implement the streaming-only `Backend` interface: | `GeminiBackend` | Gemini API | Google Gemini models | | `CopilotBackend` | GitHub Copilot API | Copilot Chat | | `CodeWhispererBackend` | CodeWhisperer API | AWS CodeWhisperer | - Shared infrastructure: - `streamRequest()` — common HTTP streaming with error classification - `RateLimitError`, `TransientError`, `ContextOverflowError`, `ToolUnsupportedError` — typed errors for loop control - `GenerationParams` — unified sampling parameters across all backends - ## Multi-Agent System - Multi-agent coordination is built entirely on the filesystem primitive: - ### Mechanism - 1. **Spawn**: write session spec to `session/new` → new session directory appears 2. **Prompt**: write task to `session/{id}/prompt` 3. **Observe**: read `session/{id}/state` or block on `session/{id}/statewait` 4. **Result**: child writes to `session/{parent_id}/prompt` when done - ### Delegation Tools - | Tool | Semantics | |---|---| | `route` | Orchestrator dispatch; selects backend+model for a task | | `subagent_spawn` | Raw session creation with full control | | `subagent_generate` | JIT agent identity generation | - ### Callback Protocol - ``` [from={session_id}] STATUS: done|error SUMMARY: ARTIFACTS: ``` - The parent never actively waits — results arrive as queued prompts. - -## HTTP Gateway (`cmd/ollie-httpgw/`) - -`ollie-httpgw` bridges HTTP clients (like the web UI) to the 9P filesystem. It translates REST-style requests into 9P filesystem operations and streams events back as Server-Sent Events. - This is the secondary integration path for non-LLM consumers (web UIs, dashboards, external tools) that want a conventional HTTP API without speaking 9P directly. - ## Frontends - Frontends connect via either 9P (filesystem) or D-Bus, depending on preference: - ```mermaid flowchart LR subgraph Core["agent.Agent"] AG["Agent Engine"] end - subgraph Surfaces["Integration Surfaces"] P9["9P Filesystem\n(session/ namespace)"] DB["D-Bus\n(org.ollie.SessionManager)"] end - subgraph Frontends SH["s/sh (terminal)"] ACME["acme (Plan 9)"] @@ -439,41 +339,30 @@ flowchart LR KP["KDE Plasmoid"] KK["Kate Plugin"] KR["KRunner"] - WEB["Web UI"] HTTP["curl / scripts"] end - AG --> P9 AG --> DB - SH --> P9 ACME --> P9 EL --> P9 HTTP --> P9 - KG --> DB KP --> DB KK --> DB KR --> DB WEB --> DB ``` - Frontends are fully decoupled — the core doesn't know which interface they use. - ## Script Namespaces - User-facing scripts installed to `~/.config/ollie/scripts/{ns}/`: - | Namespace | Purpose | Key Scripts | |---|---|---| | `s/` | Session management | `sh` (interactive), `b` (one-shot), `bfg` (blocking), `bbg` (background) | | `u/` | Utility compositions | `complete` (code completion), `optimize` (prompt optimization), `cascade`, `escalate` | | `x/` | System plugins | `elevate` (privilege escalation), `prime` (prompt template renderer), `freeloader` | - ## Agent Configuration - Agent configs are JSON files in `~/.config/ollie/agents/`: - ```json { "prompt": [ @@ -492,11 +381,8 @@ Agent configs are JSON files in `~/.config/ollie/agents/`: "maxSteps": 50 } ``` - The `prompt` array is the key innovation: each element is a shell command whose stdout is concatenated to form the system prompt. This makes prompts composable, dynamic, and environment-aware. - ## Data Flow - ```mermaid sequenceDiagram participant F as Frontend @@ -505,17 +391,14 @@ sequenceDiagram participant B as LLM Backend participant T as toolsrv.Server participant SH as sandbox (landrun) - F->>S: write to session/{id}/prompt S->>A: Submit() activate A - loop Agent Loop A->>B: ChatStream(messages, tools) activate B B-->>A: stream events deactivate B - alt has tool calls A->>T: Dispatch(tool, args) activate T @@ -529,14 +412,11 @@ sequenceDiagram break end end - A-->>S: turn complete deactivate A S-->>F: readable via session/{id}/chat ``` - ## Extension Points - | What | How | |---|---| | Add an LLM backend | Implement `backend.Backend` | @@ -546,9 +426,7 @@ sequenceDiagram | Add a skill | Drop a markdown file into `skills/` | | Add an agent persona | Create a JSON config in `agents/` | | Customize sandbox | Create a YAML in `~/.config/ollie/sandbox/` | - ## Key Design Decisions - 1. **Two integration surfaces** — 9P filesystem (canonical, Unix philosophy) and D-Bus (conventional, for GUI/web consumers). Both are thin adapters over the same core. 2. **Three built-in primitives** — `shell`, tool registry, skill registry. Everything else is a script. This keeps the core small and makes all other tools equal citizens. 3. **Mandatory sandboxing** — no unsandboxed fallback. Security is not optional. diff --git a/doc/EVOLUTION.md b/doc/EVOLUTION.md index f3da745..561f656 100644 --- a/doc/EVOLUTION.md +++ b/doc/EVOLUTION.md @@ -1,45 +1,34 @@ # Architectural Evolution - How the Ollie system-of-systems emerged and evolved. ~1300 commits over ~3.5 months (Apr 11 – Jul 30, 2026). - ## Timeline - ```mermaid gantt title Ollie Evolution dateFormat YYYY-MM-DD axisFormat %b %d - section Foundation Monorepo + submodules :done, 2026-04-11, 14d 9P filesystem :done, 2026-04-14, 28d - section Core Primitives Session lifecycle (session/) :done, 2026-04-20, 30d Tool scripts :done, 2026-05-01, 20d Skills system :done, 2026-05-10, 14d - section Execution execute_code / shell :done, 2026-05-08, 20d Batch jobs (b/ → session/bfg) :done, 2026-05-15, 25d Sandbox (landrun) :done, 2026-05-20, 40d - section Multi-Agent Subagent spawn :done, 2026-05-25, 15d Cascade orchestrator :done, 2026-06-05, 20d JIT agent generation :done, 2026-07-10, 10d - section Frontends TUI + Emacs (ellie) :done, 2026-04-15, 45d Acme (Plan 9) :done, 2026-05-20, 30d - Web UI + httpgw :done, 2026-06-01, 25d KDE/Plasma :done, 2026-06-10, 40d - section Control Planes D-Bus adapter (ollied) :done, 2026-06-25, 20d D-Bus embedded in srv :done, 2026-07-15, 10d - section Late Evolution Remote execution :done, 2026-07-05, 15d Elevation broker :done, 2026-07-10, 15d @@ -48,52 +37,35 @@ gantt FUSE → 9P client :done, 2026-07-25, 5d The Great Flattening :done, 2026-07-29, 2d ``` - ## Phase 1: Monorepo Bootstrap (Apr 11) - Started as independent git repos unified under a monorepo with submodules. Initial components: - - **agent** — agent loop (Go): backend dispatch, tool execution, session state - **fs/session** — 9P file server exposing agent sessions as a synthetic filesystem - **tui** — terminal UI (later removed) - **el** — Emacs integration (ellie.el) - Build system was `mkfile` (Plan 9 make), later `Makefile`, finally `justfile`. - ## Phase 2: The 9P Decision (Apr 14–28) - The defining architectural choice: **every agent primitive is a file**. - Sessions are directories. Sending a prompt is writing to `session/{id}/prompt`. Reading state is `cat session/{id}/state`. This made the control plane protocol-agnostic — any program that can read/write files can be a frontend. - Key files crystallized: `prompt`, `chat`, `state`, `ctl`, `cfg`, `statewait`. - The `session/new` file (write key=value pairs, read back session ID) became the session factory. - ## Phase 3: Tool System (Apr 28 – May 10) - Tools evolved through several incarnations: - 1. **MCP servers** (denote-mcp, 9beads-mcp) — external processes, heavyweight 2. **Shell/Python scripts** in `~/.config/ollie/tools/` — lightweight, sandboxed 3. **execute_code** as the single built-in tool — all scripts invoked through it 4. **call_tool/pipe** — named tool dispatch and cross-tool pipelines 5. **Tool registry** (final form) — dynamic lazy-loading via `tool_list`/`tool_load`/`tool_active`; `execute_code` renamed to `shell`; tools hot-reloadable without server restart - The file tools (`file_read`, `file_write`, `file_edit`, `file_grep`, `file_glob`) were extracted into standalone Python scripts. Memory (`memory_remember`, `memory_recall`) and reasoning (`reasoning_think`) followed the same pattern. - MCP servers were removed early. Simple scripts won over complex daemons. - ## Phase 4: Planning System Churn (May – Jul) - Planning went through the most iterations of any subsystem: - 1. `pl/` directory namespace with flat file-per-task 2. `plan_create` / `plan_complete` tools 3. **Beads** integration (external issue tracker) — made optional, then dropped @@ -101,18 +73,13 @@ Planning went through the most iterations of any subsystem: 5. Inline markdown plans forbidden 6. **Final form**: a single `session/{id}/plan` file (markdown checklist), written by the agent, persisted across context compaction - The lesson: planning needed to be simple, agent-controlled, and not bureaucratic. - ## Phase 5: Multi-Agent Coordination (May 25 – Jun 15) - Three patterns emerged: - - **subagent_spawn** — fire-and-forget session creation, parent never blocks - **subagent_generate** — JIT agent config generation (role, constraints, tools) - **cascade** — script-driven fan-out with `-max-workers`, `-retries`, optional synthesis step - ```mermaid flowchart TB UP["User prompt"] @@ -123,7 +90,6 @@ flowchart TB CASCADE["u/cascade\n(spawn + throttle)"] CW1["Cascade Worker"] CW2["Cascade Worker"] - UP --> PARENT PARENT -- subagent_spawn --> W1 PARENT -- subagent_spawn --> W2 @@ -135,23 +101,18 @@ flowchart TB CASCADE --> CW1 CASCADE --> CW2 ``` - Inter-agent communication uses the filesystem: agents write to each other's `prompt` file. - ## Phase 6: Frontend Proliferation - ```mermaid flowchart TB subgraph Core["agent.Agent"] AG["Agent Engine"] end - subgraph Surfaces["Integration Surfaces"] P9["9P Filesystem\n(session/ namespace)"] DB["D-Bus\n(org.ollie.SessionManager)"] end - subgraph Frontends SH["s/sh (terminal)"] ACME["acme (Plan 9)"] @@ -160,35 +121,26 @@ flowchart TB KP["KDE Plasmoid"] KK["Kate Plugin"] KR["KRunner"] - WEB["Web UI"] HTTP["curl / scripts"] end - AG --> P9 AG --> DB - SH --> P9 ACME --> P9 EL --> P9 HTTP --> P9 - KG --> DB KP --> DB KK --> DB KR --> DB WEB --> DB ``` - - **s/sh** — shell script frontend (bash, briefly rc, back to bash) - **acme** — Plan 9 editor integration with `Oi` (quick query), `Kmpl` (completion) - **ellie.el** — Emacs with ghost-text completion -- **httpgw** — HTTP→9P gateway -- **webui** — browser frontend via httpgw - **ollie-kde** — full Plasma integration: plasmoid, KRunner, Kate plugin, standalone GUI, GUI automation tools - **TUI** — removed (Jul) in favor of s/sh and richer GUIs - ## Phase 7: Security Model (May – Jul) - ```mermaid flowchart LR subgraph Permissions["9P Permissions"] @@ -196,7 +148,6 @@ flowchart LR AGENT["Agent → restricted"] PEERS["Peers → read-only"] end - subgraph Execution["Execution Sandbox"] AGT["Agent"] SANDBOX["landrun sandbox\n(restricted fs)"] @@ -204,59 +155,42 @@ flowchart LR ELEV["x/elevate\n(socket broker)"] PRIV["Privileged Action"] end - AGT --> SANDBOX --> TOOLS TOOLS -- needs escape --> ELEV --> PRIV ``` - Evolution: - 1. No sandboxing initially 2. YAML-based sandbox configs (landrun) for execute_code 3. Per-session 9P identity — agents can't read other sessions' tools 4. Elevation broker: socket-based, user-confirmed privilege escalation 5. SSH agent proxy added then removed (too much attack surface) 6. Final: integrated elevation broker replaces separate superpowerd adapter - ## Phase 8: D-Bus as Second Control Plane (Jun 25 – Jul) - Originally everything was 9P-only. D-Bus was added for desktop integration: - 1. Separate `ollie-dbus` daemon (ollied) 2. Embedded directly into `olliesrv` (9P server got `-no9p` flag) 3. KDE uses D-Bus exclusively; acme/sh/el use 9P 4. **9P and D-Bus do not share sessions** — independent stores, same core - ## Phase 9: Remote Execution (Jul 5–19) - Split-brain architecture: orchestration stays local, code execution on a remote machine via SSH. - - `ollie-remote` binary auto-deployed via SSH bootstrap - Tools embedded in remote binary - Streaming output notifications back to local session - Session persistence across remote restarts - ## Phase 10: Tool Registry (Jul 20–29) - The final major architectural shift — from "agent knows all tools upfront" to lazy discovery: - 1. Tools embed their own `ollie:prompt` metadata blocks 2. `tool_list` — discover available tools and descriptions 3. `tool_load` — promote a tool to a native callable 4. `tool_active` — introspect loaded tools 5. Skills get the same treatment: `skill_list`, `skill_load`, `skill_active` - This solved system prompt bloat — only load what's needed per task. - ## Phase 11: The Great Flattening (Jul 29–30) - Largest single-day structural change: **−4,573 lines net** across the codebase. Eliminated accumulated abstractions that no longer served a purpose. - ### Core (−4,400 lines) - - **Killed `agent.Core` interface** — concrete `*agent.Agent` used directly. Nobody else implemented Core; the interface just added indirection. - **Killed `Dispatcher` indirection** in toolsrv — `toolsrv.Server` called directly. @@ -268,9 +202,7 @@ Eliminated accumulated abstractions that no longer served a purpose. - **Threaded context.Context** properly through daemon → session → agent (replaced ad-hoc interrupt mechanisms with cancellation). - Dead code removal: `GlobalToolNames`, `ExtractReturnSchema`. - ### 9P server (−214 lines) - - **Killed `mgr/` package entirely** — replaced `Manager` struct with package functions in `fs/session/`. The `*fs.Tree` IS the session collection; `rootState` (unexported) lives in `tree.Data`. CRUD via `NewRoot`, `Lookup`, `Create`, @@ -283,17 +215,13 @@ Eliminated accumulated abstractions that no longer served a purpose. files, FUSE kernel saw 0 bytes and never issued a read. - **Fixed `session.All()`** — was looking at `root.Children()` (empty) instead of `rootState.sessions`. Broke D-Bus ListSessions and GUI session restore. - ### Frontends - - **acme**: paths updated `s/` → `session/`, agent files route through `session/{id}/agent/{aid}/`. - **ellie.el**: same path migration, added `ellie--agent-dir` for agent ID discovery. - **KDE GUI**: no changes needed (D-Bus operates on Go objects, not paths). - ### New filesystem layout - ``` session/ ├── new write key=value to create session @@ -325,14 +253,11 @@ session/ │ ├── tail exec helper │ └── proc/ detached process output ``` - ### Architecture after (single Go module, no submodules for core/9p) - ``` *fs.Tree (root) ← IS the session collection └── .Data = *rootState ← sessions map, config, nextUID └── sessions[id] = *Session ← owns Agent, context, cancel - Package functions (no Manager): session.NewRoot(cfg) → *fs.Tree session.Lookup(tree, id) → *Session @@ -342,21 +267,15 @@ Package functions (no Manager): session.RenameFromRoot(tree, old, new) session.Shutdown(tree) ``` - ### SLOC after flattening - | Component | Lines | |-----------|-------| | agent + backend + toolsrv + session | 10,437 | | fs/session + cmd/olliesrv | 7,925 | | kde gui | 12,331 | -| el + acme + httpgw | 1,845 | | **Total** | **32,538** | - Zero dead exported functions remain (verified via LSP + grep across all repos). - ## Current Topology - ``` ollie/ ← single Go module ├── agent/ ← agent loop, history, hooks, commands @@ -372,9 +291,8 @@ ollie/ ← single Go module ├── log/ ← structured logging ├── paths/ ← XDG path resolution ├── mount/ ← 9P FUSE mount client -├── cmd/ ← binaries (olliesrv, ollie-9p, ollie-httpgw, ollie-remote) +├── cmd/ ← binaries (olliesrv, ollie-9p, ollie-remote) ├── kde/ ← KDE Plasma (submodule) -├── webui/ ← browser frontend (submodule) ├── tools/ ← 37 tool scripts (Python) ├── scripts/{s,u,x}/ ← filesystem-hosted scripts ├── agents/ ← agent configs (JSON) @@ -383,12 +301,9 @@ ollie/ ← single Go module ├── sandbox/ ← landrun sandbox profiles (YAML) └── doc/ ← documentation ``` - ## Principles That Emerged - 1. **Filesystem-as-API** — everything is read/write on synthetic files. No custom protocols needed. -2. **Submodule independence** — KDE and webui have their own repos, tests, build. Monorepo coordinates versions. 3. **Scripts over servers** — MCP servers removed early. Simple scripts won. 4. **Progressive disclosure** — lazy tool/skill loading keeps system prompts @@ -399,9 +314,7 @@ ollie/ ← single Go module Same core, different surfaces. 7. **Security by default** — sandboxed execution with explicit elevation, per-session identity. - ## Dead Ends and Reversals - | What | Why it was removed | |------|-------------------| | MCP servers | Too heavyweight; scripts are simpler and faster | diff --git a/doc/USAGE.md b/doc/USAGE.md index b6cead5..de8435c 100644 --- a/doc/USAGE.md +++ b/doc/USAGE.md @@ -495,159 +495,6 @@ OLLIE_TOOLS_PATH=~/mnt/toolserver/t OLLIE_TRANSCRIPT_PATH=~/mnt/archive/tr ``` -## HTTP gateway - -`ollie-httpgw` is an HTTP gateway that translates REST-style HTTP calls into 9P -filesystem operations. It lets any HTTP client — including the web UI, `curl`, or -custom scripts — interact with `olliesrv` without a 9P client library. - -### Build - -```sh -just httpgw -``` - -Produces `~/bin/ollie-httpgw`. - -### Start and stop - -Like `olliesrv`, the gateway supports daemon management: - -```sh -ollie-httpgw start # start in background (daemonizes) -ollie-httpgw fgstart # start in foreground -ollie-httpgw stop # stop the daemon -ollie-httpgw status # check if running -``` - -### Run (legacy) - -For backwards compatibility, running without a subcommand starts in foreground: - -```sh -ollie-httpgw # connect to local olliesrv via namespace; listen on :8080 -ollie-httpgw -listen :9090 # different port -ollie-httpgw -net tcp -addr host:9564 # connect to remote olliesrv over TCP -``` - -**Flags** - -| Flag | Default | Description | -|---|---|---| -| `-listen` | `:8080` | HTTP listen address | -| `-service` | `ollie` | 9P service name in the local namespace | -| `-net` | `` | Network type (`tcp`, `unix`); empty = use namespace | -| `-addr` | `` | 9P server address; required when `-net` is set | - -The gateway must be able to reach `olliesrv`. For a local server started with -`olliesrv`, the defaults work as-is. For a remote server started with -`olliesrv -tcp :9564`, use `-net tcp -addr remotehost:9564`. - -### HTTP → 9P mapping - -Each HTTP method maps to a 9P operation: - -| Method | 9P operation | Example | -|---|---|---| -| `GET` | read file / list dir | `GET /s/idx` | -| `POST` | write to file | `POST /session/new`, `POST /session/{id}/prompt` | -| `DELETE` | remove | `DELETE /session/{id}` | -| `PATCH` | rename (JSON `{"name":"..."}`) | `PATCH /session/{id}` | -| `HEAD` | stat (metadata in `X-9p-*` headers) | `HEAD /session/{id}` | - -The full API is described in `httpgw/openapi.json` and served live at -`GET /openapi.json`. - -### Key endpoints - -```sh -GET /s/idx # list sessions (JSON array) -GET /session/{id}/cfg # session config (key-value JSON) -GET /session/{id}/chat # conversation transcript (plain text) -POST /session/new # create session (JSON: {cwd, backend, model, agent}) -POST /session/{id}/prompt # submit prompt (JSON: {prompt: "..."}) -POST /session/{id}/ctl # send control command (JSON: {ctl: "stop"}) -DEL /session/{id} # kill and remove session -GET /backends # list available backends -GET /a/ # agent configs -GET /session/{id}/t/ # tool scripts -GET /m/ # memory files -GET /sk/ # skills -``` - -```sh -# Examples with curl: -curl http://localhost:8080/s/idx -curl -X POST http://localhost:8080/session/new \ - -d '{"cwd":"/home/user/project","backend":"anthropic"}' -curl -X POST http://localhost:8080/s/mysession/prompt \ - -d '{"prompt":"summarize the recent commits"}' -``` - -## Web UI - -The web UI is a Preact single-page application that talks to `ollie-httpgw`. It -provides session management, a chat interface, and a basic 9P filesystem browser -in the browser. - -### Build - -```sh -just webui -``` - -Requires Node.js and npm. Output lands in `webui/dist/`. - -### Serve - -The `dist/` folder is a static site. Serve it with any HTTP server and proxy the -API paths to `ollie-httpgw`: - -**nginx example:** - -```nginx -server { - listen 3000; - root /path/to/ollie/webui/dist; - index index.html; - - location / { try_files $uri /index.html; } - - location ~ ^/(s|a|m|t|sk|backends|openapi\.json)(/|$) { - proxy_pass http://localhost:8080; - } -} -``` - -**Python one-liner (local testing only):** - -```sh -cd webui/dist && python3 -m http.server 3000 -# API calls will fail — use the Vite dev server instead for local testing -``` - -### Development - -Vite's dev server proxies API paths to httpgw automatically: - -```sh -# Terminal 1: gateway -ollie-httpgw -listen :8080 - -# Terminal 2: dev server (hot-reload, proxied API) -cd webui && npm run dev -# Open http://localhost:5173 -``` - -The proxy configuration is in `webui/vite.config.ts` and targets `localhost:8080` -by default. - -### Features - -- Session list in a sidebar with state, model, backend, and cwd -- Create, rename, and kill sessions -- Send prompts; streaming via 2-second poll -- Stop a running agent - Markdown rendering of assistant responses - 9P filesystem browser (navigate `s/`, `a/`, `m/`, `sk/`) - Two built-in themes: `midnight` (dark) and `acme` (Plan 9-inspired light); choice diff --git a/justfile b/justfile index eb63d06..78b3fbc 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,7 @@ # Ollie monorepo build system # # Two variants: -# just Default (KF6): core, 9p, acme, kde, httpgw, webui, emacs +# just Default (KF6): core, 9p, acme, kde, emacs # just kf5 KF5/Qt5 variant (Plasma 5.27) # # Build and install are separate phases. No sudo. @@ -20,9 +20,9 @@ kf5: build-kf5 install-kf5 # === Build targets === -build: core ninep acme httpgw webui kde +build: core ninep acme kde -build-kf5: core ninep acme httpgw webui kde-kf5 +build-kf5: core ninep acme kde-kf5 core: @echo === Building ollie-core === @@ -40,14 +40,6 @@ acme: install -m755 cmd/Ollie/scripts/Oi {{bin}}/Oi install -m755 cmd/Ollie/scripts/Theo {{bin}}/Theo -httpgw: - @echo === Building ollie-httpgw === - go build -o {{bin}}/ollie-httpgw ./cmd/ollie-httpgw/ - -webui: - @echo === Building ollie-webui === - just -f webui/justfile webui - ollie-remote: rm -rf cmd/ollie-remote/tools && cp -a contrib/tools cmd/ollie-remote/tools @echo === Building ollie-remote === @@ -70,7 +62,7 @@ install: test install-data install-scripts install-contrib install-kde install-e install-kf5: test install-data install-scripts install-contrib install-kde-kf5 install-el install -m644 contrib/prompts/session-9p.md {{cfg}}/prompts/session.md - @echo === Install complete \(KF5\) === + @echo === Install complete (KF5) === @echo 'Restart Plasma to pick up KDE plugins.' install-kde: @@ -193,8 +185,6 @@ install-scripts: install -m755 contrib/scripts/u/optimize {{cfg}}/scripts/u/optimize install -m755 contrib/scripts/u/complete {{cfg}}/scripts/u/complete install -m755 contrib/scripts/u/cascade {{cfg}}/scripts/u/cascade - - install -m755 contrib/scripts/x/bd {{cfg}}/scripts/x/bd install -m755 contrib/scripts/x/prime {{cfg}}/scripts/x/prime install -m755 contrib/scripts/x/freeloader {{cfg}}/scripts/x/freeloader @@ -219,7 +209,6 @@ install-contrib: uninstall: @echo === Uninstalling ollie === - rm -f {{bin}}/ollie-httpgw rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi {{bin}}/Theo rm -f {{bin}}/ollie-remount {{bin}}/ollie-watchdog rm -rf {{cfg}} @@ -235,8 +224,7 @@ uninstall: @echo === Uninstall complete === uninstall-kf5: - @echo === Uninstalling ollie \(KF5\) === - rm -f {{bin}}/ollie-httpgw + @echo === Uninstalling ollie (KF5) === rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi {{bin}}/Theo rm -f {{bin}}/ollie-remount {{bin}}/ollie-watchdog rm -rf {{cfg}} @@ -249,7 +237,7 @@ uninstall-kf5: rm -rf ~/.config/emacs/ellie # Plasma env rm -f ~/.config/plasma-workspace/env/99-ollie.sh - @echo === Uninstall complete \(KF5\) === + @echo === Uninstall complete (KF5) === # === Test targets === @@ -271,7 +259,6 @@ test-remote: clean: go clean ./agent/... ./backend/... 2>/dev/null || true - rm -f {{bin}}/ollie-httpgw rm -f {{bin}}/Ollie {{bin}}/Kmpl {{bin}}/Oi {{bin}}/Theo rm -f {{bin}}/ollie-remote just -f kde/justfile clean @@ -282,8 +269,8 @@ help: @echo 'Ollie monorepo build targets:' @echo ' just Build + install (default, KF6)' @echo ' just kf5 Build + install (KF5/Qt5, Plasma 5.27)' - @echo ' just build Build only (KF6): core, 9p, acme, kde, httpgw, webui' - @echo ' just build-kf5 Build only (KF5): core, 9p, acme, kde-kf5, httpgw, webui' + @echo ' just build Build only (KF6): core, 9p, acme, kde' + @echo ' just build-kf5 Build only (KF5): core, 9p, acme, kde-kf5' @echo ' just install Install (KF6)' @echo ' just install-kf5 Install (KF5)' @echo ' just test Run all tests (core + 9p)' @@ -293,4 +280,4 @@ help: @echo ' just uninstall-kf5 Remove all installed files (KF5)' @echo ' just clean Clean build artifacts' @echo '' - @echo 'Individual: just core | ninep | acme | httpgw | webui | kde | kde-kf5 | ollie-remote' + @echo 'Individual: just core | ninep | acme | kde | kde-kf5 | ollie-remote' diff --git a/webui b/webui deleted file mode 160000 index c760012..0000000 --- a/webui +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c760012f731704ff7a1a6caad7f5b24e6a76161b