9p: add transcript store (tr/) and save via chat write or ctl save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-16 22:35:02 +02:00
parent 261fa547fb
commit b4974cc6c2
2 changed files with 82 additions and 18 deletions

View File

@ -17,7 +17,7 @@
// prompt (write) submit a prompt to the agent // prompt (write) submit a prompt to the agent
// 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 (r/w) cumulative chat history; write to save a transcript to tr/
// state (read) current agent state // state (read) current agent state
// backend (r/w) active backend name // backend (r/w) active backend name
// agent (r/w) active agent name // agent (r/w) active agent name
@ -29,6 +29,7 @@
// ctxsz (read) context size vs window // ctxsz (read) context size vs window
// sk/ (dir) skills // sk/ (dir) skills
// t/ (dir) tool scripts // t/ (dir) tool scripts
// tr/ (dir) transcripts (ro; write to s/{id}/chat to save)
package p9 package p9
import ( import (
@ -41,6 +42,7 @@ import (
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"9fans.net/go/plan9" "9fans.net/go/plan9"
"ollie/pkg/agent" "ollie/pkg/agent"
@ -140,27 +142,31 @@ type Server struct {
promptStore ReadableStore promptStore ReadableStore
planStore Store planStore Store
memStore Store memStore Store
toolStore Store toolStore Store
skillStore Store skillStore Store
sessionStore Store sessionStore Store
batchStore *BatchStore batchStore *BatchStore
transcriptStore Store
} }
// New creates a new Server. // New creates a new Server.
func New() *Server { func New() *Server {
memDir := defaultMemDir() memDir := defaultMemDir()
os.MkdirAll(memDir, 0755) //nolint:errcheck os.MkdirAll(memDir, 0755) //nolint:errcheck
transcriptDir := defaultTranscriptDir()
os.MkdirAll(transcriptDir, 0755) //nolint:errcheck
agentsDir := agent.DefaultAgentsDir() agentsDir := agent.DefaultAgentsDir()
s := &Server{ s := &Server{
sessions: make(map[string]*session), sessions: make(map[string]*session),
agentsDir: agentsDir, agentsDir: agentsDir,
sessionsDir: agent.DefaultSessionsDir(), sessionsDir: agent.DefaultSessionsDir(),
agentStore: NewFlatDirStore(agentsDir, 0644), agentStore: NewFlatDirStore(agentsDir, 0644),
promptStore: NewFlatDirStore(agent.DefaultPromptsDir(), 0444), promptStore: NewFlatDirStore(agent.DefaultPromptsDir(), 0444),
planStore: NewFlatDirStore(defaultPlanDir(), 0644), planStore: NewFlatDirStore(defaultPlanDir(), 0644),
memStore: NewFlatDirStore(memDir, 0644), memStore: NewFlatDirStore(memDir, 0644),
toolStore: NewToolStore(), toolStore: NewToolStore(),
skillStore: NewSkillStore(), skillStore: NewSkillStore(),
transcriptStore: NewFlatDirStore(transcriptDir, 0444),
} }
s.sessionStore = &SessionStore{srv: s} s.sessionStore = &SessionStore{srv: s}
s.batchStore = NewBatchStore(s) s.batchStore = NewBatchStore(s)
@ -176,6 +182,15 @@ func defaultPlanDir() string {
return home + "/.config/ollie/planning" return home + "/.config/ollie/planning"
} }
// defaultTranscriptDir returns the transcript directory from OLLIE_TRANSCRIPT_PATH or the default.
func defaultTranscriptDir() string {
if p := os.Getenv("OLLIE_TRANSCRIPT_PATH"); p != "" {
return p
}
home, _ := os.UserHomeDir()
return home + "/.config/ollie/transcript"
}
// defaultMemDir returns the memory directory from OLLIE_MEMORY_PATH or the default. // defaultMemDir returns the memory directory from OLLIE_MEMORY_PATH or the default.
func defaultMemDir() string { func defaultMemDir() string {
if p := os.Getenv("OLLIE_MEMORY_PATH"); p != "" { if p := os.Getenv("OLLIE_MEMORY_PATH"); p != "" {
@ -197,6 +212,10 @@ func (s *Server) sessionFileStore(sessID string) (*SessionFileStore, bool) {
sess, sess,
func() { s.killSession(sessID) }, func() { s.killSession(sessID) },
func(newID string) error { return s.renameSession(sessID, newID) }, func(newID string) error { return s.renameSession(sessID, newID) },
func(data []byte) error {
name := time.Now().Format("20060102T150405") + "-chat.md"
return s.transcriptStore.Put(name, data)
},
), true ), true
} }
@ -314,6 +333,12 @@ 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] == "tr":
return "dir"
case len(parts) == 2 && parts[0] == "tr":
if _, err := s.transcriptStore.Stat(parts[1]); err == nil {
return "file"
}
case len(parts) == 1 && parts[0] == "b": case len(parts) == 1 && parts[0] == "b":
return "dir" return "dir"
case len(parts) == 2 && parts[0] == "b": case len(parts) == 2 && parts[0] == "b":
@ -649,6 +674,16 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
return s.readSlice(fc, content) return s.readSlice(fc, content)
} }
// Transcript files are served from the transcript store.
if strings.HasPrefix(path, "/tr/") {
plog.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count)
content, err := s.transcriptStore.Get(pathBase(path))
if err != nil {
return errFcall(fc, err.Error())
}
return s.readSlice(fc, content)
}
// Session files: /s/{id}/{file} // Session files: /s/{id}/{file}
if strings.HasPrefix(path, "/s/") { if strings.HasPrefix(path, "/s/") {
parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3) parts := strings.SplitN(strings.TrimPrefix(path, "/"), "/", 3)
@ -1243,6 +1278,7 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
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))
dirs = append(dirs, makeDir("tr", "/tr", true, plan9.DMDIR|0555))
} else if path == "/a" { } else if path == "/a" {
entries, _ := s.agentStore.List() entries, _ := s.agentStore.List()
for _, e := range entries { for _, e := range entries {
@ -1299,6 +1335,18 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
} }
dirs = append(dirs, makeDir(e.Name(), "/t/"+e.Name(), false, mode)) dirs = append(dirs, makeDir(e.Name(), "/t/"+e.Name(), false, mode))
} }
} else if path == "/tr" {
entries, _ := s.transcriptStore.List()
for _, e := range entries {
if !e.IsDir() {
d := makeDir(e.Name(), "/tr/"+e.Name(), false, 0444)
if info, err := e.Info(); err == nil {
d.Atime = uint32(info.ModTime().Unix())
d.Mtime = uint32(info.ModTime().Unix())
}
dirs = append(dirs, d)
}
}
} else if path == "/b" { } else if path == "/b" {
entries, _ := s.batchStore.List() entries, _ := s.batchStore.List()
for _, e := range entries { for _, e := range entries {
@ -1521,6 +1569,12 @@ func (s *Server) makeStat(path string) plan9.Dir {
if content, err := s.toolStore.Get(base); err == nil { if content, err := s.toolStore.Get(base); err == nil {
dir.Length = uint64(len(content)) dir.Length = uint64(len(content))
} }
case strings.HasPrefix(path, "/tr/"):
if info, err := s.transcriptStore.Stat(base); err == nil {
dir.Length = uint64(info.Size())
dir.Atime = uint32(info.ModTime().Unix())
dir.Mtime = uint32(info.ModTime().Unix())
}
case path == "/b/new" || path == "/b/idx": case path == "/b/new" || path == "/b/idx":
if content, err := s.batchStore.Get(base); err == nil { if content, err := s.batchStore.Get(base); err == nil {
dir.Length = uint64(len(content)) dir.Length = uint64(len(content))

View File

@ -18,7 +18,7 @@ var sessionFileList = []struct {
{"prompt", 0200}, {"prompt", 0200},
{"enqueue", 0200}, {"enqueue", 0200},
{"dequeue", 0444}, {"dequeue", 0444},
{"chat", 0444}, {"chat", 0666},
{"offset", 0444}, {"offset", 0444},
{"state", 0444}, {"state", 0444},
{"backend", 0666}, {"backend", 0666},
@ -36,13 +36,14 @@ var sessionFileList = []struct {
// session directory (/s/{id}/*). The file set is fixed; Create, Delete, and // session directory (/s/{id}/*). The file set is fixed; Create, Delete, and
// Rename are not meaningful and are not part of the interface. // Rename are not meaningful and are not part of the interface.
type SessionFileStore struct { type SessionFileStore struct {
sess *session sess *session
kill func() kill func()
rename func(newID string) error rename func(newID string) error
saveTranscript func([]byte) error
} }
func NewSessionFileStore(sess *session, kill func(), rename func(newID string) error) *SessionFileStore { func NewSessionFileStore(sess *session, kill func(), rename func(newID string) error, saveTranscript func([]byte) error) *SessionFileStore {
return &SessionFileStore{sess: sess, kill: kill, rename: rename} return &SessionFileStore{sess: sess, kill: kill, rename: rename, saveTranscript: saveTranscript}
} }
func (s *SessionFileStore) List() ([]os.DirEntry, error) { func (s *SessionFileStore) List() ([]os.DirEntry, error) {
@ -103,6 +104,9 @@ func (s *SessionFileStore) Put(name string, data []byte) error {
return nil return nil
} }
switch name { switch name {
case "chat":
return s.saveTranscript([]byte(input))
case "prompt": case "prompt":
s.sess.core.Submit(s.sess.ctx, input, s.makePublish()) s.sess.core.Submit(s.sess.ctx, input, s.makePublish())
s.sess.trackMutable() s.sess.trackMutable()
@ -221,6 +225,12 @@ func (s *SessionFileStore) handleCtl(input string) error {
plog.Error("rename: %v", err) plog.Error("rename: %v", err)
} }
} }
case "save":
s.sess.mu.RLock()
data := make([]byte, len(s.sess.chatLog))
copy(data, s.sess.chatLog)
s.sess.mu.RUnlock()
return s.saveTranscript(data)
case "compact", "clear", "backend", "model", "models", case "compact", "clear", "backend", "model", "models",
"agents", "agent", "sessions", "cwd", "skills", "agents", "agent", "sessions", "cwd", "skills",
"tools", "mcp", "context", "usage", "history", "tools", "mcp", "context", "usage", "history",