Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/super-ollama/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func main() {
newAskCmd(),
newChatCmd(),
newStubCmd("email", "Email assistant (coming in a later phase)"),
newStubCmd("todo", "TODO manager (coming in a later phase)"),
newTodoCmd(),
newStubCmd("snap", "Screenshot capture (coming in a later phase)"),
newStubCmd("learn", "Learning-loop re-index (coming in a later phase)"),
newConfigShowCmd(),
Expand Down
267 changes: 267 additions & 0 deletions cmd/super-ollama/todo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
package main

import (
"context"
"fmt"
"os/signal"
"strings"
"syscall"

"github.com/spf13/cobra"

"github.com/ollama/ollama/internal/config"
"github.com/ollama/ollama/internal/engine"
"github.com/ollama/ollama/internal/todo"
"github.com/ollama/ollama/internal/ui"
)

// newTodoCmd builds the `todo` command tree.
func newTodoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "todo",
Short: "Manage your ~/TODO.md from the terminal",
}
cmd.AddCommand(
todoAddCmd(),
todoDoneCmd(),
todoListCmd(),
todoSearchCmd(),
todoSuggestCmd(),
todoTriageCmd(),
)
return cmd
}

// loadTodo reads the configured TODO file and returns both the list and the path.
func loadTodo() (*todo.List, string, string, error) {
cfg, err := config.Load()
if err != nil {
return nil, "", "", err
}
list, err := todo.LoadFile(cfg.TodoPath)
return list, cfg.TodoPath, cfg.DBPath, err
}

// saveTodo writes the list and rebuilds the SQLite index.
func saveTodo(path, dbPath string, list *todo.List) error {
if err := todo.SaveFile(path, list); err != nil {
return err
}
db, err := todo.OpenStore(dbPath)
if err != nil {
// Non-fatal: the Markdown file is the source of truth.
ui.Eprintf("warning: could not open index DB: %v\n", err)
return nil
}
defer db.Close()
if err := todo.RebuildIndex(db, list); err != nil {
ui.Eprintf("warning: could not rebuild index: %v\n", err)
}
return nil
}

// printItem formats a single item for console output.
func printItem(item *todo.Item) {
check := "[ ]"
if item.Done {
check = "[x]"
}
ui.Printf("%s [%d] %s\n", check, item.ID, item.Text)
}

// ── add ───────────────────────────────────────────────────────────────────────

func todoAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add <text>",
Short: "Add a new task to the active list",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
text := strings.Join(args, " ")
list, path, dbPath, err := loadTodo()
if err != nil {
return err
}
item := todo.AddItem(list, text)
if err := saveTodo(path, dbPath, list); err != nil {
return err
}
ui.Printf("Added [%d] %s\n", item.ID, item.Text)
return nil
},
}
}

// ── done ──────────────────────────────────────────────────────────────────────

func todoDoneCmd() *cobra.Command {
return &cobra.Command{
Use: "done <id-or-partial-text>",
Short: "Mark a task as done (by ID or partial text match)",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := strings.Join(args, " ")
list, path, dbPath, err := loadTodo()
if err != nil {
return err
}
item := todo.MarkDone(list, query)
if item == nil {
return fmt.Errorf("no active task matched %q", query)
}
if err := saveTodo(path, dbPath, list); err != nil {
return err
}
ui.Printf("Done [%d] %s\n", item.ID, item.Text)
return nil
},
}
}

// ── list ──────────────────────────────────────────────────────────────────────

func todoListCmd() *cobra.Command {
var (
all bool
tag string
)
cmd := &cobra.Command{
Use: "list",
Short: "List tasks",
RunE: func(cmd *cobra.Command, args []string) error {
list, _, _, err := loadTodo()
if err != nil {
return err
}
items := todo.FilterByTag(list, tag, all)
if len(items) == 0 {
ui.Println("No tasks found.")
return nil
}
for _, item := range items {
printItem(item)
}
return nil
},
}
cmd.Flags().BoolVar(&all, "all", false, "include done tasks")
cmd.Flags().StringVar(&tag, "tag", "", "filter by tag (e.g. work)")
return cmd
}

// ── search ────────────────────────────────────────────────────────────────────

func todoSearchCmd() *cobra.Command {
return &cobra.Command{
Use: "search <query>",
Short: "Search tasks by text",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
query := strings.Join(args, " ")
list, _, _, err := loadTodo()
if err != nil {
return err
}
results := todo.SearchItems(list, query)
if len(results) == 0 {
ui.Printf("No tasks matched %q.\n", query)
return nil
}
for _, item := range results {
printItem(item)
}
return nil
},
}
}

// ── suggest ───────────────────────────────────────────────────────────────────

const suggestPromptFmt = `Given this list of tasks, rank them by urgency and importance.
Return a numbered list with a one-sentence reason per item.

Tasks:
%s`

func todoSuggestCmd() *cobra.Command {
return &cobra.Command{
Use: "suggest",
Short: "AI ranks active tasks by inferred urgency",
RunE: func(cmd *cobra.Command, args []string) error {
list, _, _, err := loadTodo()
if err != nil {
return err
}
if len(list.Active) == 0 {
ui.Println("No active tasks to suggest.")
return nil
}

model, err := resolveModel()
if err != nil {
return err
}
prompt := fmt.Sprintf(suggestPromptFmt, todo.ActiveSummary(list))

ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

eng, err := engine.New(context.Background())
if err != nil {
return err
}
defer eng.Close()

out, err := eng.Generate(ctx, model, prompt, nil)
if err != nil {
return err
}
ui.Println(out)
return nil
},
}
}

// ── triage ────────────────────────────────────────────────────────────────────

const triagePromptFmt = `You are a personal productivity assistant.
Read the following TODO list and propose a reordered version of the active tasks,
placing the most important and urgent ones first.
Provide a brief explanation for your ordering.

%s`

func todoTriageCmd() *cobra.Command {
return &cobra.Command{
Use: "triage",
Short: "AI reads the full list and proposes a reordered version",
RunE: func(cmd *cobra.Command, args []string) error {
list, _, _, err := loadTodo()
if err != nil {
return err
}

model, err := resolveModel()
if err != nil {
return err
}
prompt := fmt.Sprintf(triagePromptFmt, todo.FullSummary(list))

ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

eng, err := engine.New(context.Background())
if err != nil {
return err
}
defer eng.Close()

out, err := eng.Generate(ctx, model, prompt, nil)
if err != nil {
return err
}
ui.Println(out)
return nil
},
}
}
39 changes: 38 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const defaultModel = "gemma3:1b"
// Config holds super-ollama runtime settings from config.toml.
type Config struct {
DefaultModel string `toml:"default_model"`
// TodoPath is the path to the Markdown TODO file.
// Defaults to ~/TODO.md. Supports ~ expansion.
TodoPath string `toml:"todo_path"`
// DBPath is the path to the SQLite index database.
// Defaults to ~/.super-ollama/data.db. Supports ~ expansion.
DBPath string `toml:"db_path"`
}

// ResolvedPath returns the path to the active config file.
Expand All @@ -36,16 +42,47 @@ func Load() (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return Config{DefaultModel: defaultModel}, nil
return applyDefaults(Config{})
}
return Config{}, err
}
var c Config
if err := toml.Unmarshal(data, &c); err != nil {
return Config{}, err
}
return applyDefaults(c)
}

// applyDefaults fills in any empty fields with their default values, expanding
// leading ~ in path fields to the user home directory.
func applyDefaults(c Config) (Config, error) {
home, err := os.UserHomeDir()
if err != nil {
return c, err
}
if strings.TrimSpace(c.DefaultModel) == "" {
c.DefaultModel = defaultModel
}
if strings.TrimSpace(c.TodoPath) == "" {
c.TodoPath = filepath.Join(home, "TODO.md")
} else {
c.TodoPath = expandHome(c.TodoPath, home)
}
if strings.TrimSpace(c.DBPath) == "" {
c.DBPath = filepath.Join(home, ".super-ollama", "data.db")
} else {
c.DBPath = expandHome(c.DBPath, home)
}
return c, nil
}

// expandHome replaces a leading ~ with the provided home directory.
func expandHome(path, home string) string {
if path == "~" {
return home
}
if strings.HasPrefix(path, "~/") {
return filepath.Join(home, path[2:])
}
return path
}
Loading
Loading