This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-9p/internal/p9/sessionfilestore.go

377 lines
9.1 KiB
Go

package p9
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"ollie/pkg/agent"
"ollie/pkg/backend"
olog "ollie/pkg/log"
"ollie/pkg/store"
)
// 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},
{"fifo.in", 0200},
{"fifo.out", 0444},
{"chat", 0666},
{"offset", 0444},
{"state", 0444},
{"statewait", 0444},
{"backend", 0666},
{"agent", 0666},
{"model", 0666},
{"cwd", 0666},
{"cwdwait", 0444},
{"usage", 0444},
{"usagewait", 0444},
{"ctxsz", 0444},
{"ctxszwait", 0444},
{"models", 0444},
{"systemprompt", 0444},
{"params", 0666},
}
// 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
log *olog.Logger
kill func()
rename func(newID string) error
saveTranscript func([]byte) error
}
func NewSessionFileStore(sess *session, log *olog.Logger, kill func(), rename func(newID string) error, saveTranscript func([]byte) error) *SessionFileStore {
return &SessionFileStore{sess: sess, log: log, kill: kill, rename: rename, saveTranscript: saveTranscript}
}
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 "statewait", "usagewait", "ctxszwait", "cwdwait":
// Blocking reads; size is unknown until resolved.
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 "offset":
return []byte(s.content("offset")), nil
case "fifo.out":
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 "chat":
return s.saveTranscript([]byte(input))
case "prompt":
s.sess.core.Submit(s.sess.ctx, input, s.makePublish())
case "fifo.in":
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())
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())
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())
case "cwd":
if err := s.sess.core.SetCWD(input); err != nil {
return err
}
case "params":
if s.sess.core.IsRunning() {
return fmt.Errorf("cannot change params while agent is running")
}
params, err := parseParams(input, s.sess.core.GenerationParams())
if err != nil {
return err
}
return s.sess.core.SetGenerationParams(params)
}
return nil
}
func formatParams(p backend.GenerationParams) string {
var sb strings.Builder
fmt.Fprintf(&sb, "maxTokens=%d\n", p.MaxTokens)
if p.Temperature != nil {
fmt.Fprintf(&sb, "temperature=%g\n", *p.Temperature)
} else {
fmt.Fprintf(&sb, "temperature=\n")
}
if p.FrequencyPenalty != nil {
fmt.Fprintf(&sb, "frequencyPenalty=%g\n", *p.FrequencyPenalty)
} else {
fmt.Fprintf(&sb, "frequencyPenalty=\n")
}
if p.PresencePenalty != nil {
fmt.Fprintf(&sb, "presencePenalty=%g\n", *p.PresencePenalty)
} else {
fmt.Fprintf(&sb, "presencePenalty=\n")
}
return sb.String()
}
func parseParams(input string, current backend.GenerationParams) (backend.GenerationParams, error) {
p := current
for _, line := range strings.Split(input, "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(v)
switch k {
case "maxTokens":
if v == "" {
p.MaxTokens = 0
} else {
n, err := strconv.Atoi(v)
if err != nil {
return p, fmt.Errorf("invalid maxTokens: %s", v)
}
p.MaxTokens = n
}
case "temperature":
if v == "" {
p.Temperature = nil
} else {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return p, fmt.Errorf("invalid temperature: %s", v)
}
p.Temperature = &f
}
case "frequencyPenalty":
if v == "" {
p.FrequencyPenalty = nil
} else {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return p, fmt.Errorf("invalid frequencyPenalty: %s", v)
}
p.FrequencyPenalty = &f
}
case "presencePenalty":
if v == "" {
p.PresencePenalty = nil
} else {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return p, fmt.Errorf("invalid presencePenalty: %s", v)
}
p.PresencePenalty = &f
}
}
}
return p, 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 "systemprompt":
return s.sess.core.SystemPrompt()
case "offset":
return fmt.Sprintf("%d\n", s.sess.chatOffset)
case "params":
return formatParams(s.sess.core.GenerationParams())
}
return ""
}
// CurrentWaitValue returns the current value for the named *wait file.
func (s *SessionFileStore) CurrentWaitValue(name string) string {
switch name {
case "statewait":
return s.sess.core.State()
case "usagewait":
return s.sess.core.Usage()
case "ctxszwait":
return s.sess.core.CtxSz()
case "cwdwait":
return s.sess.core.CWD()
}
return ""
}
// Wait blocks until the named *wait file's underlying value changes from base,
// then returns the new value. If base is empty, the current value is used.
// Unblocks when connCtx or the session context is cancelled.
func (s *SessionFileStore) Wait(connCtx context.Context, name, base string) ([]byte, error) {
ctx, cancel := context.WithCancel(connCtx)
defer cancel()
// also unblock when the session itself is killed.
context.AfterFunc(s.sess.ctx, cancel)
var field string
switch name {
case "statewait":
field = agent.WatchState
case "usagewait":
field = agent.WatchUsage
case "ctxszwait":
field = agent.WatchCtxSz
case "cwdwait":
field = agent.WatchCWD
default:
return nil, fmt.Errorf("%s: not a wait file", name)
}
if base == "" {
base = s.CurrentWaitValue(name)
}
v, ok := s.sess.core.WaitChange(ctx, field, base)
if !ok {
return nil, nil
}
return []byte(v + "\n"), nil
}
func (s *SessionFileStore) makePublish() func(agent.Event) {
assistantStarted := false
return func(ev agent.Event) {
switch ev.Role {
case "user", "call", "tool":
assistantStarted = false
case "assistant":
if !assistantStarted {
s.sess.appendChat([]byte("assistant: "))
assistantStarted = true
}
}
s.sess.appendChat(store.FormatEvent(ev))
if ev.Role == "user" {
s.sess.mu.Lock()
s.sess.chatOffset = len(s.sess.chatLog)
s.sess.mu.Unlock()
}
}
}
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 {
s.log.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", "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
}