diff --git a/internal/p9/server.go b/internal/p9/server.go index 9f6b6cc..5e964c0 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -17,7 +17,7 @@ // prompt (write) submit a prompt to the agent // enqueue (write) queue a prompt for later execution // 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 // backend (r/w) active backend name // agent (r/w) active agent name @@ -29,6 +29,7 @@ // ctxsz (read) context size vs window // sk/ (dir) skills // t/ (dir) tool scripts +// tr/ (dir) transcripts (ro; write to s/{id}/chat to save) package p9 import ( @@ -41,6 +42,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "9fans.net/go/plan9" "ollie/pkg/agent" @@ -140,27 +142,31 @@ type Server struct { promptStore ReadableStore planStore Store memStore Store - toolStore Store - skillStore Store - sessionStore Store - batchStore *BatchStore + toolStore Store + skillStore Store + sessionStore Store + batchStore *BatchStore + transcriptStore Store } // New creates a new Server. func New() *Server { memDir := defaultMemDir() os.MkdirAll(memDir, 0755) //nolint:errcheck + transcriptDir := defaultTranscriptDir() + os.MkdirAll(transcriptDir, 0755) //nolint:errcheck agentsDir := agent.DefaultAgentsDir() s := &Server{ sessions: make(map[string]*session), agentsDir: agentsDir, - sessionsDir: agent.DefaultSessionsDir(), - agentStore: NewFlatDirStore(agentsDir, 0644), - promptStore: NewFlatDirStore(agent.DefaultPromptsDir(), 0444), - planStore: NewFlatDirStore(defaultPlanDir(), 0644), - memStore: NewFlatDirStore(memDir, 0644), - toolStore: NewToolStore(), - skillStore: NewSkillStore(), + sessionsDir: agent.DefaultSessionsDir(), + agentStore: NewFlatDirStore(agentsDir, 0644), + promptStore: NewFlatDirStore(agent.DefaultPromptsDir(), 0444), + planStore: NewFlatDirStore(defaultPlanDir(), 0644), + memStore: NewFlatDirStore(memDir, 0644), + toolStore: NewToolStore(), + skillStore: NewSkillStore(), + transcriptStore: NewFlatDirStore(transcriptDir, 0444), } s.sessionStore = &SessionStore{srv: s} s.batchStore = NewBatchStore(s) @@ -176,6 +182,15 @@ func defaultPlanDir() string { 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. func defaultMemDir() string { if p := os.Getenv("OLLIE_MEMORY_PATH"); p != "" { @@ -197,6 +212,10 @@ func (s *Server) sessionFileStore(sessID string) (*SessionFileStore, bool) { sess, func() { s.killSession(sessID) }, 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 } @@ -314,6 +333,12 @@ func (s *Server) pathType(path string) string { return "dir" case len(parts) == 1 && parts[0] == "t": 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": return "dir" 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) } + // 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} if strings.HasPrefix(path, "/s/") { 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("sk", "/sk", true, plan9.DMDIR|0555)) dirs = append(dirs, makeDir("t", "/t", true, plan9.DMDIR|0777)) + dirs = append(dirs, makeDir("tr", "/tr", true, plan9.DMDIR|0555)) } else if path == "/a" { entries, _ := s.agentStore.List() 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)) } + } 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" { entries, _ := s.batchStore.List() 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 { 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": if content, err := s.batchStore.Get(base); err == nil { dir.Length = uint64(len(content)) diff --git a/internal/p9/sessionfilestore.go b/internal/p9/sessionfilestore.go index 85070c5..8d96207 100644 --- a/internal/p9/sessionfilestore.go +++ b/internal/p9/sessionfilestore.go @@ -18,7 +18,7 @@ var sessionFileList = []struct { {"prompt", 0200}, {"enqueue", 0200}, {"dequeue", 0444}, - {"chat", 0444}, + {"chat", 0666}, {"offset", 0444}, {"state", 0444}, {"backend", 0666}, @@ -36,13 +36,14 @@ var sessionFileList = []struct { // 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 + sess *session + kill func() + rename func(newID string) error + saveTranscript func([]byte) error } -func NewSessionFileStore(sess *session, kill func(), rename func(newID string) error) *SessionFileStore { - return &SessionFileStore{sess: sess, kill: kill, rename: rename} +func NewSessionFileStore(sess *session, kill func(), rename func(newID string) error, saveTranscript func([]byte) error) *SessionFileStore { + return &SessionFileStore{sess: sess, kill: kill, rename: rename, saveTranscript: saveTranscript} } func (s *SessionFileStore) List() ([]os.DirEntry, error) { @@ -103,6 +104,9 @@ func (s *SessionFileStore) Put(name string, data []byte) error { return nil } switch name { + case "chat": + return s.saveTranscript([]byte(input)) + case "prompt": s.sess.core.Submit(s.sess.ctx, input, s.makePublish()) s.sess.trackMutable() @@ -221,6 +225,12 @@ func (s *SessionFileStore) handleCtl(input string) error { 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", "agents", "agent", "sessions", "cwd", "skills", "tools", "mcp", "context", "usage", "history",