9p: introduce pluggable store interface for all filesystem endpoints

Add ReadableStore, WritableStore, ReadWriteStore, BlobStore, and Store
interfaces with FlatDirStore as the default filesystem-backed implementation.
Each 9P directory endpoint is now backed by a named store:

  /a/       agentStore   (Store      - FlatDirStore)
  /p/       promptStore  (ReadableStore - FlatDirStore)
  /m/       memStore     (Store      - FlatDirStore)
  /pl/      planStore    (Store      - FlatDirStore)
  /t/       toolStore    (Store      - ToolStore, synthetic idx)
  /sk/      skillStore   (Store      - SkillStore, wraps skills package)
  /s/{id}/  per-session  (ReadWriteStore - SessionFileStore, in-memory)

Swapping a backend (e.g. /m/ to a vector store, /pl/ to beads) now requires
only a new Store implementation — no changes to the 9P server logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-14 21:40:17 +02:00
parent b5a0e82aad
commit 60024ee917
5 changed files with 687 additions and 465 deletions

View File

@ -47,7 +47,6 @@ import (
"ollie/pkg/agent"
"ollie/pkg/backend"
"ollie/pkg/config"
"ollie/pkg/skills"
"ollie/pkg/tools"
"ollie/pkg/tools/execute"
)
@ -136,24 +135,31 @@ type Server struct {
mu sync.RWMutex
sessions map[string]*session
conns []*connState
agentsDir string
agentsDir string // kept for session creation wiring
sessionsDir string
promptsDir string
planDir string // planning directory for /pl
memDir string // memory directory for /m
agentStore Store
promptStore ReadableStore
planStore Store
memStore Store
toolStore Store
skillStore Store
}
// New creates a new Server.
func New() *Server {
memDir := defaultMemDir()
os.MkdirAll(memDir, 0755) //nolint:errcheck
agentsDir := agent.DefaultAgentsDir()
return &Server{
sessions: make(map[string]*session),
agentsDir: agent.DefaultAgentsDir(),
agentsDir: agentsDir,
sessionsDir: agent.DefaultSessionsDir(),
promptsDir: agent.DefaultPromptsDir(),
planDir: defaultPlanDir(),
memDir: memDir,
agentStore: NewFlatDirStore(agentsDir, 0644),
promptStore: NewFlatDirStore(agent.DefaultPromptsDir(), 0444),
planStore: NewFlatDirStore(defaultPlanDir(), 0644),
memStore: NewFlatDirStore(memDir, 0644),
toolStore: NewToolStore(),
skillStore: NewSkillStore(),
}
}
@ -175,6 +181,21 @@ func defaultMemDir() string {
return home + "/.config/ollie/memory"
}
// sessionFileStore returns a SessionFileStore for the given session ID.
func (s *Server) sessionFileStore(sessID string) (*SessionFileStore, bool) {
s.mu.RLock()
sess, ok := s.sessions[sessID]
s.mu.RUnlock()
if !ok {
return nil, false
}
return NewSessionFileStore(
sess,
func() { s.killSession(sessID) },
func(newID string) error { return s.renameSession(sessID, newID) },
), true
}
// Serve handles a single 9P connection.
func (s *Server) Serve(conn net.Conn) {
defer conn.Close()
@ -287,34 +308,27 @@ func (s *Server) pathType(path string) string {
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 {
if _, err := s.agentStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "p":
if _, err := os.Stat(s.promptsDir + "/" + parts[1]); err == nil {
if _, err := s.promptStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "m":
if _, err := os.Stat(s.memDir + "/" + parts[1]); err == nil {
if _, err := s.memStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "pl":
if _, err := os.Stat(s.planDir + "/" + parts[1]); err == nil {
if _, err := s.planStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "sk":
if parts[1] == "idx" {
return "file"
}
name := strings.TrimSuffix(parts[1], ".md")
if _, err := skills.Read(name); err == nil {
if _, err := s.skillStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "t":
if parts[1] == "idx" {
return "file"
}
if _, err := os.Stat(execute.ToolsPath() + "/" + parts[1]); err == nil {
if _, err := s.toolStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 2 && parts[0] == "s":
@ -457,38 +471,23 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
// (e.g. touch) produces a real file.
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":
os.MkdirAll(s.promptsDir, 0755) //nolint:errcheck
if err := os.WriteFile(s.promptsDir+"/"+fc.Name, nil, 0644); err != nil {
if err := s.agentStore.Create(fc.Name); err != nil {
return errFcall(fc, err.Error())
}
case "/m":
os.MkdirAll(s.memDir, 0755) //nolint:errcheck
if err := os.WriteFile(s.memDir+"/"+fc.Name, nil, 0644); err != nil {
if err := s.memStore.Create(fc.Name); err != nil {
return errFcall(fc, err.Error())
}
case "/pl":
os.MkdirAll(s.planDir, 0755) //nolint:errcheck
if err := os.WriteFile(s.planDir+"/"+fc.Name, nil, 0644); err != nil {
if err := s.planStore.Create(fc.Name); err != nil {
return errFcall(fc, err.Error())
}
case "/t":
dir := execute.ToolsPath()
os.MkdirAll(dir, 0755) //nolint:errcheck
if err := os.WriteFile(dir+"/"+fc.Name, nil, 0755); err != nil {
if err := s.toolStore.Create(fc.Name); err != nil {
return errFcall(fc, err.Error())
}
case "/sk":
name := strings.TrimSuffix(fc.Name, ".md")
dir := skills.Dirs()[0] + "/" + name
if err := os.MkdirAll(dir, 0755); err != nil {
return errFcall(fc, err.Error())
}
if err := os.WriteFile(dir+"/SKILL.md", nil, 0644); err != nil {
if err := s.skillStore.Create(fc.Name); err != nil {
return errFcall(fc, err.Error())
}
}
@ -516,16 +515,6 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
}
// chat, reply, and dequeue are served from session state.
switch pathBase(path) {
case "chat":
return s.readChat(fc, path)
case "reply":
return s.readReply(fc, path)
case "dequeue":
return s.readDequeue(fc, path)
}
// s/new returns the session creation template.
if path == "/s/new" {
content := []byte("name=\ncwd=\nbackend=\nmodel=\nagent=\n")
@ -564,209 +553,82 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return s.readSlice(fc, content)
}
// Agent config files are served from disk.
// Agent config files are served from the agent store.
if strings.HasPrefix(path, "/a/") {
content, err := os.ReadFile(s.agentsDir + "/" + pathBase(path))
content, err := s.agentStore.Get(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 the prompt store.
if strings.HasPrefix(path, "/p/") {
content, err := os.ReadFile(s.promptsDir + "/" + pathBase(path))
content, err := s.promptStore.Get(pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
return s.readSlice(fc, content)
}
// Memory files are served from disk.
// Memory files are served from the memory store.
if strings.HasPrefix(path, "/m/") {
content, err := os.ReadFile(s.memDir + "/" + pathBase(path))
content, err := s.memStore.Get(pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
return s.readSlice(fc, content)
}
// Plan files are served from disk.
// Plan files are served from the plan store.
if strings.HasPrefix(path, "/pl/") {
content, err := os.ReadFile(s.planDir + "/" + pathBase(path))
content, err := s.planStore.Get(pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
return s.readSlice(fc, content)
}
// Skill files are served via pkg/skills (reads from disk each time).
// Skill files are served from the skill store.
if strings.HasPrefix(path, "/sk/") {
if pathBase(path) == "idx" {
var sb strings.Builder
for _, m := range skills.List() {
fmt.Fprintf(&sb, "## %s\n", m.Name)
fmt.Fprintf(&sb, "description: %s\n", m.Description)
sb.WriteString("\n")
}
return s.readSlice(fc, []byte(sb.String()))
}
name := strings.TrimSuffix(pathBase(path), ".md")
content, err := skills.Read(name)
content, err := s.skillStore.Get(pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
return s.readSlice(fc, content)
}
// Tool files are served from the tools path.
// Tool files are served from the tool store.
if strings.HasPrefix(path, "/t/") {
if pathBase(path) == "idx" {
toolsPath := execute.ToolsPath()
entries, err := os.ReadDir(toolsPath)
content, err := s.toolStore.Get(pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
return s.readSlice(fc, content)
}
// Session files: /s/{id}/{file}
if strings.HasPrefix(path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) == 3 {
store, ok := s.sessionFileStore(parts[1])
if !ok {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
// dequeue: non-zero offset is the trailing EOF read after a successful pop.
if parts[2] == "dequeue" && fc.Offset > 0 {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
content, err := store.Get(parts[2])
if err != nil {
return errFcall(fc, err.Error())
}
var sb strings.Builder
for _, e := range entries {
if e.IsDir() || e.Name() == "idx" {
continue
}
data, err := os.ReadFile(toolsPath + "/" + e.Name())
if err != nil {
continue
}
var desc, args string
for line := range strings.SplitSeq(string(data), "\n") {
if d, ok := strings.CutPrefix(line, "# description:"); ok {
desc = strings.TrimSpace(d)
} else if a, ok := strings.CutPrefix(line, "# Args:"); ok {
args = strings.TrimSpace(a)
} else if a, ok := strings.CutPrefix(line, "# args:"); ok {
args = strings.TrimSpace(a)
}
if !strings.HasPrefix(line, "#") && line != "" {
break
}
}
if desc == "" {
continue
}
fmt.Fprintf(&sb, "## %s\n", e.Name())
fmt.Fprintf(&sb, "description: %s\n", desc)
if args != "" {
fmt.Fprintf(&sb, "args: %s\n", args)
}
sb.WriteString("\n")
}
return s.readSlice(fc, []byte(sb.String()))
return s.readSlice(fc, content)
}
content, err := os.ReadFile(execute.ToolsPath() + "/" + pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
return s.readSlice(fc, content)
}
content := s.readFile(path)
var data []byte
off := int(fc.Offset)
if off < len(content) {
end := off + int(fc.Count)
if end > len(content) {
end = len(content)
}
data = []byte(content[off:end])
}
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: 0}
}
// readChat serves bytes from the session's chatLog at the requested offset.
// Returns 0 bytes when offset is at or past the current end (normal EOF);
// tail -f detects growth via stat and re-reads from its last position.
func (s *Server) readChat(fc *plan9.Fcall, path string) *plan9.Fcall {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 3 || parts[0] != "s" {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
s.mu.RLock()
sess := s.sessions[parts[1]]
s.mu.RUnlock()
if sess == nil {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
sess.mu.RLock()
log := sess.chatLog
sess.mu.RUnlock()
var data []byte
off := int(fc.Offset)
if off < len(log) {
end := off + int(fc.Count)
if end > len(log) {
end = len(log)
}
data = log[off:end]
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
}
// readDequeue pops the next queued prompt from the session's FIFO.
// Only pops on offset==0; a non-zero offset signals the EOF read that
// follows a successful read and must not consume another item.
func (s *Server) readDequeue(fc *plan9.Fcall, path string) *plan9.Fcall {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 3 || parts[0] != "s" {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
s.mu.RLock()
sess := s.sessions[parts[1]]
s.mu.RUnlock()
if sess == nil {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
// Non-zero offset is the trailing EOF read; don't pop another item.
if fc.Offset > 0 {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
item, ok := sess.core.PopQueue()
if !ok {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
data := []byte(item)
if uint32(len(data)) > fc.Count {
data = data[:fc.Count]
}
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data}
}
// readReply serves bytes from the session's reply buffer at the requested offset.
func (s *Server) readReply(fc *plan9.Fcall, path string) *plan9.Fcall {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 3 || parts[0] != "s" {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
s.mu.RLock()
sess := s.sessions[parts[1]]
s.mu.RUnlock()
if sess == nil {
return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: 0}
}
buf := []byte(sess.core.Reply())
var data []byte
off := int(fc.Offset)
if off < len(buf) {
end := off + int(fc.Count)
if end > len(buf) {
end = len(buf)
}
data = buf[off:end]
}
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 {
@ -787,47 +649,6 @@ func (s *Server) helpPath() string {
return home + "/.config/ollie/help.md"
}
// readFile returns the text content for a readable session file.
func (s *Server) readFile(path string) string {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 3 || parts[0] != "s" {
return ""
}
sessID, fileName := parts[1], parts[2]
s.mu.RLock()
sess := s.sessions[sessID]
s.mu.RUnlock()
if sess == nil {
return ""
}
sess.mu.RLock()
defer sess.mu.RUnlock()
switch fileName {
case "backend":
return sess.core.BackendName() + "\n"
case "agent":
return sess.core.AgentName() + "\n"
case "model":
return sess.core.ModelName() + "\n"
case "state":
return sess.core.State() + "\n"
case "cwd":
return sess.core.CWD() + "\n"
case "usage":
return sess.core.Usage() + "\n"
case "ctxsz":
return sess.core.CtxSz() + "\n"
case "models":
return sess.core.ListModels() + "\n"
case "mcp":
return sess.core.ListServers() + "\n"
case "systemprompt":
return sess.core.SystemPrompt()
}
return ""
}
func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock()
@ -885,9 +706,7 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
switch {
case strings.HasPrefix(f.path, "/a/"):
oldPath := s.agentsDir + "/" + oldName
newPath := s.agentsDir + "/" + newDir.Name
if err := os.Rename(oldPath, newPath); err != nil {
if err := s.agentStore.Rename(oldName, newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
cs.mu.Lock()
@ -896,9 +715,7 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Unlock()
case strings.HasPrefix(f.path, "/m/"):
oldPath := s.memDir + "/" + oldName
newPath := s.memDir + "/" + newDir.Name
if err := os.Rename(oldPath, newPath); err != nil {
if err := s.memStore.Rename(oldName, newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
cs.mu.Lock()
@ -907,9 +724,7 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Unlock()
case strings.HasPrefix(f.path, "/pl/"):
oldPath := s.planDir + "/" + oldName
newPath := s.planDir + "/" + newDir.Name
if err := os.Rename(oldPath, newPath); err != nil {
if err := s.planStore.Rename(oldName, newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
cs.mu.Lock()
@ -917,6 +732,24 @@ func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
case strings.HasPrefix(f.path, "/sk/"):
if err := s.skillStore.Rename(oldName, newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
cs.mu.Lock()
f.path = "/sk/" + newDir.Name
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
case strings.HasPrefix(f.path, "/t/"):
if err := s.toolStore.Rename(oldName, newDir.Name); err != nil {
return errFcall(fc, err.Error())
}
cs.mu.Lock()
f.path = "/t/" + newDir.Name
f.qid.Path = qidPath(f.path)
cs.mu.Unlock()
case strings.HasPrefix(f.path, "/s/") && f.path != "/s/new":
// Session directory rename: /s/{old} -> /s/{new}
parts := strings.SplitN(strings.TrimPrefix(f.path, "/"), "/", 3)
@ -1039,23 +872,15 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
var err error
switch {
case strings.HasPrefix(path, "/a/"):
err = os.Remove(s.agentsDir + "/" + pathBase(path))
case strings.HasPrefix(path, "/p/"):
err = os.Remove(s.promptsDir + "/" + pathBase(path))
err = s.agentStore.Delete(pathBase(path))
case strings.HasPrefix(path, "/m/"):
err = os.Remove(s.memDir + "/" + pathBase(path))
err = s.memStore.Delete(pathBase(path))
case strings.HasPrefix(path, "/pl/"):
err = os.Remove(s.planDir + "/" + pathBase(path))
err = s.planStore.Delete(pathBase(path))
case strings.HasPrefix(path, "/sk/"):
name := strings.TrimSuffix(pathBase(path), ".md")
for _, m := range skills.List() {
if m.Name == name {
err = os.RemoveAll(m.Dir)
break
}
}
err = s.skillStore.Delete(pathBase(path))
case strings.HasPrefix(path, "/t/"):
err = os.Remove(execute.ToolsPath() + "/" + pathBase(path))
err = s.toolStore.Delete(pathBase(path))
case strings.HasPrefix(path, "/s/") && path != "/s/new":
s.killSession(pathBase(path))
default:
@ -1079,166 +904,41 @@ func (s *Server) handleWrite(path, input string) error {
return s.handleNewSession(input)
}
// Prompt file writes go directly to disk.
if strings.HasPrefix(path, "/p/") {
return os.WriteFile(s.promptsDir+"/"+pathBase(path), []byte(input), 0644)
}
// Agent config writes go directly to disk.
// Agent config writes go to the agent store.
if strings.HasPrefix(path, "/a/") {
return os.WriteFile(s.agentsDir+"/"+pathBase(path), []byte(input), 0644)
return s.agentStore.Put(pathBase(path), []byte(input))
}
// Memory file writes go directly to disk.
// Memory file writes go to the memory store.
if strings.HasPrefix(path, "/m/") {
os.MkdirAll(s.memDir, 0755) //nolint:errcheck
dest := s.memDir + "/" + pathBase(path)
return os.WriteFile(dest, []byte(input), 0644)
return s.memStore.Put(pathBase(path), []byte(input))
}
// Plan file writes go directly to disk.
// Plan file writes go to the plan store.
if strings.HasPrefix(path, "/pl/") {
os.MkdirAll(s.planDir, 0755) //nolint:errcheck
return os.WriteFile(s.planDir+"/"+pathBase(path), []byte(input), 0644)
return s.planStore.Put(pathBase(path), []byte(input))
}
// Skill file writes: create skill directory and write SKILL.md.
// Skill file writes go to the skill store.
if strings.HasPrefix(path, "/sk/") {
name := strings.TrimSuffix(pathBase(path), ".md")
dir := ""
for _, m := range skills.List() {
if m.Name == name {
dir = m.Dir
break
}
}
if dir == "" {
dir = skills.Dirs()[0] + "/" + name
}
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(dir+"/SKILL.md", []byte(input), 0644)
return s.skillStore.Put(pathBase(path), []byte(input))
}
// Tool file writes go directly to disk.
// Tool file writes go to the tool store.
if strings.HasPrefix(path, "/t/") {
return os.WriteFile(execute.ToolsPath()+"/"+pathBase(path), []byte(input), 0755)
return s.toolStore.Put(pathBase(path), []byte(input))
}
// Path format: /s/{sessid}/{file}
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) != 3 || parts[0] != "s" {
return nil
}
sessID, fileName := parts[1], parts[2]
s.mu.RLock()
sess := s.sessions[sessID]
s.mu.RUnlock()
if sess == nil {
return fmt.Errorf("session not found: %s", sessID)
}
// assistantStarted tracks whether we've emitted the "assistant: " label
// for the current turn. Resets at turn boundaries so each response gets
// exactly one label regardless of how many streaming chunks arrive.
assistantStarted := false
publish := func(ev agent.Event) {
switch ev.Role {
case "call", "tool", "newline":
assistantStarted = false
case "assistant":
if !assistantStarted {
sess.appendChat([]byte("assistant: "))
assistantStarted = true
// Session file writes: /s/{sessid}/{file}
if strings.HasPrefix(path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
if len(parts) == 3 {
store, ok := s.sessionFileStore(parts[1])
if !ok {
return fmt.Errorf("session not found: %s", parts[1])
}
return store.Put(parts[2], []byte(input))
}
sess.appendChat(formatEvent(ev))
sess.trackMutable()
}
switch fileName {
case "prompt":
sess.core.Submit(sess.ctx, input, publish)
sess.trackMutable()
sess.mu.Lock()
sess.replyVers++
sess.mu.Unlock()
return nil
case "enqueue":
sess.core.Queue(input)
return nil
case "ctl":
cmd := strings.Fields(input)
if len(cmd) == 0 {
return fmt.Errorf("empty ctl command")
}
switch cmd[0] {
case "stop":
sess.core.Interrupt(agent.ErrInterrupted)
case "kill":
s.killSession(sessID)
case "rn":
if name := strings.TrimSpace(input[3:]); name != "" {
if err := s.renameSession(sessID, name); err != nil {
fmt.Fprintf(os.Stderr, "olliesrv: rename: %v\n", err)
}
}
case "compact", "clear", "backend", "model", "models",
"agents", "agent", "sessions", "cwd", "skills",
"tools", "mcp", "context", "usage", "history",
"irw", "help":
sess.core.Submit(sess.ctx, "/"+input, publish)
default:
return fmt.Errorf("unknown ctl command: %s", cmd[0])
}
return nil
case "backend":
if sess.core.IsRunning() {
return fmt.Errorf("cannot switch backend while agent is running")
}
sess.core.Submit(sess.ctx, "/backend "+input, publish)
sess.mu.Lock()
sess.mutableVers["backend"].update(sess.core.BackendName())
sess.mutableVers["model"].update(sess.core.ModelName())
sess.mu.Unlock()
return nil
case "agent":
if sess.core.IsRunning() {
return fmt.Errorf("cannot switch agent while agent is running")
}
sess.core.Submit(sess.ctx, "/agent "+input, publish)
sess.mu.Lock()
sess.mutableVers["agent"].update(sess.core.AgentName())
sess.mu.Unlock()
return nil
case "model":
if input == "" {
return fmt.Errorf("empty model name")
}
if sess.core.IsRunning() {
return fmt.Errorf("cannot switch model while agent is running")
}
sess.core.Submit(sess.ctx, "/model "+input, publish)
sess.mu.Lock()
sess.mutableVers["model"].update(sess.core.ModelName())
sess.mu.Unlock()
return nil
case "cwd":
if err := sess.core.SetCWD(input); err != nil {
return err
}
sess.mu.Lock()
sess.mutableVers["cwd"].update(sess.core.CWD())
sess.mu.Unlock()
return nil
}
return nil
@ -1457,27 +1157,27 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
dirs = append(dirs, makeDir("backends", "/backends", false, 0444))
dirs = append(dirs, makeDir("help", "/help", false, 0444))
dirs = append(dirs, makeDir("m", "/m", true, plan9.DMDIR|0755))
dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0755))
dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0555))
dirs = append(dirs, makeDir("pl", "/pl", true, plan9.DMDIR|0755))
dirs = append(dirs, makeDir("s", "/s", true, plan9.DMDIR|0555))
dirs = append(dirs, makeDir("sk", "/sk", true, plan9.DMDIR|0555))
dirs = append(dirs, makeDir("t", "/t", true, plan9.DMDIR|0777))
} else if path == "/a" {
entries, _ := os.ReadDir(s.agentsDir)
entries, _ := s.agentStore.List()
for _, e := range entries {
if !e.IsDir() {
dirs = append(dirs, makeDir(e.Name(), "/a/"+e.Name(), false, 0666))
}
}
} else if path == "/p" {
entries, _ := os.ReadDir(s.promptsDir)
entries, _ := s.promptStore.List()
for _, e := range entries {
if !e.IsDir() {
dirs = append(dirs, makeDir(e.Name(), "/p/"+e.Name(), false, 0666))
}
}
} else if path == "/m" {
entries, _ := os.ReadDir(s.memDir)
entries, _ := s.memStore.List()
for _, e := range entries {
if !e.IsDir() {
d := makeDir(e.Name(), "/m/"+e.Name(), false, 0666)
@ -1489,7 +1189,7 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
}
}
} else if path == "/pl" {
entries, _ := os.ReadDir(s.planDir)
entries, _ := s.planStore.List()
for _, e := range entries {
if !e.IsDir() {
d := makeDir(e.Name(), "/pl/"+e.Name(), false, 0666)
@ -1501,18 +1201,22 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
}
}
} else if path == "/sk" {
dirs = append(dirs, makeDir("idx", "/sk/idx", false, 0444))
for _, m := range skills.List() {
name := m.Name + ".md"
dirs = append(dirs, makeDir(name, "/sk/"+name, false, 0666))
entries, _ := s.skillStore.List()
for _, e := range entries {
mode := plan9.Perm(0666)
if e.Name() == "idx" {
mode = 0444
}
dirs = append(dirs, makeDir(e.Name(), "/sk/"+e.Name(), false, mode))
}
} else if path == "/t" {
dirs = append(dirs, makeDir("idx", "/t/idx", false, 0444))
entries, _ := os.ReadDir(execute.ToolsPath())
entries, _ := s.toolStore.List()
for _, e := range entries {
if !e.IsDir() {
dirs = append(dirs, makeDir(e.Name(), "/t/"+e.Name(), false, 0777))
mode := plan9.Perm(0777)
if e.Name() == "idx" {
mode = 0444
}
dirs = append(dirs, makeDir(e.Name(), "/t/"+e.Name(), false, mode))
}
} else if path == "/s" {
dirs = append(dirs, makeDir("new", "/s/new", false, 0666))
@ -1523,32 +1227,14 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
}
s.mu.RUnlock()
} else {
// Session directory: path is /s/{sessid}
sessPath := path
type entry struct {
name string
mode plan9.Perm
}
files := []entry{
{"ctl", 0200},
{"prompt", 0200},
{"enqueue", 0200},
{"dequeue", 0444},
{"chat", 0444},
{"reply", 0444},
{"state", 0444},
{"backend", 0666},
{"agent", 0666},
{"model", 0666},
{"cwd", 0666},
{"usage", 0444},
{"ctxsz", 0444},
{"models", 0444},
{"mcp", 0444},
{"systemprompt", 0444},
}
for _, e := range files {
dirs = append(dirs, makeDir(e.name, sessPath+"/"+e.name, false, e.mode))
// Session subdirectory: /s/{sessid}
sessID := pathBase(path)
if store, ok := s.sessionFileStore(sessID); ok {
entries, _ := store.List()
for _, e := range entries {
info, _ := e.Info()
dirs = append(dirs, makeDir(e.Name(), path+"/"+e.Name(), false, plan9.Perm(info.Mode())))
}
}
}
@ -1601,7 +1287,7 @@ func (s *Server) makeStat(path string) plan9.Dir {
qid.Type = QTDir
if path == "/t" {
mode = plan9.DMDIR | 0777
} else if path == "/a" || path == "/m" || path == "/p" || path == "/pl" {
} else if path == "/a" || path == "/m" || path == "/pl" {
mode = plan9.DMDIR | 0755
} else {
mode = plan9.DMDIR | 0555
@ -1622,7 +1308,7 @@ func (s *Server) makeStat(path string) plan9.Dir {
} else if strings.HasPrefix(path, "/a/") {
mode = 0666
} else if strings.HasPrefix(path, "/p/") {
mode = 0666
mode = 0444
} else if strings.HasPrefix(path, "/m/") {
mode = 0666
} else if strings.HasPrefix(path, "/pl/") {
@ -1681,25 +1367,26 @@ func (s *Server) makeStat(path string) plan9.Dir {
if mf != nil && atomic.CompareAndSwapInt32(&mf.truncated, 1, 0) {
// Report size 0 once so tail -f detects truncation.
dir.Length = 0
} else {
content := s.readFile(path)
dir.Length = uint64(len(content))
} else if store, ok := s.sessionFileStore(parts[1]); ok {
if info, err := store.Stat(base); err == nil {
dir.Length = uint64(info.Size())
}
}
}
}
}
}
// For memory and plan files, report real size and timestamps from disk.
// For memory and plan files, report real size and timestamps from the store.
if strings.HasPrefix(path, "/m/") {
if info, err := os.Stat(s.memDir + "/" + base); err == nil {
if info, err := s.memStore.Stat(base); err == nil {
dir.Length = uint64(info.Size())
dir.Atime = uint32(info.ModTime().Unix())
dir.Mtime = uint32(info.ModTime().Unix())
}
}
if strings.HasPrefix(path, "/pl/") {
if info, err := os.Stat(s.planDir + "/" + base); err == nil {
if info, err := s.planStore.Stat(base); err == nil {
dir.Length = uint64(info.Size())
dir.Atime = uint32(info.ModTime().Unix())
dir.Mtime = uint32(info.ModTime().Unix())

View File

@ -0,0 +1,231 @@
package p9
import (
"fmt"
"os"
"strings"
"ollie/pkg/agent"
)
// sessionFileList defines the fixed set of files in a session directory,
// with their 9P permission modes.
var sessionFileList = []struct {
name string
mode os.FileMode
}{
{"ctl", 0200},
{"prompt", 0200},
{"enqueue", 0200},
{"dequeue", 0444},
{"chat", 0444},
{"reply", 0444},
{"state", 0444},
{"backend", 0666},
{"agent", 0666},
{"model", 0666},
{"cwd", 0666},
{"usage", 0444},
{"ctxsz", 0444},
{"models", 0444},
{"mcp", 0444},
{"systemprompt", 0444},
}
// SessionFileStore implements ReadWriteStore for the files within a single
// session directory (/s/{id}/*). The file set is fixed; Create, Delete, and
// Rename are not meaningful and are not part of the interface.
type SessionFileStore struct {
sess *session
kill func()
rename func(newID string) error
}
func NewSessionFileStore(sess *session, kill func(), rename func(newID string) error) *SessionFileStore {
return &SessionFileStore{sess: sess, kill: kill, rename: rename}
}
func (s *SessionFileStore) List() ([]os.DirEntry, error) {
entries := make([]os.DirEntry, len(sessionFileList))
for i, f := range sessionFileList {
entries[i] = syntheticEntry(f.name, f.mode)
}
return entries, nil
}
func (s *SessionFileStore) Stat(name string) (os.FileInfo, error) {
for _, f := range sessionFileList {
if f.name == name {
var size int64
switch name {
case "chat":
s.sess.mu.RLock()
size = int64(len(s.sess.chatLog))
s.sess.mu.RUnlock()
case "reply":
size = int64(len(s.sess.core.Reply()))
default:
size = int64(len(s.content(name)))
}
return &syntheticFileInfo{name: name, mode: f.mode, size: size}, nil
}
}
return nil, fmt.Errorf("%s: not found", name)
}
func (s *SessionFileStore) Get(name string) ([]byte, error) {
switch name {
case "chat":
s.sess.mu.RLock()
data := make([]byte, len(s.sess.chatLog))
copy(data, s.sess.chatLog)
s.sess.mu.RUnlock()
return data, nil
case "reply":
return []byte(s.sess.core.Reply()), nil
case "dequeue":
item, ok := s.sess.core.PopQueue()
if !ok {
return nil, nil
}
return []byte(item), nil
default:
for _, f := range sessionFileList {
if f.name == name {
return []byte(s.content(name)), nil
}
}
return nil, fmt.Errorf("%s: not found", name)
}
}
func (s *SessionFileStore) Put(name string, data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
switch name {
case "prompt":
s.sess.core.Submit(s.sess.ctx, input, s.makePublish())
s.sess.trackMutable()
s.sess.mu.Lock()
s.sess.replyVers++
s.sess.mu.Unlock()
case "enqueue":
s.sess.core.Queue(input)
case "ctl":
return s.handleCtl(input)
case "backend":
if s.sess.core.IsRunning() {
return fmt.Errorf("cannot switch backend while agent is running")
}
s.sess.core.Submit(s.sess.ctx, "/backend "+input, s.makePublish())
s.sess.mu.Lock()
s.sess.mutableVers["backend"].update(s.sess.core.BackendName())
s.sess.mutableVers["model"].update(s.sess.core.ModelName())
s.sess.mu.Unlock()
case "agent":
if s.sess.core.IsRunning() {
return fmt.Errorf("cannot switch agent while agent is running")
}
s.sess.core.Submit(s.sess.ctx, "/agent "+input, s.makePublish())
s.sess.mu.Lock()
s.sess.mutableVers["agent"].update(s.sess.core.AgentName())
s.sess.mu.Unlock()
case "model":
if s.sess.core.IsRunning() {
return fmt.Errorf("cannot switch model while agent is running")
}
s.sess.core.Submit(s.sess.ctx, "/model "+input, s.makePublish())
s.sess.mu.Lock()
s.sess.mutableVers["model"].update(s.sess.core.ModelName())
s.sess.mu.Unlock()
case "cwd":
if err := s.sess.core.SetCWD(input); err != nil {
return err
}
s.sess.mu.Lock()
s.sess.mutableVers["cwd"].update(s.sess.core.CWD())
s.sess.mu.Unlock()
}
return nil
}
// content returns the string content of a simple readable session file.
func (s *SessionFileStore) content(name string) string {
s.sess.mu.RLock()
defer s.sess.mu.RUnlock()
switch name {
case "backend":
return s.sess.core.BackendName() + "\n"
case "agent":
return s.sess.core.AgentName() + "\n"
case "model":
return s.sess.core.ModelName() + "\n"
case "state":
return s.sess.core.State() + "\n"
case "cwd":
return s.sess.core.CWD() + "\n"
case "usage":
return s.sess.core.Usage() + "\n"
case "ctxsz":
return s.sess.core.CtxSz() + "\n"
case "models":
return s.sess.core.ListModels() + "\n"
case "mcp":
return s.sess.core.ListServers() + "\n"
case "systemprompt":
return s.sess.core.SystemPrompt()
}
return ""
}
func (s *SessionFileStore) makePublish() func(agent.Event) {
assistantStarted := false
return func(ev agent.Event) {
switch ev.Role {
case "call", "tool", "newline":
assistantStarted = false
case "assistant":
if !assistantStarted {
s.sess.appendChat([]byte("assistant: "))
assistantStarted = true
}
}
s.sess.appendChat(formatEvent(ev))
s.sess.trackMutable()
}
}
func (s *SessionFileStore) handleCtl(input string) error {
cmd := strings.Fields(input)
if len(cmd) == 0 {
return fmt.Errorf("empty ctl command")
}
switch cmd[0] {
case "stop":
s.sess.core.Interrupt(agent.ErrInterrupted)
case "kill":
s.kill()
case "rn":
if name := strings.TrimSpace(input[3:]); name != "" {
if err := s.rename(name); err != nil {
fmt.Fprintf(os.Stderr, "olliesrv: rename: %v\n", err)
}
}
case "compact", "clear", "backend", "model", "models",
"agents", "agent", "sessions", "cwd", "skills",
"tools", "mcp", "context", "usage", "history",
"irw", "help":
s.sess.core.Submit(s.sess.ctx, "/"+input, s.makePublish())
default:
return fmt.Errorf("unknown ctl command: %s", cmd[0])
}
return nil
}

106
internal/p9/skillstore.go Normal file
View File

@ -0,0 +1,106 @@
package p9
import (
"fmt"
"os"
"path/filepath"
"strings"
"ollie/pkg/skills"
)
// SkillStore is a BlobStore backed by the skills package.
// Skills are stored as directories containing a SKILL.md file, but are
// exposed over 9P as flat "{name}.md" entries. The synthetic "idx" entry
// lists all skills with their descriptions.
type SkillStore struct{}
func NewSkillStore() *SkillStore {
return &SkillStore{}
}
func (s *SkillStore) Stat(name string) (os.FileInfo, error) {
if name == "idx" {
return &syntheticFileInfo{name: "idx", mode: 0444}, nil
}
skillName := strings.TrimSuffix(name, ".md")
if _, err := skills.Read(skillName); err != nil {
return nil, fmt.Errorf("%s: not found", name)
}
return &syntheticFileInfo{name: name, mode: 0666}, nil
}
func (s *SkillStore) List() ([]os.DirEntry, error) {
result := []os.DirEntry{syntheticEntry("idx", 0444)}
for _, m := range skills.List() {
result = append(result, syntheticEntry(m.Name+".md", 0666))
}
return result, nil
}
func (s *SkillStore) Get(name string) ([]byte, error) {
if name == "idx" {
return s.index()
}
skillName := strings.TrimSuffix(name, ".md")
return skills.Read(skillName)
}
func (s *SkillStore) Put(name string, data []byte) error {
skillName := strings.TrimSuffix(name, ".md")
dir := ""
for _, m := range skills.List() {
if m.Name == skillName {
dir = m.Dir
break
}
}
if dir == "" {
dir = filepath.Join(skills.Dirs()[0], skillName)
}
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "SKILL.md"), data, 0644)
}
func (s *SkillStore) Delete(name string) error {
skillName := strings.TrimSuffix(name, ".md")
for _, m := range skills.List() {
if m.Name == skillName {
return os.RemoveAll(m.Dir)
}
}
return fmt.Errorf("skill not found: %s", skillName)
}
func (s *SkillStore) Rename(oldName, newName string) error {
oldSkill := strings.TrimSuffix(oldName, ".md")
newSkill := strings.TrimSuffix(newName, ".md")
for _, m := range skills.List() {
if m.Name == oldSkill {
newDir := filepath.Join(filepath.Dir(m.Dir), newSkill)
return os.Rename(m.Dir, newDir)
}
}
return fmt.Errorf("skill not found: %s", oldSkill)
}
func (s *SkillStore) Create(name string) error {
skillName := strings.TrimSuffix(name, ".md")
dir := filepath.Join(skills.Dirs()[0], skillName)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "SKILL.md"), nil, 0644)
}
func (s *SkillStore) index() ([]byte, error) {
var sb strings.Builder
for _, m := range skills.List() {
fmt.Fprintf(&sb, "## %s\n", m.Name)
fmt.Fprintf(&sb, "description: %s\n", m.Description)
sb.WriteString("\n")
}
return []byte(sb.String()), nil
}

116
internal/p9/store.go Normal file
View File

@ -0,0 +1,116 @@
package p9
import (
"os"
"path/filepath"
"time"
)
// syntheticFileInfo implements os.FileInfo for entries with no backing file.
type syntheticFileInfo struct {
name string
mode os.FileMode
size int64
}
func (f *syntheticFileInfo) Name() string { return f.name }
func (f *syntheticFileInfo) Size() int64 { return f.size }
func (f *syntheticFileInfo) Mode() os.FileMode { return f.mode }
func (f *syntheticFileInfo) ModTime() time.Time { return time.Time{} }
func (f *syntheticFileInfo) IsDir() bool { return false }
func (f *syntheticFileInfo) Sys() any { return nil }
// syntheticDirEntry implements os.DirEntry for entries with no backing file.
type syntheticDirEntry struct {
name string
mode os.FileMode
}
func (e *syntheticDirEntry) Name() string { return e.name }
func (e *syntheticDirEntry) IsDir() bool { return false }
func (e *syntheticDirEntry) Type() os.FileMode { return 0 }
func (e *syntheticDirEntry) Info() (os.FileInfo, error) {
return &syntheticFileInfo{name: e.name, mode: e.mode}, nil
}
func syntheticEntry(name string, mode os.FileMode) os.DirEntry {
return &syntheticDirEntry{name: name, mode: mode}
}
// ReadableStore is a named collection of byte blobs supporting only read operations.
type ReadableStore interface {
Stat(name string) (os.FileInfo, error)
List() ([]os.DirEntry, error)
Get(name string) ([]byte, error)
}
// WritableStore supports writing blobs by name.
type WritableStore interface {
Put(name string, data []byte) error
}
// ReadWriteStore supports reading and writing but not structural mutations.
type ReadWriteStore interface {
ReadableStore
WritableStore
}
// BlobStore extends ReadWriteStore with creation and deletion.
// Suitable for content-addressed or append-only backing stores.
type BlobStore interface {
ReadWriteStore
Delete(name string) error
Create(name string) error
}
// Store is a fully mutable store that also supports renaming entries.
type Store interface {
BlobStore
Rename(oldName, newName string) error
}
// FlatDirStore implements Store backed by a directory on the local filesystem.
type FlatDirStore struct {
dir string
perm os.FileMode
}
// NewFlatDirStore returns a FlatDirStore rooted at dir.
// perm is applied to newly created or written files.
func NewFlatDirStore(dir string, perm os.FileMode) *FlatDirStore {
return &FlatDirStore{dir: dir, perm: perm}
}
func (s *FlatDirStore) Stat(name string) (os.FileInfo, error) {
return os.Stat(filepath.Join(s.dir, name))
}
func (s *FlatDirStore) List() ([]os.DirEntry, error) {
return os.ReadDir(s.dir)
}
func (s *FlatDirStore) Get(name string) ([]byte, error) {
return os.ReadFile(filepath.Join(s.dir, name))
}
func (s *FlatDirStore) Put(name string, data []byte) error {
if err := os.MkdirAll(s.dir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(s.dir, name), data, s.perm)
}
func (s *FlatDirStore) Delete(name string) error {
return os.Remove(filepath.Join(s.dir, name))
}
func (s *FlatDirStore) Create(name string) error {
if err := os.MkdirAll(s.dir, 0755); err != nil {
return err
}
return os.WriteFile(filepath.Join(s.dir, name), nil, s.perm)
}
func (s *FlatDirStore) Rename(oldName, newName string) error {
return os.Rename(filepath.Join(s.dir, oldName), filepath.Join(s.dir, newName))
}

82
internal/p9/toolstore.go Normal file
View File

@ -0,0 +1,82 @@
package p9
import (
"fmt"
"os"
"strings"
"ollie/pkg/tools/execute"
)
// ToolStore is a BlobStore backed by the tools directory.
// It extends FlatDirStore with a synthetic "idx" entry that lists all tools
// with their descriptions and argument signatures.
type ToolStore struct {
*FlatDirStore
}
func NewToolStore() *ToolStore {
return &ToolStore{FlatDirStore: NewFlatDirStore(execute.ToolsPath(), 0755)}
}
func (s *ToolStore) Stat(name string) (os.FileInfo, error) {
if name == "idx" {
return &syntheticFileInfo{name: "idx", mode: 0444}, nil
}
return s.FlatDirStore.Stat(name)
}
func (s *ToolStore) List() ([]os.DirEntry, error) {
entries, err := s.FlatDirStore.List()
result := make([]os.DirEntry, 0, len(entries)+1)
result = append(result, syntheticEntry("idx", 0444))
result = append(result, entries...)
return result, err
}
func (s *ToolStore) Get(name string) ([]byte, error) {
if name == "idx" {
return s.index()
}
return s.FlatDirStore.Get(name)
}
func (s *ToolStore) index() ([]byte, error) {
entries, err := s.FlatDirStore.List()
if err != nil {
return nil, err
}
var sb strings.Builder
for _, e := range entries {
if e.IsDir() {
continue
}
data, err := s.FlatDirStore.Get(e.Name())
if err != nil {
continue
}
var desc, args string
for line := range strings.SplitSeq(string(data), "\n") {
if d, ok := strings.CutPrefix(line, "# description:"); ok {
desc = strings.TrimSpace(d)
} else if a, ok := strings.CutPrefix(line, "# Args:"); ok {
args = strings.TrimSpace(a)
} else if a, ok := strings.CutPrefix(line, "# args:"); ok {
args = strings.TrimSpace(a)
}
if !strings.HasPrefix(line, "#") && line != "" {
break
}
}
if desc == "" {
continue
}
fmt.Fprintf(&sb, "## %s\n", e.Name())
fmt.Fprintf(&sb, "description: %s\n", desc)
if args != "" {
fmt.Fprintf(&sb, "args: %s\n", args)
}
sb.WriteString("\n")
}
return []byte(sb.String()), nil
}