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:
parent
b5a0e82aad
commit
60024ee917
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Reference in New Issue