flat planning repo built-in
This commit is contained in:
parent
c320693ab6
commit
e2e7f594ca
|
|
@ -3,71 +3,73 @@ package p9
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ollie/pkg/agent"
|
|
||||||
"ollie/pkg/tools"
|
"ollie/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
// queuePlanBackend implements tools.PlanBackend by enqueuing each step as a
|
// filePlanBackend implements tools.PlanBackend by writing a markdown checklist
|
||||||
// prompt into the session's queue via agent.Core.Queue. It is used as the
|
// to the planning directory. The file persists across crashes so another
|
||||||
// reasoning_plan fallback when no task_create MCP tool is available.
|
// session can pick up where the previous one left off.
|
||||||
//
|
//
|
||||||
// Steps are enqueued in topological order (blockers before dependents). The
|
// Filename: {sid}--{goal-slugified}__wip.md
|
||||||
// goal description is prepended to the first step for context. Placeholder IDs
|
type filePlanBackend struct {
|
||||||
// ("q1", "q2", …) are returned so the agent can refer to steps by name.
|
dir string // planning directory path
|
||||||
type queuePlanBackend struct {
|
sid string // session ID (used in filename)
|
||||||
core agent.Core
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePlan enqueues each plan step as a prompt. Steps with After dependencies
|
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
// are sorted so blockers are enqueued before the steps that depend on them.
|
|
||||||
// The returned IDs are positional placeholders ("q1", "q2", …).
|
func slugify(s string) string {
|
||||||
func (b *queuePlanBackend) CreatePlan(_ context.Context, goal string, steps []tools.PlanStep) ([]string, string, error) {
|
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)
|
order, err := topoSort(steps)
|
||||||
if err != nil {
|
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))
|
ids := make([]string, len(steps))
|
||||||
for i := range steps {
|
for _, idx := range order {
|
||||||
ids[i] = fmt.Sprintf("q%d", i+1)
|
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 {
|
if err := os.MkdirAll(b.dir, 0755); err != nil {
|
||||||
step := steps[idx]
|
return nil, "", fmt.Errorf("file plan: %w", err)
|
||||||
var sb strings.Builder
|
}
|
||||||
if pos == 0 {
|
path := b.dir + "/" + filename
|
||||||
sb.WriteString("Goal: ")
|
if err := os.WriteFile(path, []byte(md.String()), 0644); err != nil {
|
||||||
sb.WriteString(goal)
|
return nil, "", fmt.Errorf("file plan: %w", err)
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"Plan queued (%d steps). Stop here — do not execute. "+
|
"Plan saved to pl/%s (%d steps). Work through the checklist — mark items [x] as you complete them.",
|
||||||
"The queue will re-enter you with each step in order.",
|
filename, len(steps),
|
||||||
len(steps),
|
|
||||||
)
|
)
|
||||||
return ids, msg, nil
|
return ids, msg, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ type Server struct {
|
||||||
agentsDir string
|
agentsDir string
|
||||||
sessionsDir string
|
sessionsDir string
|
||||||
promptsDir string
|
promptsDir string
|
||||||
|
planDir string // planning directory for /pl
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Server.
|
// New creates a new Server.
|
||||||
|
|
@ -104,9 +105,19 @@ func New() *Server {
|
||||||
agentsDir: agent.DefaultAgentsDir(),
|
agentsDir: agent.DefaultAgentsDir(),
|
||||||
sessionsDir: agent.DefaultSessionsDir(),
|
sessionsDir: agent.DefaultSessionsDir(),
|
||||||
promptsDir: agent.DefaultPromptsDir(),
|
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.
|
// Serve handles a single 9P connection.
|
||||||
func (s *Server) Serve(conn net.Conn) {
|
func (s *Server) Serve(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
@ -148,8 +159,7 @@ func (s *Server) handle(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
||||||
case plan9.Tstat:
|
case plan9.Tstat:
|
||||||
return s.stat(cs, fc)
|
return s.stat(cs, fc)
|
||||||
case plan9.Twstat:
|
case plan9.Twstat:
|
||||||
// Accept and ignore wstat; 9pfuse sends Twstat for O_TRUNC on open files.
|
return s.wstat(cs, fc)
|
||||||
return &plan9.Fcall{Type: plan9.Rwstat, Tag: fc.Tag}
|
|
||||||
case plan9.Tflush:
|
case plan9.Tflush:
|
||||||
// No blocking reads to cancel; always succeed.
|
// No blocking reads to cancel; always succeed.
|
||||||
return &plan9.Fcall{Type: plan9.Rflush, Tag: fc.Tag}
|
return &plan9.Fcall{Type: plan9.Rflush, Tag: fc.Tag}
|
||||||
|
|
@ -190,6 +200,8 @@ func (s *Server) pathType(path string) string {
|
||||||
return "dir"
|
return "dir"
|
||||||
case len(parts) == 1 && parts[0] == "p":
|
case len(parts) == 1 && parts[0] == "p":
|
||||||
return "dir"
|
return "dir"
|
||||||
|
case len(parts) == 1 && parts[0] == "pl":
|
||||||
|
return "dir"
|
||||||
case len(parts) == 1 && parts[0] == "sk":
|
case len(parts) == 1 && parts[0] == "sk":
|
||||||
return "dir"
|
return "dir"
|
||||||
case len(parts) == 1 && parts[0] == "t":
|
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 {
|
if _, err := os.Stat(s.promptsDir + "/" + parts[1]); err == nil {
|
||||||
return "file"
|
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":
|
case len(parts) == 2 && parts[0] == "sk":
|
||||||
name := strings.TrimSuffix(parts[1], ".md")
|
name := strings.TrimSuffix(parts[1], ".md")
|
||||||
if _, err := skills.Read(name); err == nil {
|
if _, err := skills.Read(name); err == nil {
|
||||||
|
|
@ -337,7 +353,7 @@ func (s *Server) create(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
||||||
if !ok {
|
if !ok {
|
||||||
return errFcall(fc, "bad fid")
|
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")
|
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 {
|
if err := os.WriteFile(s.promptsDir+"/"+fc.Name, nil, 0644); err != nil {
|
||||||
return errFcall(fc, err.Error())
|
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":
|
case "/t":
|
||||||
dir := execute.ToolsPath()
|
dir := execute.ToolsPath()
|
||||||
os.MkdirAll(dir, 0755) //nolint:errcheck
|
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}
|
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).
|
// Skill files are served via pkg/skills (reads from disk each time).
|
||||||
if strings.HasPrefix(path, "/sk/") {
|
if strings.HasPrefix(path, "/sk/") {
|
||||||
name := strings.TrimSuffix(pathBase(path), ".md")
|
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}
|
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 {
|
func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
|
||||||
cs.mu.Lock()
|
cs.mu.Lock()
|
||||||
f, ok := cs.fids[fc.Fid]
|
f, ok := cs.fids[fc.Fid]
|
||||||
|
|
@ -666,6 +742,15 @@ func (s *Server) handleWrite(path, input string) {
|
||||||
return
|
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.
|
// Skill file writes: create skill directory and write SKILL.md.
|
||||||
if strings.HasPrefix(path, "/sk/") {
|
if strings.HasPrefix(path, "/sk/") {
|
||||||
name := strings.TrimSuffix(pathBase(path), ".md")
|
name := strings.TrimSuffix(pathBase(path), ".md")
|
||||||
|
|
@ -855,9 +940,17 @@ func (s *Server) createSession(args []string) error {
|
||||||
})
|
})
|
||||||
|
|
||||||
sessID := agent.NewSessionID()
|
sessID := agent.NewSessionID()
|
||||||
fallback := &queuePlanBackend{}
|
fallback := &filePlanBackend{dir: s.planDir, sid: sessID}
|
||||||
env := agent.BuildAgentEnv(cfg, newDisp(), workdir, agent.WithFallbackPlanBackend(fallback))
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
core := agent.NewAgentCore(agent.AgentCoreConfig{
|
core := agent.NewAgentCore(agent.AgentCoreConfig{
|
||||||
Backend: be,
|
Backend: be,
|
||||||
|
|
@ -869,7 +962,6 @@ func (s *Server) createSession(args []string) error {
|
||||||
Env: env,
|
Env: env,
|
||||||
NewDispatcher: newDisp,
|
NewDispatcher: newDisp,
|
||||||
})
|
})
|
||||||
fallback.core = core
|
|
||||||
|
|
||||||
sess := &session{
|
sess := &session{
|
||||||
id: sessID,
|
id: sessID,
|
||||||
|
|
@ -951,6 +1043,7 @@ func (s *Server) readDir(path string, offset uint64, count uint32) []byte {
|
||||||
if path == "/" {
|
if path == "/" {
|
||||||
dirs = append(dirs, makeDir("ctl", "/ctl", false, 0200))
|
dirs = append(dirs, makeDir("ctl", "/ctl", false, 0200))
|
||||||
dirs = append(dirs, makeDir("p", "/p", true, plan9.DMDIR|0755))
|
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("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))
|
||||||
|
|
@ -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))
|
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" {
|
} else if path == "/sk" {
|
||||||
for _, m := range skills.List() {
|
for _, m := range skills.List() {
|
||||||
name := m.Name + ".md"
|
name := m.Name + ".md"
|
||||||
|
|
@ -1057,7 +1162,7 @@ func (s *Server) makeStat(path string) plan9.Dir {
|
||||||
qid.Type = QTDir
|
qid.Type = QTDir
|
||||||
if path == "/t" {
|
if path == "/t" {
|
||||||
mode = plan9.DMDIR | 0777
|
mode = plan9.DMDIR | 0777
|
||||||
} else if path == "/p" {
|
} else if path == "/p" || path == "/pl" {
|
||||||
mode = plan9.DMDIR | 0755
|
mode = plan9.DMDIR | 0755
|
||||||
} else {
|
} else {
|
||||||
mode = plan9.DMDIR | 0555
|
mode = plan9.DMDIR | 0555
|
||||||
|
|
@ -1073,6 +1178,8 @@ func (s *Server) makeStat(path string) plan9.Dir {
|
||||||
default:
|
default:
|
||||||
if strings.HasPrefix(path, "/p/") {
|
if strings.HasPrefix(path, "/p/") {
|
||||||
mode = 0666
|
mode = 0666
|
||||||
|
} else if strings.HasPrefix(path, "/pl/") {
|
||||||
|
mode = 0666
|
||||||
} else if strings.HasPrefix(path, "/sk/") {
|
} else if strings.HasPrefix(path, "/sk/") {
|
||||||
mode = 0666
|
mode = 0666
|
||||||
} else if strings.HasPrefix(path, "/t/") {
|
} 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
|
return dir
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Reference in New Issue