9p: add agents dir, backends, help, plan rm; refactor readSlice

This commit is contained in:
Levi Neely 2026-04-13 14:45:17 +02:00
parent fc5443e314
commit 0a4ca8a869
2 changed files with 151 additions and 63 deletions

View File

@ -8,25 +8,31 @@ The goal is integration, not self-sufficiency. Rather than providing orchestrati
``` ```
ollie/ ollie/
a/ dir: agent configs (r/w, backed by ~/.config/ollie/agents/)
<name>.json r/w: agent config; supports mv, cp, rm
backends read: list of ollie-provided backends
help read: help file (backed by ~/.config/ollie/help.md)
p/ dir: prompt templates (r/w, backed by ~/.config/ollie/prompts/) p/ dir: prompt templates (r/w, backed by ~/.config/ollie/prompts/)
SYSTEM_PROMPT.md r/w: the system prompt template SYSTEM_PROMPT.md r/w: the system prompt template
pl/ dir: plans pl/ dir: plans
s/ dir: one entry per active session, sorted by creation time s/ dir: one entry per active session, sorted by creation time
new r/w: read: KV template; write: create session new r/w: read: KV template; write: create session
<session-id>/ rm -r to kill session; mv to rename <session-id>/ rm -r to kill session; mv to rename
prompt write: submit a prompt to the agent agent r/w: active agent name
enqueue write: queue a prompt for later execution backend r/w: active backend name
dequeue read: pop the next queued prompt
chat read: cumulative conversation history chat read: cumulative conversation history
ctl write: stop | <command>
ctxsz read: estimated context size vs context window
dequeue read: pop the next queued prompt
enqueue write: queue a prompt for later execution
mcp read: MCP server list
model r/w: active model name
models read: available models from the backend
prompt write: submit a prompt to the agent
reply read: assistant text from the most recent turn only reply read: assistant text from the most recent turn only
state read: current agent state (idle, thinking, calling: <tool>) state read: current agent state (idle, thinking, calling: <tool>)
ctl write: stop | <command>
backend r/w: active backend name
agent r/w: active agent name
model r/w: active model name
workdir r/w: working directory for tool execution and system prompt
usage read: token counts (input, output, requests; [estimated] if not reported by backend) usage read: token counts (input, output, requests; [estimated] if not reported by backend)
ctxsz read: estimated context size vs context window workdir r/w: working directory for tool execution and system prompt
sk/ dir: skills (r/w, from OLLIE_SKILLS_PATH or ~/.config/ollie/skills/) sk/ dir: skills (r/w, from OLLIE_SKILLS_PATH or ~/.config/ollie/skills/)
<name>.md r/w: skill SKILL.md content <name>.md r/w: skill SKILL.md content
t/ dir: tool scripts (r/w, backed by ~/.config/ollie/tools/) t/ dir: tool scripts (r/w, backed by ~/.config/ollie/tools/)
@ -129,6 +135,30 @@ mv ~/mnt/ollie/s/<session-id> ~/mnt/ollie/s/my-friendly-name
Rename is rejected if the agent is running or the target name already exists. All open file handles into the session are updated automatically. Rename is rejected if the agent is running or the target name already exists. All open file handles into the session are updated automatically.
## Agents
Agent configs live in `a/` and are backed by `~/.config/ollie/agents/`. They're plain JSON files.
```sh
ls ~/mnt/ollie/a/ # list agents
cat ~/mnt/ollie/a/default.json # read an agent config
cp ~/mnt/ollie/a/default.json ~/mnt/ollie/a/yolo.json # copy an agent
mv ~/mnt/ollie/a/old.json ~/mnt/ollie/a/new.json # rename
rm ~/mnt/ollie/a/scratch.json # delete
```
## Backends
```sh
cat ~/mnt/ollie/backends # list available backends
```
## Help
```sh
cat ~/mnt/ollie/help # show help (from ~/.config/ollie/help.md)
```
## Example shell session ## Example shell session
```sh ```sh

View File

@ -3,6 +3,9 @@
// Filesystem layout: // Filesystem layout:
// //
// ollie/ // ollie/
// a/ (dir) agent configs (r/w, backed by ~/.config/ollie/agents/)
// backends (read) list of ollie-provided backends
// help (read) help file (backed by ~/.config/ollie/help.md)
// p/ (dir) prompt templates // p/ (dir) prompt templates
// pl/ (dir) plans // pl/ (dir) plans
// s/ (dir) session directory // s/ (dir) session directory
@ -13,8 +16,16 @@
// enqueue (write) queue a prompt for later execution // enqueue (write) queue a prompt for later execution
// dequeue (read) pop the next queued prompt // dequeue (read) pop the next queued prompt
// chat (read) cumulative chat history // chat (read) cumulative chat history
// backend (r/w) read/write the active backend name // reply (read) assistant text from the most recent turn
// agent (r/w) read/write the active agent name // state (read) current agent state
// backend (r/w) active backend name
// agent (r/w) active agent name
// model (r/w) active model name
// models (read) available models from the backend
// mcp (read) MCP server list
// workdir (r/w) working directory
// usage (read) token counts
// ctxsz (read) context size vs window
// sk/ (dir) skills // sk/ (dir) skills
// t/ (dir) tool scripts // t/ (dir) tool scripts
package p9 package p9
@ -210,6 +221,8 @@ func (s *Server) pathType(path string) string {
return "dir" return "dir"
case len(parts) == 2 && parts[0] == "s" && parts[1] == "new": case len(parts) == 2 && parts[0] == "s" && parts[1] == "new":
return "file" return "file"
case len(parts) == 1 && parts[0] == "a":
return "dir"
case len(parts) == 1 && parts[0] == "p": case len(parts) == 1 && parts[0] == "p":
return "dir" return "dir"
case len(parts) == 1 && parts[0] == "pl": case len(parts) == 1 && parts[0] == "pl":
@ -218,6 +231,14 @@ func (s *Server) pathType(path string) string {
return "dir" return "dir"
case len(parts) == 1 && parts[0] == "t": case len(parts) == 1 && parts[0] == "t":
return "dir" return "dir"
case len(parts) == 1 && parts[0] == "backends":
return "file"
case len(parts) == 1 && parts[0] == "help":
return "file"
case len(parts) == 2 && parts[0] == "a":
if _, err := os.Stat(s.agentsDir + "/" + parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "p": case len(parts) == 2 && parts[0] == "p":
if _, err := os.Stat(s.promptsDir + "/" + parts[1]); err == nil { if _, err := os.Stat(s.promptsDir + "/" + parts[1]); err == nil {
return "file" return "file"
@ -365,7 +386,7 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if !ok { if !ok {
return errFcall(fc, "bad fid") return errFcall(fc, "bad fid")
} }
if f.path != "/p" && f.path != "/pl" && f.path != "/sk" && f.path != "/t" { if f.path != "/a" && f.path != "/p" && f.path != "/pl" && f.path != "/sk" && f.path != "/t" {
return errFcall(fc, "create not supported") return errFcall(fc, "create not supported")
} }
if fc.Perm&plan9.DMDIR != 0 { if fc.Perm&plan9.DMDIR != 0 {
@ -377,6 +398,11 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
// Actually create the file on disk so that even a no-write create // Actually create the file on disk so that even a no-write create
// (e.g. touch) produces a real file. // (e.g. touch) produces a real file.
switch f.path { switch f.path {
case "/a":
os.MkdirAll(s.agentsDir, 0755) //nolint:errcheck
if err := os.WriteFile(s.agentsDir+"/"+fc.Name, nil, 0644); err != nil {
return errFcall(fc, err.Error())
}
case "/p": case "/p":
os.MkdirAll(s.promptsDir, 0755) //nolint:errcheck os.MkdirAll(s.promptsDir, 0755) //nolint:errcheck
if err := os.WriteFile(s.promptsDir+"/"+fc.Name, nil, 0644); err != nil { if err := os.WriteFile(s.promptsDir+"/"+fc.Name, nil, 0644); err != nil {
@ -440,16 +466,31 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
// s/new returns the session creation template. // s/new returns the session creation template.
if path == "/s/new" { if path == "/s/new" {
content := []byte("workdir=\nbackend=\nmodel=\nagent=\n") content := []byte("workdir=\nbackend=\nmodel=\nagent=\n")
var data []byte return s.readSlice(fc, content)
off := int(fc.Offset) }
if off < len(content) {
end := off + int(fc.Count) // backends is a static list of ollie-provided backends.
if end > len(content) { if path == "/backends" {
end = len(content) content := []byte(strings.Join(backend.Backends(), "\n") + "\n")
} return s.readSlice(fc, content)
data = content[off:end] }
// help is served from ~/.config/ollie/help.md.
if path == "/help" {
content, err := os.ReadFile(s.helpPath())
if err != nil {
return errFcall(fc, err.Error())
} }
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data} return s.readSlice(fc, content)
}
// Agent config files are served from disk.
if strings.HasPrefix(path, "/a/") {
content, err := os.ReadFile(s.agentsDir + "/" + pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
return s.readSlice(fc, content)
} }
// Prompt files are served from disk. // Prompt files are served from disk.
@ -458,16 +499,7 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if err != nil { if err != nil {
return errFcall(fc, err.Error()) return errFcall(fc, err.Error())
} }
var data []byte return s.readSlice(fc, content)
off := int(fc.Offset)
if off < len(content) {
end := off + int(fc.Count)
if end > len(content) {
end = len(content)
}
data = content[off:end]
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
} }
// Plan files are served from disk. // Plan files are served from disk.
@ -476,16 +508,7 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if err != nil { if err != nil {
return errFcall(fc, err.Error()) return errFcall(fc, err.Error())
} }
var data []byte return s.readSlice(fc, content)
off := int(fc.Offset)
if off < len(content) {
end := off + int(fc.Count)
if end > len(content) {
end = len(content)
}
data = content[off:end]
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
} }
// Skill files are served via pkg/skills (reads from disk each time). // Skill files are served via pkg/skills (reads from disk each time).
@ -495,16 +518,7 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if err != nil { if err != nil {
return errFcall(fc, err.Error()) return errFcall(fc, err.Error())
} }
var data []byte return s.readSlice(fc, content)
off := int(fc.Offset)
if off < len(content) {
end := off + int(fc.Count)
if end > len(content) {
end = len(content)
}
data = content[off:end]
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
} }
// Tool files are served from the tools path. // Tool files are served from the tools path.
@ -513,16 +527,7 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
if err != nil { if err != nil {
return errFcall(fc, err.Error()) return errFcall(fc, err.Error())
} }
var data []byte return s.readSlice(fc, content)
off := int(fc.Offset)
if off < len(content) {
end := off + int(fc.Count)
if end > len(content) {
end = len(content)
}
data = content[off:end]
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
} }
content := s.readFile(path) content := s.readFile(path)
@ -625,6 +630,25 @@ func (s *Server) readReply(fc *plan9.Fcall, path string) *plan9.Fcall {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data} return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
} }
// readSlice serves a byte slice at the requested offset/count.
func (s *Server) readSlice(fc *plan9.Fcall, content []byte) *plan9.Fcall {
var data []byte
off := int(fc.Offset)
if off < len(content) {
end := off + int(fc.Count)
if end > len(content) {
end = len(content)
}
data = content[off:end]
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
}
func (s *Server) helpPath() string {
home, _ := os.UserHomeDir()
return home + "/.config/ollie/help.md"
}
// readFile returns the text content for a readable session file. // readFile returns the text content for a readable session file.
func (s *Server) readFile(path string) string { func (s *Server) readFile(path string) string {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
@ -720,6 +744,17 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
} }
switch { switch {
case strings.HasPrefix(f.path, "/a/"):
oldPath := s.agentsDir + "/" + oldName
newPath := s.agentsDir + "/" + newDir.Name
if err := os.Rename(oldPath, newPath); err != nil {
return errFcall(fc, err.Error())
}
cs.mu.Lock()
f.path = "/a/" + newDir.Name
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
case strings.HasPrefix(f.path, "/pl/"): case strings.HasPrefix(f.path, "/pl/"):
oldPath := s.planDir + "/" + oldName oldPath := s.planDir + "/" + oldName
newPath := s.planDir + "/" + newDir.Name newPath := s.planDir + "/" + newDir.Name
@ -845,8 +880,12 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
path := f.path path := f.path
var err error var err error
switch { switch {
case strings.HasPrefix(path, "/a/"):
err = os.Remove(s.agentsDir + "/" + pathBase(path))
case strings.HasPrefix(path, "/p/"): case strings.HasPrefix(path, "/p/"):
err = os.Remove(s.promptsDir + "/" + pathBase(path)) err = os.Remove(s.promptsDir + "/" + pathBase(path))
case strings.HasPrefix(path, "/pl/"):
err = os.Remove(s.planDir + "/" + pathBase(path))
case strings.HasPrefix(path, "/sk/"): case strings.HasPrefix(path, "/sk/"):
name := strings.TrimSuffix(pathBase(path), ".md") name := strings.TrimSuffix(pathBase(path), ".md")
for _, m := range skills.List() { for _, m := range skills.List() {
@ -885,6 +924,11 @@ func (s *Server) handleWrite(path, input string) error {
return os.WriteFile(s.promptsDir+"/"+pathBase(path), []byte(input), 0644) return os.WriteFile(s.promptsDir+"/"+pathBase(path), []byte(input), 0644)
} }
// Agent config writes go directly to disk.
if strings.HasPrefix(path, "/a/") {
return os.WriteFile(s.agentsDir+"/"+pathBase(path), []byte(input), 0644)
}
// Plan file writes go directly to disk. // Plan file writes go directly to disk.
if strings.HasPrefix(path, "/pl/") { if strings.HasPrefix(path, "/pl/") {
os.MkdirAll(s.planDir, 0755) //nolint:errcheck os.MkdirAll(s.planDir, 0755) //nolint:errcheck
@ -1183,11 +1227,21 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
} }
if path == "/" { if path == "/" {
dirs = append(dirs, makeDir("a", "/a", true, plan9.DMDIR|0755))
dirs = append(dirs, makeDir("backends", "/backends", false, 0444))
dirs = append(dirs, makeDir("help", "/help", false, 0444))
dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0755)) dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0755))
dirs = append(dirs, makeDir("pl", "/pl", true, plan9.DMDIR|0755)) dirs = append(dirs, makeDir("pl", "/pl", true, plan9.DMDIR|0755))
dirs = append(dirs, makeDir("s", "/s", true, plan9.DMDIR|0555)) dirs = append(dirs, makeDir("s", "/s", true, plan9.DMDIR|0555))
dirs = append(dirs, makeDir("sk", "/sk", true, plan9.DMDIR|0555)) dirs = append(dirs, makeDir("sk", "/sk", true, plan9.DMDIR|0555))
dirs = append(dirs, makeDir("t", "/t", true, plan9.DMDIR|0777)) dirs = append(dirs, makeDir("t", "/t", true, plan9.DMDIR|0777))
} else if path == "/a" {
entries, _ := os.ReadDir(s.agentsDir)
for _, e := range entries {
if !e.IsDir() {
dirs = append(dirs, makeDir(e.Name(), "/a/"+e.Name(), false, 0666))
}
}
} else if path == "/p" { } else if path == "/p" {
entries, _ := os.ReadDir(s.promptsDir) entries, _ := os.ReadDir(s.promptsDir)
for _, e := range entries { for _, e := range entries {
@ -1304,7 +1358,7 @@ func (s *Server) makeStat(path string) plan9.Dir {
qid.Type = QTDir qid.Type = QTDir
if path == "/t" { if path == "/t" {
mode = plan9.DMDIR | 0777 mode = plan9.DMDIR | 0777
} else if path == "/p" || path == "/pl" { } else if path == "/a" || path == "/p" || path == "/pl" {
mode = plan9.DMDIR | 0755 mode = plan9.DMDIR | 0755
} else { } else {
mode = plan9.DMDIR | 0555 mode = plan9.DMDIR | 0555
@ -1318,7 +1372,11 @@ func (s *Server) makeStat(path string) plan9.Dir {
case "backend", "agent", "model", "workdir": case "backend", "agent", "model", "workdir":
mode = 0666 mode = 0666
default: default:
if path == "/s/new" { if path == "/backends" || path == "/help" {
mode = 0444
} else if path == "/s/new" {
mode = 0666
} else if strings.HasPrefix(path, "/a/") {
mode = 0666 mode = 0666
} else if strings.HasPrefix(path, "/p/") { } else if strings.HasPrefix(path, "/p/") {
mode = 0666 mode = 0666