diff --git a/pkg/tools/memory/server.go b/pkg/tools/memory/server.go deleted file mode 100644 index 5ae0ddc..0000000 --- a/pkg/tools/memory/server.go +++ /dev/null @@ -1,256 +0,0 @@ -package memory - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "time" - - "ollie/pkg/tools" -) - -// Decl returns a factory for a memory Server. -func Decl() func() tools.Server { - return func() tools.Server { return &Server{dir: flatDirPath()} } -} - -// Server implements tools.Server for memory_remember and memory_recall. -type Server struct { - dir string -} - -// ListTools implements tools.Server. -func (s *Server) ListTools() ([]tools.ToolInfo, error) { - return []tools.ToolInfo{ToolRemember, ToolRecall}, nil -} - -// CallTool implements tools.Server. -func (s *Server) CallTool(_ context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { - var text string - var err error - switch tool { - case ToolRemember.Name: - text, err = s.remember(args) - case ToolRecall.Name: - text, err = s.recall(args) - default: - text = "error: unknown tool: " + tool - } - if err != nil { - text = "error: " + err.Error() - } - result, _ := json.Marshal(map[string]any{ - "content": []map[string]string{{"type": "text", "text": text}}, - }) - return result, nil -} - -// Close implements tools.Server (no-op). -func (s *Server) Close() {} - -func (s *Server) remember(args json.RawMessage) (string, error) { - var a struct { - Title string `json:"title"` - Tags []string `json:"tags"` - Body string `json:"body"` - } - if err := json.Unmarshal(args, &a); err != nil { - return "", fmt.Errorf("bad args: %w", err) - } - if a.Title == "" { - return "", fmt.Errorf("'title' is required") - } - if len(a.Tags) == 0 { - return "", fmt.Errorf("'tags' must be non-empty") - } - if a.Body == "" { - return "", fmt.Errorf("'body' is required") - } - - if err := os.MkdirAll(s.dir, 0700); err != nil { - return "", fmt.Errorf("memory dir: %w", err) - } - - slug := slugify(a.Title) - ts := time.Now().Format("20060102T150405") - name := ts + "--" + slug + "__" + strings.Join(a.Tags, "_") + ".md" - - content := buildNote(a.Title, a.Tags, a.Body) - if err := os.WriteFile(filepath.Join(s.dir, name), []byte(content), 0600); err != nil { - return "", fmt.Errorf("write memory: %w", err) - } - return name, nil -} - -func (s *Server) recall(args json.RawMessage) (string, error) { - var a struct { - Query string `json:"query"` - } - if err := json.Unmarshal(args, &a); err != nil { - return "", fmt.Errorf("bad args: %w", err) - } - if a.Query == "" { - return "", fmt.Errorf("'query' is required") - } - - entries, err := os.ReadDir(s.dir) - if err != nil { - if os.IsNotExist(err) { - return "no memories found matching: " + a.Query, nil - } - return "", fmt.Errorf("read memory dir: %w", err) - } - - q := strings.ToLower(a.Query) - var results []string - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { - continue - } - if !strings.Contains(strings.ToLower(e.Name()), q) { - continue - } - data, err := os.ReadFile(filepath.Join(s.dir, e.Name())) - if err != nil { - continue - } - results = append(results, fmt.Sprintf("file: %s\n\n%s", e.Name(), strings.TrimSpace(string(data)))) - } - - if len(results) == 0 { - return "no memories found matching: " + a.Query, nil - } - return strings.Join(results, "\n\n---\n\n"), nil -} - -// flatDirPath resolves the memory directory: OLLIE_MEMORY_PATH env var or -// ~/.local/share/ollie/memory/. -func flatDirPath() string { - if d := os.Getenv("OLLIE_MEMORY_PATH"); d != "" { - return d - } - home, _ := os.UserHomeDir() - return filepath.Join(home, ".local", "share", "ollie", "memory") -} - -var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`) - -func slugify(s string) string { - s = strings.ToLower(s) - s = nonAlnum.ReplaceAllString(s, "-") - return strings.Trim(s, "-") -} - -func buildNote(title string, tags []string, body string) string { - var sb strings.Builder - fmt.Fprintf(&sb, "# %s\n\n", title) - if len(tags) > 0 { - fmt.Fprintf(&sb, "tags: %s\n\n", strings.Join(tags, ", ")) - } - sb.WriteString(strings.TrimSpace(body)) - sb.WriteByte('\n') - return sb.String() -} - -var ToolRemember = tools.ToolInfo{ - Name: "memory_remember", - Description: `Save a persistent memory across sessions. - -Usage: -- Store key facts, decisions, preferences, or context -- Memories persist for future recall -- Use descriptive titles and specific tags -- Keep body concise and actionable - -Title Requirements: -- Descriptive, stands alone -- Answers "what is this about?" -- Minimum 3 characters - -Tag Guidelines: -- Specific, stable terms (not generic) -- Concrete topics, people, projects -- Influences recall accuracy -- Minimum 2 characters per tag -- Unique within memory - -Body Content: -- Key facts and essential context -- Decisions made or preferences -- Not raw transcripts or chatter -- Minimum 8 characters - -Examples: -- Project structure decisions -- User preferences or constraints -- Code patterns or conventions -- Important discoveries or insights`, - InputSchema: json.RawMessage(`{ - "type": "object", - "additionalProperties": false, - "required": ["title", "tags", "body"], - "properties": { - "title": { - "type": "string", - "minLength": 3, - "description": "Descriptive title that can stand alone and answer 'what is this about?'" - }, - "tags": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 2 - }, - "description": "Specific, stable recall tags you would search for later. Prefer concrete topics, people, projects, decisions, or preferences." - }, - "body": { - "type": "string", - "minLength": 8, - "description": "Concise memory content: key facts, decisions, preferences, and essential context. Not a transcript." - } - } - }`), -} - -var ToolRecall = tools.ToolInfo{ - Name: "memory_recall", - Description: `Search previously saved memories. - -Usage: -- Find memories by keyword search -- Searches titles and tags only (not body content) -- Use specific, literal terms from original tags/titles -- Returns matching memories with full details - -Search Tips: -- Use exact tags or project names -- Prefer specific terms over vague descriptions -- Minimum query length: 2 characters -- Case-insensitive matching - -Examples: -- Project name: "ollie" or "core" -- Topic: "authentication" or "database" -- Person: "user" or specific username -- Decision type: "architecture" or "api-design" - -Note: Body content is not searched - only titles and tags.`, - InputSchema: json.RawMessage(`{ - "type": "object", - "additionalProperties": false, - "required": ["query"], - "properties": { - "query": { - "type": "string", - "minLength": 2, - "description": "Short keyword or phrase to search for in memory titles and tags. Prefer literal terms such as names, projects, topics, or exact tags." - } - } - }`), -} diff --git a/pkg/tools/task/server.go b/pkg/tools/task/server.go deleted file mode 100644 index b38753a..0000000 --- a/pkg/tools/task/server.go +++ /dev/null @@ -1,318 +0,0 @@ -package task - -import ( - "context" - "crypto/rand" - "encoding/json" - "fmt" - "os" - "path/filepath" - "regexp" - "strings" - "time" - - "ollie/pkg/tools" -) - -// Decl returns a factory for a task Server rooted at planDir. -func Decl(planDir string) func() tools.Server { - return func() tools.Server { return &Server{planDir: planDir} } -} - -// Server implements tools.Server for task_plan and task_complete. -type Server struct { - planDir string -} - -// ListTools implements tools.Server. -func (s *Server) ListTools() ([]tools.ToolInfo, error) { - return []tools.ToolInfo{ToolPlan, ToolComplete}, nil -} - -// CallTool implements tools.Server. -func (s *Server) CallTool(_ context.Context, tool string, args json.RawMessage) (json.RawMessage, error) { - var text string - var err error - switch tool { - case ToolPlan.Name: - text, err = s.plan(args) - case ToolComplete.Name: - text, err = s.complete(args) - default: - text = "error: unknown tool: " + tool - } - if err != nil { - text = "error: " + err.Error() - } - result, _ := json.Marshal(map[string]any{ - "content": []map[string]string{{"type": "text", "text": text}}, - }) - return result, nil -} - -// Close implements tools.Server (no-op). -func (s *Server) Close() {} - -type planStep struct { - Title string `json:"title"` - Body string `json:"description"` - After []int `json:"depends_on"` -} - -func (s *Server) plan(args json.RawMessage) (string, error) { - var a struct { - Goal string `json:"goal"` - Steps []planStep `json:"steps"` - } - if err := json.Unmarshal(args, &a); err != nil { - return "", fmt.Errorf("bad args: %w", err) - } - if a.Goal == "" { - return "", fmt.Errorf("'goal' is required") - } - if len(a.Steps) == 0 { - return "", fmt.Errorf("'steps' must not be empty") - } - - order, err := topoSort(a.Steps) - if err != nil { - return "", err - } - - slug := slugify(a.Goal) - uid := make([]byte, 4) - rand.Read(uid) //nolint:errcheck - ts := time.Now().Format("20060102T150405") - filename := ts + "_" + fmt.Sprintf("%08x", uid) + "--" + slug + "__todo.md" - - var md strings.Builder - fmt.Fprintf(&md, "# %s\n\n", a.Goal) - for _, idx := range order { - step := a.Steps[idx] - fmt.Fprintf(&md, "- [ ] %s\n", step.Title) - if step.Body != "" { - for line := range strings.SplitSeq(step.Body, "\n") { - fmt.Fprintf(&md, " %s\n", line) - } - } - } - - if err := os.MkdirAll(s.planDir, 0755); err != nil { - return "", fmt.Errorf("plan dir: %w", err) - } - if err := os.WriteFile(filepath.Join(s.planDir, filename), []byte(md.String()), 0644); err != nil { - return "", fmt.Errorf("write plan: %w", err) - } - - return fmt.Sprintf( - "Plan saved to pl/%s (%d steps). Rename __todo → __wip when you start work, use task_complete to mark steps done, rename __wip → __done when the goal is realized.", - filename, len(a.Steps), - ), nil -} - -func (s *Server) complete(args json.RawMessage) (string, error) { - var a struct { - File string `json:"file"` - Step string `json:"step"` - } - if err := json.Unmarshal(args, &a); err != nil { - return "", fmt.Errorf("bad args: %w", err) - } - if a.File == "" { - return "", fmt.Errorf("'file' is required") - } - if a.Step == "" { - return "", fmt.Errorf("'step' is required") - } - - path := filepath.Join(s.planDir, a.File) - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("read plan: %w", err) - } - - lines := strings.Split(string(data), "\n") - needle := strings.ToLower(strings.TrimSpace(a.Step)) - matched := false - for i, line := range lines { - if !strings.HasPrefix(line, "- [ ] ") { - continue - } - title := strings.ToLower(strings.TrimPrefix(line, "- [ ] ")) - if strings.Contains(title, needle) { - lines[i] = "- [x] " + strings.TrimPrefix(line, "- [ ] ") - matched = true - break - } - } - if !matched { - return "", fmt.Errorf("no unchecked step matching %q in %s", a.Step, a.File) - } - - if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644); err != nil { - return "", fmt.Errorf("write plan: %w", err) - } - - var remaining []string - for _, line := range lines { - if strings.HasPrefix(line, "- [ ] ") { - remaining = append(remaining, strings.TrimPrefix(line, "- [ ] ")) - } - } - - if len(remaining) == 0 { - return "step marked complete. all steps done.", nil - } - return fmt.Sprintf("step marked complete. remaining steps:\n- %s", strings.Join(remaining, "\n- ")), nil -} - -func topoSort(steps []planStep) ([]int, error) { - n := len(steps) - visited := make([]bool, n) - result := make([]int, 0, n) - - var visit func(i int, path []bool) error - visit = func(i int, path []bool) error { - if path[i] { - return fmt.Errorf("dependency cycle at step %d", i) - } - if visited[i] { - return nil - } - path[i] = true - for _, dep := range steps[i].After { - if dep < 0 || dep >= n { - continue - } - if err := visit(dep, path); err != nil { - return err - } - } - path[i] = false - visited[i] = true - result = append(result, i) - return nil - } - - for i := range steps { - if err := visit(i, make([]bool, n)); err != nil { - return nil, err - } - } - return result, nil -} - -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 -} - -var ToolPlan = tools.ToolInfo{ - Name: "task_plan", - Description: `Create an execution plan for multi-step tasks. - -Usage: -- Use for non-trivial tasks requiring multiple steps -- Plan before acting when order matters or there are dependencies -- Saves a markdown checklist to the plan directory (pl/) -- Execute steps sequentially based on dependencies - -Plan Structure: -- goal: top-level objective -- steps: array of ordered steps -- Each step: title, description, depends_on, acceptance_criteria -- depends_on: earlier step indexes (0-based) -- Execute steps only after dependencies complete - -Plan Lifecycle: -- Plans are saved as __todo.md files in pl/ -- Rename __todo → __wip when you begin work -- Use task_complete after each step finishes -- Rename __wip → __done when the goal is realized - -When to Plan: -- Multiple steps required -- Steps have dependencies -- Execution order is not obvious - -Re-plan If: -- A step fails -- New information changes constraints -- Better decomposition is possible`, - InputSchema: json.RawMessage(`{ - "type": "object", - "additionalProperties": false, - "required": ["goal", "steps"], - "properties": { - "goal": { - "type": "string", - "description": "Top-level goal or objective." - }, - "steps": { - "type": "array", - "description": "Ordered plan steps. Dependencies may only reference earlier 0-based step indexes.", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["title"], - "properties": { - "title": { - "type": "string", - "description": "Short, outcome-oriented step title." - }, - "description": { - "type": "string", - "description": "Optional implementation detail for the step." - }, - "depends_on": { - "type": "array", - "description": "0-based indexes of earlier steps that must complete before this one.", - "items": {"type": "integer", "minimum": 0}, - "default": [] - }, - "acceptance_criteria": { - "type": "string", - "description": "How to tell the step is complete or successful." - } - } - } - } - } - }`), -} - -var ToolComplete = tools.ToolInfo{ - Name: "task_complete", - Description: `Mark a plan step as complete. - -Finds the first unchecked step whose title contains the given string -(case-insensitive) and marks it [x]. Returns the remaining unchecked steps -so you always know what's left. - -Call this immediately after finishing each step — before starting the next.`, - InputSchema: json.RawMessage(`{ - "type": "object", - "additionalProperties": false, - "required": ["file", "step"], - "properties": { - "file": { - "type": "string", - "description": "Plan filename as returned by task_plan (e.g. '20240101T120000_ab12cd34--my-goal__wip.md')." - }, - "step": { - "type": "string", - "description": "Title (or substring) of the step to mark complete. Matched case-insensitively against unchecked steps." - } - } - }`), -}