flat planning repo built-in
This commit is contained in:
parent
c320693ab6
commit
e2e7f594ca
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Reference in New Issue