diff --git a/internal/p9/plan.go b/internal/p9/plan.go index d293302..7d026fe 100644 --- a/internal/p9/plan.go +++ b/internal/p9/plan.go @@ -3,71 +3,73 @@ package p9 import ( "context" "fmt" + "os" + "regexp" "strings" - "ollie/pkg/agent" "ollie/pkg/tools" ) -// queuePlanBackend implements tools.PlanBackend by enqueuing each step as a -// prompt into the session's queue via agent.Core.Queue. It is used as the -// reasoning_plan fallback when no task_create MCP tool is available. +// filePlanBackend implements tools.PlanBackend by writing a markdown checklist +// to the planning directory. The file persists across crashes so another +// session can pick up where the previous one left off. // -// Steps are enqueued in topological order (blockers before dependents). The -// goal description is prepended to the first step for context. Placeholder IDs -// ("q1", "q2", …) are returned so the agent can refer to steps by name. -type queuePlanBackend struct { - core agent.Core +// Filename: {sid}--{goal-slugified}__wip.md +type filePlanBackend struct { + dir string // planning directory path + sid string // session ID (used in filename) } -// CreatePlan enqueues each plan step as a prompt. Steps with After dependencies -// are sorted so blockers are enqueued before the steps that depend on them. -// The returned IDs are positional placeholders ("q1", "q2", …). -func (b *queuePlanBackend) CreatePlan(_ context.Context, goal string, steps []tools.PlanStep) ([]string, string, error) { +var slugRe = regexp.MustCompile(`[^a-z0-9]+`) + +func slugify(s string) string { + s = strings.ToLower(s) + s = slugRe.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if len(s) > 60 { + s = s[:60] + if i := strings.LastIndex(s, "-"); i > 20 { + s = s[:i] + } + } + return s +} + +// CreatePlan writes a markdown checklist to disk and returns step IDs. +func (b *filePlanBackend) CreatePlan(_ context.Context, goal string, steps []tools.PlanStep) ([]string, string, error) { order, err := topoSort(steps) if err != nil { - return nil, "", fmt.Errorf("queue plan: %w", err) + return nil, "", fmt.Errorf("file plan: %w", err) } + slug := slugify(goal) + filename := b.sid + "--" + slug + "__wip.md" + + var md strings.Builder + fmt.Fprintf(&md, "# %s\n\n", goal) ids := make([]string, len(steps)) - for i := range steps { - ids[i] = fmt.Sprintf("q%d", i+1) + for _, idx := range order { + step := steps[idx] + ids[idx] = fmt.Sprintf("s%d", idx+1) + fmt.Fprintf(&md, "- [ ] %s\n", step.Title) + if step.Body != "" { + for _, line := range strings.Split(step.Body, "\n") { + fmt.Fprintf(&md, " %s\n", line) + } + } } - for pos, idx := range order { - step := steps[idx] - var sb strings.Builder - if pos == 0 { - sb.WriteString("Goal: ") - sb.WriteString(goal) - sb.WriteString("\n\n") - } - sb.WriteString("Step ") - sb.WriteString(ids[idx]) - sb.WriteString(": ") - sb.WriteString(step.Title) - if step.Body != "" { - sb.WriteString("\n") - sb.WriteString(step.Body) - } - if len(step.After) > 0 { - sb.WriteString("\n(after: ") - for i, dep := range step.After { - if i > 0 { - sb.WriteString(", ") - } - sb.WriteString(ids[dep]) - } - sb.WriteString(")") - } - - b.core.Queue(sb.String()) + if err := os.MkdirAll(b.dir, 0755); err != nil { + return nil, "", fmt.Errorf("file plan: %w", err) + } + path := b.dir + "/" + filename + if err := os.WriteFile(path, []byte(md.String()), 0644); err != nil { + return nil, "", fmt.Errorf("file plan: %w", err) } msg := fmt.Sprintf( - "Plan queued (%d steps). Stop here — do not execute. "+ - "The queue will re-enter you with each step in order.", - len(steps), + "Plan saved to pl/%s (%d steps). Work through the checklist — mark items [x] as you complete them.", + filename, len(steps), ) return ids, msg, nil } diff --git a/internal/p9/server.go b/internal/p9/server.go index 0caa6ec..4d20b6c 100644 --- a/internal/p9/server.go +++ b/internal/p9/server.go @@ -95,6 +95,7 @@ type Server struct { agentsDir string sessionsDir string promptsDir string + planDir string // planning directory for /pl } // New creates a new Server. @@ -104,9 +105,19 @@ func New() *Server { agentsDir: agent.DefaultAgentsDir(), sessionsDir: agent.DefaultSessionsDir(), promptsDir: agent.DefaultPromptsDir(), + planDir: defaultPlanDir(), } } +// defaultPlanDir returns the planning directory from OLLIE_PLAN_PATH or the default. +func defaultPlanDir() string { + if p := os.Getenv("OLLIE_PLAN_PATH"); p != "" { + return p + } + home, _ := os.UserHomeDir() + return home + "/.config/ollie/planning" +} + // Serve handles a single 9P connection. func (s *Server) Serve(conn net.Conn) { defer conn.Close() @@ -148,8 +159,7 @@ func (s *Server) handle(cs *connState, fc *plan9.Fcall) *plan9.Fcall { case plan9.Tstat: return s.stat(cs, fc) case plan9.Twstat: - // Accept and ignore wstat; 9pfuse sends Twstat for O_TRUNC on open files. - return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} + return s.wstat(cs, fc) case plan9.Tflush: // No blocking reads to cancel; always succeed. return &plan9.Fcall{Type: plan9.Rflush, Tag: fc.Tag} @@ -190,6 +200,8 @@ func (s *Server) pathType(path string) string { return "dir" case len(parts) == 1 && parts[0] == "p": return "dir" + case len(parts) == 1 && parts[0] == "pl": + return "dir" case len(parts) == 1 && parts[0] == "sk": return "dir" case len(parts) == 1 && parts[0] == "t": @@ -198,6 +210,10 @@ func (s *Server) pathType(path string) string { if _, err := os.Stat(s.promptsDir + "/" + parts[1]); err == nil { return "file" } + case len(parts) == 2 && parts[0] == "pl": + if _, err := os.Stat(s.planDir + "/" + parts[1]); err == nil { + return "file" + } case len(parts) == 2 && parts[0] == "sk": name := strings.TrimSuffix(parts[1], ".md") if _, err := skills.Read(name); err == nil { @@ -337,7 +353,7 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if !ok { return errFcall(fc, "bad fid") } - if f.path != "/p" && f.path != "/sk" && f.path != "/t" { + if f.path != "/p" && f.path != "/pl" && f.path != "/sk" && f.path != "/t" { return errFcall(fc, "create not supported") } @@ -351,6 +367,11 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall { if err := os.WriteFile(s.promptsDir+"/"+fc.Name, nil, 0644); 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 { + return errFcall(fc, err.Error()) + } case "/t": dir := execute.ToolsPath() os.MkdirAll(dir, 0755) //nolint:errcheck @@ -419,6 +440,24 @@ 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} } + // Plan files are served from disk. + if strings.HasPrefix(path, "/pl/") { + content, err := os.ReadFile(s.planDir + "/" + pathBase(path)) + if err != nil { + return errFcall(fc, err.Error()) + } + var data []byte + off := int(fc.Offset) + if off < len(content) { + end := off + int(fc.Count) + if end > len(content) { + end = len(content) + } + data = content[off:end] + } + return &plan9.Fcall{Type: plan9.Rread, Tag: fc.Tag, Count: uint32(len(data)), Data: data} + } + // Skill files are served via pkg/skills (reads from disk each time). if strings.HasPrefix(path, "/sk/") { name := strings.TrimSuffix(pathBase(path), ".md") @@ -630,6 +669,43 @@ func (s *Server) stat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return &plan9.Fcall{Type: plan9.Rstat, Tag: fc.Tag, Stat: stat} } +func (s *Server) wstat(cs *connState, fc *plan9.Fcall) *plan9.Fcall { + cs.mu.Lock() + f, ok := cs.fids[fc.Fid] + cs.mu.Unlock() + if !ok { + return errFcall(fc, "bad fid") + } + + // Only /pl files support rename via wstat. + if !strings.HasPrefix(f.path, "/pl/") { + return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} + } + + // Parse the new Dir from the stat bytes. + newDir, err := plan9.UnmarshalDir(fc.Stat) + if err != nil { + // Some clients send minimal wstat (e.g. truncate); accept silently. + return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} + } + + // If the name changed, rename the file on disk. + oldName := pathBase(f.path) + if newDir.Name != "" && newDir.Name != oldName { + oldPath := s.planDir + "/" + oldName + newPath := s.planDir + "/" + newDir.Name + if err := os.Rename(oldPath, newPath); err != nil { + return errFcall(fc, err.Error()) + } + cs.mu.Lock() + f.path = "/pl/" + newDir.Name + f.qid.Path = qidPath(f.path) + cs.mu.Unlock() + } + + return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag} +} + func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() f, ok := cs.fids[fc.Fid] @@ -666,6 +742,15 @@ func (s *Server) handleWrite(path, input string) { return } + // Plan file writes go directly to disk. + if strings.HasPrefix(path, "/pl/") { + os.MkdirAll(s.planDir, 0755) //nolint:errcheck + if err := os.WriteFile(s.planDir+"/"+pathBase(path), []byte(input), 0644); err != nil { + fmt.Fprintf(os.Stderr, "olliesrv: write plan: %v\n", err) + } + return + } + // Skill file writes: create skill directory and write SKILL.md. if strings.HasPrefix(path, "/sk/") { name := strings.TrimSuffix(pathBase(path), ".md") @@ -855,9 +940,17 @@ func (s *Server) createSession(args []string) error { }) sessID := agent.NewSessionID() - fallback := &queuePlanBackend{} + fallback := &filePlanBackend{dir: s.planDir, sid: sessID} env := agent.BuildAgentEnv(cfg, newDisp(), workdir, agent.WithFallbackPlanBackend(fallback)) + // Inject per-session env vars into the execute server. + if srv, ok := env.Dispatcher().GetServer("execute"); ok { + if es, ok := srv.(tools.EnvSetter); ok { + es.SetEnv("OLLIE_SESSION_ID", sessID) + es.SetEnv("OLLIE_PLAN_PATH", s.planDir) + } + } + ctx, cancel := context.WithCancel(context.Background()) core := agent.NewAgentCore(agent.AgentCoreConfig{ Backend: be, @@ -869,7 +962,6 @@ func (s *Server) createSession(args []string) error { Env: env, NewDispatcher: newDisp, }) - fallback.core = core sess := &session{ id: sessID, @@ -951,6 +1043,7 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { if path == "/" { dirs = append(dirs, makeDir("ctl", "/ctl", false, 0200)) dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0755)) + 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)) @@ -961,6 +1054,18 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte { dirs = append(dirs, makeDir(e.Name(), "/p/"+e.Name(), false, 0666)) } } + } else if path == "/pl" { + entries, _ := os.ReadDir(s.planDir) + for _, e := range entries { + if !e.IsDir() { + d := makeDir(e.Name(), "/pl/"+e.Name(), false, 0666) + 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 == "/sk" { for _, m := range skills.List() { name := m.Name + ".md" @@ -1057,7 +1162,7 @@ func (s *Server) makeStat(path string) plan9.Dir { qid.Type = QTDir if path == "/t" { mode = plan9.DMDIR | 0777 - } else if path == "/p" { + } else if path == "/p" || path == "/pl" { mode = plan9.DMDIR | 0755 } else { mode = plan9.DMDIR | 0555 @@ -1073,6 +1178,8 @@ func (s *Server) makeStat(path string) plan9.Dir { default: if strings.HasPrefix(path, "/p/") { mode = 0666 + } else if strings.HasPrefix(path, "/pl/") { + mode = 0666 } else if strings.HasPrefix(path, "/sk/") { mode = 0666 } else if strings.HasPrefix(path, "/t/") { @@ -1117,5 +1224,14 @@ func (s *Server) makeStat(path string) plan9.Dir { } } + // For plan files, report real size and timestamps from disk. + if strings.HasPrefix(path, "/pl/") { + if info, err := os.Stat(s.planDir + "/" + base); err == nil { + dir.Length = uint64(info.Size()) + dir.Atime = uint32(info.ModTime().Unix()) + dir.Mtime = uint32(info.ModTime().Unix()) + } + } + return dir }