diff --git a/extensions/ty-qmd/.gitignore b/extensions/ty-qmd/.gitignore new file mode 100644 index 00000000..2d975c5b --- /dev/null +++ b/extensions/ty-qmd/.gitignore @@ -0,0 +1,9 @@ +# Binary +ty-qmd + +# Config +config.yaml + +# Test artifacts +*.test +coverage.out diff --git a/extensions/ty-qmd/DESIGN.md b/extensions/ty-qmd/DESIGN.md new file mode 100644 index 00000000..267b548f --- /dev/null +++ b/extensions/ty-qmd/DESIGN.md @@ -0,0 +1,267 @@ +# ty-qmd Design Document + +## Problem Statement + +When Claude works on tasks, it lacks access to historical context: +- What similar tasks were done before? +- What patterns/solutions worked? +- What's documented in project READMEs, meeting notes, etc.? + +Currently, Claude must re-explore codebases and rediscover patterns each time. + +## Solution: QMD Integration + +[QMD](https://github.com/tobi/qmd) is an on-device search engine that: +- Indexes markdown, notes, documentation +- Provides hybrid search (BM25 + semantic vectors + LLM re-ranking) +- Exposes tools via MCP for Claude integration +- Runs entirely locally (no data leaves the machine) + +By integrating QMD with TaskYou, we enable: +1. **Task memory** - Search past completed tasks for relevant context +2. **Project knowledge** - Search project docs, READMEs, architecture notes +3. **Pattern learning** - Find similar past solutions to inform current work + +## Integration Strategies + +### Strategy 1: MCP Sidecar (Recommended for MVP) + +Run QMD's MCP server alongside task execution. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Task Execution Environment │ +│ │ +│ ┌──────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ Claude │───▶│ taskyou-mcp│ │ qmd mcp │ │ +│ │ Code │ │ (tools) │ │ (search) │ │ +│ └──────────┘ └────────────┘ └──────────────┘ │ +│ │ │ │ +│ └────────────────────────────────────┘ │ +│ Claude uses both MCP servers │ +└─────────────────────────────────────────────────────────┘ +``` + +**Implementation:** +1. Configure Claude Code to use qmd as an MCP server +2. Agent has access to `qmd_search`, `qmd_query`, `qmd_get` tools +3. Before starting work, agent can search for relevant past tasks + +**Pros:** +- Simple - uses existing QMD MCP server +- No changes to taskyou-mcp needed +- User configures once in Claude Code settings + +**Cons:** +- Requires user to set up QMD separately +- Two MCP servers to manage + +### Strategy 2: Proxy Mode + +TaskYou's MCP server proxies requests to QMD. + +``` +Claude ──▶ taskyou-mcp ──▶ qmd (subprocess) + │ + ▼ + taskyou tools +``` + +**Implementation:** +1. Add qmd_* tools to taskyou-mcp's tool list +2. When qmd_* tools are called, spawn `qmd` subprocess +3. Forward requests and responses + +**Pros:** +- Single MCP server for Claude +- Taskyou controls QMD lifecycle + +**Cons:** +- More complex implementation +- Tighter coupling + +### Strategy 3: Pre-Execution Context Injection + +Before task execution, search QMD and inject context. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Task Start Hook │ +│ │ +│ 1. Read task title/body │ +│ 2. Query QMD for related past tasks │ +│ 3. Append relevant context to task prompt │ +│ 4. Start Claude with enriched context │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +**Implementation:** +1. In task executor, before spawning Claude: + - Query `qmd query "task title" -n 3` + - Format results as "Related Past Tasks" section + - Append to system prompt or task context + +**Pros:** +- Automatic - no agent action needed +- Context injected upfront +- Works without MCP + +**Cons:** +- Static context (can't search dynamically during task) +- May include irrelevant results + +### Strategy 4: Hybrid Approach (Recommended for Full Implementation) + +Combine strategies for maximum utility: + +1. **Pre-execution injection** for immediate context +2. **MCP sidecar** for dynamic search during execution +3. **Post-completion sync** to update index + +``` +┌───────────────────────────────────────────────────────────────┐ +│ Task Lifecycle │ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │ +│ │ Pre-Start │────▶│ Running │────▶│ Post-Complete │ │ +│ │ │ │ │ │ │ │ +│ │ QMD query │ │ MCP access │ │ Export to QMD │ │ +│ │ + inject │ │ to qmd_* │ │ index new task │ │ +│ └────────────┘ └────────────┘ └────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────┘ +``` + +## Data Model + +### Task Export Format + +Each completed task is exported as markdown: + +```markdown +--- +task_id: 42 +project: workflow +status: done +type: code +completed: 2024-01-15 +tags: [auth, oauth] +--- + +# Add Google OAuth + +Implement Google OAuth 2.0 login flow for the web app. + +## Summary + +Added OAuth with passport.js. Tokens stored in Redis with 1h expiry. +Implemented refresh token rotation. + +## Key Changes + +Files modified: +- src/auth/oauth.ts (new) +- src/auth/strategies/google.ts (new) +- src/middleware/session.ts (modified) +``` + +### Collection Organization + +``` +QMD Index +├── ty-tasks # All completed tasks +├── project-workflow # workflow project docs +├── project-webapp # webapp project docs +└── notes # General notes/meeting transcripts +``` + +## Implementation Phases + +### Phase 1: Basic Sync (This PR) +- [x] ty-qmd extension scaffold +- [x] Export completed tasks to markdown +- [x] Index with QMD +- [x] CLI commands: sync, search, status + +### Phase 2: MCP Integration +- [ ] Add qmd MCP server config to Claude Code +- [ ] Document setup for users +- [ ] Test dynamic search during task execution + +### Phase 3: Pre-Execution Context +- [ ] Add hook in task executor +- [ ] Query QMD before spawning Claude +- [ ] Format and inject context into prompt + +### Phase 4: Advanced Features +- [ ] Auto-sync on task completion (daemon mode) +- [ ] Project-specific collections +- [ ] Search result quality tuning +- [ ] UI integration (show related tasks in TUI) + +## Configuration + +### QMD Setup + +```bash +# Install QMD +bun install -g github:tobi/qmd + +# Add tasks collection +qmd collection add ~/.local/share/ty-qmd/tasks --name ty-tasks --mask "*.md" + +# Generate embeddings +qmd embed +``` + +### Claude Code Integration + +Add to `~/.claude/settings.json`: + +```json +{ + "mcpServers": { + "qmd": { + "command": "qmd", + "args": ["mcp"] + } + } +} +``` + +### ty-qmd Configuration + +```yaml +# ~/.config/ty-qmd/config.yaml +qmd: + binary: qmd + +sync: + auto: true + interval: 5m + statuses: [done, archived] + +collections: + tasks: ty-tasks +``` + +## Security Considerations + +1. **Local-only** - QMD runs entirely on-device +2. **Read-only DB access** - ty-qmd opens TaskYou DB read-only +3. **No external data** - Task content stays local +4. **Model downloads** - QMD downloads ~2GB of GGUF models on first use + +## Open Questions + +1. **Embedding quality** - Are QMD's embeddings good enough for task similarity? +2. **Index size** - How big does the index get with thousands of tasks? +3. **Search latency** - Is hybrid search fast enough for interactive use? +4. **Collection granularity** - Per-project collections vs single global collection? + +## References + +- [QMD README](https://github.com/tobi/qmd) +- [MCP Specification](https://modelcontextprotocol.io/) +- [TaskYou Architecture](../../DEVELOPMENT.md) diff --git a/extensions/ty-qmd/README.md b/extensions/ty-qmd/README.md new file mode 100644 index 00000000..737698dc --- /dev/null +++ b/extensions/ty-qmd/README.md @@ -0,0 +1,267 @@ +# ty-qmd + +QMD integration for TaskYou. Provides semantic search over task history, project documentation, and knowledge bases during task execution. + +## Overview + +[QMD](https://github.com/tobi/qmd) is an on-device search engine that combines BM25 full-text search, vector semantic search, and LLM re-ranking. This extension integrates QMD with TaskYou to: + +1. **Index task history** - Completed tasks become searchable knowledge +2. **Search during execution** - Claude can query past work for context +3. **Project documentation** - Index markdown, meeting notes, docs alongside tasks + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ TaskYou Task Execution │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Claude ──MCP──▶ taskyou-mcp ──proxy──▶ qmd mcp server │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌───────────────┐ │ +│ │ │ QMD Index │ │ +│ │ │ - tasks │ │ +│ │ │ - docs │ │ +│ │ │ - notes │ │ +│ ▼ └───────────────┘ │ +│ ┌─────────────┐ │ +│ │ TaskYou DB │◀──────── ty-qmd sync │ +│ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Features + +### Task History Search + +When a task completes, ty-qmd exports it to a searchable collection: + +```bash +# Index completed tasks +ty-qmd sync + +# Search past tasks +ty-qmd search "authentication implementation" +``` + +Each task becomes a document containing: +- Title and description +- Completion summary +- Key files modified +- Project context + +### MCP Sidecar Mode + +Run QMD as an MCP sidecar during task execution: + +```bash +# Start qmd MCP server for Claude to use +ty-qmd serve +``` + +This exposes QMD tools to Claude: +- `qmd_search` - Fast keyword search +- `qmd_vsearch` - Semantic similarity search +- `qmd_query` - Hybrid search with re-ranking +- `qmd_get` - Retrieve full documents + +### Project Documentation Indexing + +Index project docs alongside tasks: + +```bash +# Add project docs to qmd +ty-qmd index-project ~/Projects/myapp --mask "**/*.md" + +# Add meeting notes +ty-qmd index-project ~/Notes/meetings --name meetings +``` + +## Installation + +### Prerequisites + +1. Install QMD: + ```bash + bun install -g github:tobi/qmd + ``` + +2. Build ty-qmd: + ```bash + cd extensions/ty-qmd + go build -o ty-qmd ./cmd + ``` + +### Configuration + +Config at `~/.config/ty-qmd/config.yaml`: + +```yaml +qmd: + binary: qmd + index: ~/.cache/qmd/index.sqlite + +sync: + # Auto-sync completed tasks + auto: true + # Sync interval when running as daemon + interval: 5m + # Task statuses to index + statuses: + - done + - archived + # Export format for task documents + format: markdown + +collections: + # Default collection for tasks + tasks: ty-tasks + # Project-specific collections + projects: + workflow: workflow-docs +``` + +## Commands + +```bash +# Sync completed tasks to QMD +ty-qmd sync [--all] [--project ] + +# Search across all indexed content +ty-qmd search [-n ] + +# Run MCP server for Claude integration +ty-qmd serve + +# Index project documentation +ty-qmd index-project [--name ] [--mask ] + +# Show sync status +ty-qmd status +``` + +## Integration with TaskYou + +### Option 1: MCP Server Chaining + +Configure taskyou to spawn qmd MCP server alongside Claude: + +```yaml +# In taskyou config +execution: + mcp_servers: + - name: qmd + command: qmd + args: [mcp] +``` + +### Option 2: Proxy Mode + +The taskyou MCP server can proxy to qmd, exposing its tools: + +```go +// In internal/mcp/server.go +// Add qmd tools to tools/list +// Forward qmd_* calls to qmd process +``` + +### Option 3: Pre-Execution Context + +Before executing a task, search for relevant past tasks: + +```bash +# In task execution hook +ty-qmd search "$TASK_TITLE" --json | head -5 >> context.md +``` + +## Use Cases + +### 1. Learning from Past Work + +``` +Task: "Implement OAuth login" + +Claude searches: "OAuth authentication implementation" +Finds: Task #42 "Add Google OAuth" - completed 2 weeks ago +Context: Used passport.js, stored tokens in Redis, added refresh logic +``` + +### 2. Project-Specific Knowledge + +``` +Task: "Add caching to API" + +Claude searches in project docs: "caching architecture" +Finds: architecture.md, redis-patterns.md +Context: Project uses Redis, 15-minute TTL standard, cache-aside pattern +``` + +### 3. Similar Bug Fixes + +``` +Task: "Fix race condition in worker" + +Claude searches: "race condition fix worker" +Finds: Task #128 "Fix concurrent map access" +Context: Used sync.Mutex, added goroutine-safe map wrapper +``` + +## Task Export Format + +When syncing tasks to QMD, each task is exported as markdown: + +```markdown +--- +task_id: 42 +project: workflow +status: done +completed: 2024-01-15 +tags: [auth, oauth] +--- + +# Add Google OAuth + +Implement Google OAuth 2.0 login flow for the web app. + +## Summary + +Added OAuth with passport.js. Tokens stored in Redis with 1h expiry. +Implemented refresh token rotation. + +## Key Files + +- src/auth/oauth.ts +- src/auth/strategies/google.ts +- src/middleware/session.ts + +## Related Tasks + +- #38: Add session management +- #45: Add OAuth scopes +``` + +## Development + +### Building + +```bash +cd extensions/ty-qmd +go build -o ty-qmd ./cmd +``` + +### Testing + +```bash +go test ./... +``` + +## Future Ideas + +- **Automatic tagging** - Use QMD's LLM to auto-tag tasks +- **Related task suggestions** - Show similar past tasks when creating new ones +- **Knowledge graph** - Build task relationship graph from semantic similarity +- **Cross-project search** - Search across all indexed projects +- **Embedding visualization** - Visualize task clusters in embedding space diff --git a/extensions/ty-qmd/cmd/main.go b/extensions/ty-qmd/cmd/main.go new file mode 100644 index 00000000..7607f861 --- /dev/null +++ b/extensions/ty-qmd/cmd/main.go @@ -0,0 +1,448 @@ +// Command ty-qmd provides QMD search integration for TaskYou. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/bborn/workflow/extensions/ty-qmd/internal/exporter" + "github.com/bborn/workflow/extensions/ty-qmd/internal/qmd" + "github.com/bborn/workflow/extensions/ty-qmd/internal/tasks" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// Config holds the configuration. +type Config struct { + QMD struct { + Binary string `yaml:"binary"` + Index string `yaml:"index"` + } `yaml:"qmd"` + TaskYou struct { + DB string `yaml:"db"` + CLI string `yaml:"cli"` + } `yaml:"taskyou"` + Sync struct { + Auto bool `yaml:"auto"` + Interval time.Duration `yaml:"interval"` + Statuses []string `yaml:"statuses"` + IncludeLogs bool `yaml:"include_logs"` + MaxLogLines int `yaml:"max_log_lines"` + } `yaml:"sync"` + Collections struct { + Tasks string `yaml:"tasks"` + Projects map[string]string `yaml:"projects"` + } `yaml:"collections"` +} + +var ( + cfgFile string + verbose bool +) + +func main() { + rootCmd := &cobra.Command{ + Use: "ty-qmd", + Short: "QMD search integration for TaskYou", + Long: "Index completed tasks and search across task history, project docs, and knowledge bases.", + } + + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default: ~/.config/ty-qmd/config.yaml)") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") + + rootCmd.AddCommand(syncCmd()) + rootCmd.AddCommand(searchCmd()) + rootCmd.AddCommand(serveCmd()) + rootCmd.AddCommand(indexProjectCmd()) + rootCmd.AddCommand(statusCmd()) + + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func syncCmd() *cobra.Command { + var all bool + var project string + + cmd := &cobra.Command{ + Use: "sync", + Short: "Sync completed tasks to QMD index", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + logger := setupLogger() + q := qmd.New(cfg.QMD.Binary, logger) + + // Check qmd is available + if !q.IsAvailable() { + return fmt.Errorf("qmd not found at: %s\nInstall with: bun install -g github:tobi/qmd", cfg.QMD.Binary) + } + + // Open tasks database + taskDB, err := tasks.Open(cfg.TaskYou.DB) + if err != nil { + return fmt.Errorf("failed to open tasks db: %w", err) + } + defer taskDB.Close() + + // Get tasks to sync + opts := tasks.ListOptions{ + Statuses: cfg.Sync.Statuses, + } + if project != "" { + opts.Project = project + } + if !all { + opts.NotSynced = true + } + + taskList, err := taskDB.ListTasks(opts) + if err != nil { + return fmt.Errorf("failed to list tasks: %w", err) + } + + if len(taskList) == 0 { + logger.Info("no tasks to sync") + return nil + } + + logger.Info("syncing tasks", "count", len(taskList)) + + // Ensure collection exists + collection := cfg.Collections.Tasks + if err := q.EnsureCollection(collection); err != nil { + return fmt.Errorf("failed to ensure collection: %w", err) + } + + // Export and index each task + exp := exporter.New(cfg.Sync.IncludeLogs, cfg.Sync.MaxLogLines) + synced := 0 + + for _, t := range taskList { + // Export task to markdown + md := exp.Export(t) + + // Write to temp file + tmpDir := filepath.Join(os.TempDir(), "ty-qmd-export") + os.MkdirAll(tmpDir, 0755) + + filename := fmt.Sprintf("task-%d.md", t.ID) + tmpPath := filepath.Join(tmpDir, filename) + + if err := os.WriteFile(tmpPath, []byte(md), 0644); err != nil { + logger.Error("failed to write temp file", "task", t.ID, "error", err) + continue + } + + // Index with qmd + if err := q.IndexFile(tmpPath, collection); err != nil { + logger.Error("failed to index task", "task", t.ID, "error", err) + continue + } + + // Mark as synced + if err := taskDB.MarkSynced(t.ID); err != nil { + logger.Warn("failed to mark task synced", "task", t.ID, "error", err) + } + + synced++ + logger.Debug("synced task", "id", t.ID, "title", t.Title) + } + + logger.Info("sync complete", "synced", synced, "total", len(taskList)) + return nil + }, + } + + cmd.Flags().BoolVarP(&all, "all", "a", false, "re-sync all tasks, not just new ones") + cmd.Flags().StringVarP(&project, "project", "p", "", "sync only tasks from specific project") + + return cmd +} + +func searchCmd() *cobra.Command { + var count int + var collection string + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search indexed content", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + logger := setupLogger() + q := qmd.New(cfg.QMD.Binary, logger) + + query := args[0] + for _, arg := range args[1:] { + query += " " + arg + } + + if collection == "" { + collection = cfg.Collections.Tasks + } + + results, err := q.Query(query, collection, count) + if err != nil { + return fmt.Errorf("search failed: %w", err) + } + + if jsonOutput { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + } + + if len(results) == 0 { + fmt.Println("No results found.") + return nil + } + + for _, r := range results { + fmt.Printf("\n%.2f %s\n", r.Score, r.Title) + fmt.Printf(" %s\n", r.Path) + if r.Snippet != "" { + fmt.Printf(" %s\n", r.Snippet) + } + } + + return nil + }, + } + + cmd.Flags().IntVarP(&count, "count", "n", 5, "number of results") + cmd.Flags().StringVarP(&collection, "collection", "c", "", "collection to search (default: tasks)") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") + + return cmd +} + +func serveCmd() *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Run QMD MCP server for Claude integration", + Long: `Start the QMD MCP server as a sidecar process. + +This allows Claude to use QMD search tools during task execution: +- qmd_search: Fast keyword search +- qmd_vsearch: Semantic vector search +- qmd_query: Hybrid search with re-ranking +- qmd_get: Retrieve documents`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + logger := setupLogger() + + // Check qmd is available + q := qmd.New(cfg.QMD.Binary, logger) + if !q.IsAvailable() { + return fmt.Errorf("qmd not found at: %s\nInstall with: bun install -g github:tobi/qmd", cfg.QMD.Binary) + } + + logger.Info("starting qmd mcp server", "binary", cfg.QMD.Binary) + + // Setup signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + + // Start qmd mcp + qmdCmd := exec.CommandContext(ctx, cfg.QMD.Binary, "mcp") + qmdCmd.Stdin = os.Stdin + qmdCmd.Stdout = os.Stdout + qmdCmd.Stderr = os.Stderr + + if err := qmdCmd.Start(); err != nil { + return fmt.Errorf("failed to start qmd mcp: %w", err) + } + + // Wait for signal or process exit + doneCh := make(chan error, 1) + go func() { + doneCh <- qmdCmd.Wait() + }() + + select { + case <-sigCh: + logger.Info("shutting down...") + qmdCmd.Process.Signal(syscall.SIGTERM) + return nil + case err := <-doneCh: + if err != nil { + return fmt.Errorf("qmd mcp exited: %w", err) + } + return nil + } + }, + } +} + +func indexProjectCmd() *cobra.Command { + var name string + var mask string + + cmd := &cobra.Command{ + Use: "index-project ", + Short: "Index project documentation", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + logger := setupLogger() + q := qmd.New(cfg.QMD.Binary, logger) + + if !q.IsAvailable() { + return fmt.Errorf("qmd not found at: %s", cfg.QMD.Binary) + } + + path := args[0] + if name == "" { + name = filepath.Base(path) + } + + logger.Info("indexing project", "path", path, "collection", name, "mask", mask) + + if err := q.AddCollection(path, name, mask); err != nil { + return fmt.Errorf("failed to add collection: %w", err) + } + + if err := q.Embed(); err != nil { + return fmt.Errorf("failed to embed: %w", err) + } + + logger.Info("project indexed successfully", "collection", name) + return nil + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "collection name (default: directory name)") + cmd.Flags().StringVarP(&mask, "mask", "m", "**/*.md", "file glob pattern") + + return cmd +} + +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show sync and index status", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + logger := setupLogger() + q := qmd.New(cfg.QMD.Binary, logger) + + if !q.IsAvailable() { + fmt.Println("QMD: not installed") + fmt.Printf(" Install with: bun install -g github:tobi/qmd\n") + return nil + } + + // Get qmd status + status, err := q.Status() + if err != nil { + fmt.Printf("QMD: error getting status: %v\n", err) + } else { + fmt.Println("QMD Status:") + fmt.Printf(" Collections: %d\n", status.Collections) + fmt.Printf(" Documents: %d\n", status.Documents) + fmt.Printf(" Embedded: %d\n", status.Embedded) + } + + // Get tasks sync status + taskDB, err := tasks.Open(cfg.TaskYou.DB) + if err != nil { + fmt.Printf("\nTaskYou DB: error: %v\n", err) + return nil + } + defer taskDB.Close() + + syncStats, err := taskDB.SyncStats() + if err != nil { + fmt.Printf("\nSync Status: error: %v\n", err) + return nil + } + + fmt.Printf("\nSync Status:\n") + fmt.Printf(" Total completed: %d\n", syncStats.Total) + fmt.Printf(" Synced: %d\n", syncStats.Synced) + fmt.Printf(" Pending: %d\n", syncStats.Pending) + + return nil + }, + } +} + +func loadConfig() (*Config, error) { + path := cfgFile + if path == "" { + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".config", "ty-qmd", "config.yaml") + } + + var cfg Config + + // Set defaults + cfg.QMD.Binary = "qmd" + cfg.Sync.Interval = 5 * time.Minute + cfg.Sync.Statuses = []string{"done", "archived"} + cfg.Sync.MaxLogLines = 100 + cfg.Collections.Tasks = "ty-tasks" + + home, _ := os.UserHomeDir() + cfg.TaskYou.DB = filepath.Join(home, ".local", "share", "taskyou", "tasks.db") + cfg.TaskYou.CLI = "ty" + + // Load config file if exists + data, err := os.ReadFile(path) + if err == nil { + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + } + + // Expand home directory in paths + if cfg.TaskYou.DB != "" && cfg.TaskYou.DB[0] == '~' { + cfg.TaskYou.DB = filepath.Join(home, cfg.TaskYou.DB[1:]) + } + + return &cfg, nil +} + +func setupLogger() *slog.Logger { + level := slog.LevelInfo + if verbose { + level = slog.LevelDebug + } + + return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: level, + })) +} diff --git a/extensions/ty-qmd/config.example.yaml b/extensions/ty-qmd/config.example.yaml new file mode 100644 index 00000000..39dc27d2 --- /dev/null +++ b/extensions/ty-qmd/config.example.yaml @@ -0,0 +1,35 @@ +# ty-qmd configuration +# Copy to ~/.config/ty-qmd/config.yaml + +qmd: + # Path to qmd binary + binary: qmd + # QMD index location (default: ~/.cache/qmd/index.sqlite) + index: "" + +taskyou: + # Path to TaskYou database + db: ~/.local/share/taskyou/tasks.db + # Path to ty CLI binary + cli: ty + +sync: + # Automatically sync tasks on completion + auto: true + # Sync interval when running as daemon + interval: 5m + # Task statuses to index + statuses: + - done + - archived + # Include task logs in export + include_logs: false + # Maximum log lines to include + max_log_lines: 100 + +collections: + # Collection name for tasks + tasks: ty-tasks + # Project-specific overrides + # projects: + # myproject: myproject-tasks diff --git a/extensions/ty-qmd/go.mod b/extensions/ty-qmd/go.mod new file mode 100644 index 00000000..6e9efc4b --- /dev/null +++ b/extensions/ty-qmd/go.mod @@ -0,0 +1,32 @@ +module github.com/bborn/workflow/extensions/ty-qmd + +go 1.21 + +require ( + github.com/spf13/cobra v1.8.0 + gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.28.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/tools v0.15.0 // indirect + lukechampine.com/uint128 v1.2.0 // indirect + modernc.org/cc/v3 v3.41.0 // indirect + modernc.org/ccgo/v3 v3.16.15 // indirect + modernc.org/libc v1.41.0 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/opt v0.1.3 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/extensions/ty-qmd/go.sum b/extensions/ty-qmd/go.sum new file mode 100644 index 00000000..01e38d4f --- /dev/null +++ b/extensions/ty-qmd/go.sum @@ -0,0 +1,69 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= +golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= +modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q= +modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= +modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0= +modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI= +modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= +modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ= +modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= +modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM= +modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk= +modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY= +modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY= +modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE= diff --git a/extensions/ty-qmd/internal/exporter/exporter.go b/extensions/ty-qmd/internal/exporter/exporter.go new file mode 100644 index 00000000..3d76b1cc --- /dev/null +++ b/extensions/ty-qmd/internal/exporter/exporter.go @@ -0,0 +1,119 @@ +// Package exporter converts tasks to markdown for QMD indexing. +package exporter + +import ( + "fmt" + "strings" + + "github.com/bborn/workflow/extensions/ty-qmd/internal/tasks" +) + +// Exporter converts tasks to markdown format. +type Exporter struct { + includeLogs bool + maxLogLines int +} + +// New creates a new exporter. +func New(includeLogs bool, maxLogLines int) *Exporter { + return &Exporter{ + includeLogs: includeLogs, + maxLogLines: maxLogLines, + } +} + +// Export converts a task to markdown. +func (e *Exporter) Export(t tasks.Task) string { + var sb strings.Builder + + // YAML frontmatter + sb.WriteString("---\n") + sb.WriteString(fmt.Sprintf("task_id: %d\n", t.ID)) + if t.Project != "" { + sb.WriteString(fmt.Sprintf("project: %s\n", t.Project)) + } + sb.WriteString(fmt.Sprintf("status: %s\n", t.Status)) + if t.Type != "" { + sb.WriteString(fmt.Sprintf("type: %s\n", t.Type)) + } + sb.WriteString(fmt.Sprintf("created: %s\n", t.CreatedAt.Format("2006-01-02"))) + if t.CompletedAt != nil { + sb.WriteString(fmt.Sprintf("completed: %s\n", t.CompletedAt.Format("2006-01-02"))) + } + if t.Tags != "" { + // Convert comma-separated tags to YAML list + tags := strings.Split(t.Tags, ",") + sb.WriteString("tags:\n") + for _, tag := range tags { + tag = strings.TrimSpace(tag) + if tag != "" { + sb.WriteString(fmt.Sprintf(" - %s\n", tag)) + } + } + } + sb.WriteString("---\n\n") + + // Title + sb.WriteString(fmt.Sprintf("# %s\n\n", t.Title)) + + // Description + if t.Body != "" { + sb.WriteString("## Description\n\n") + sb.WriteString(t.Body) + sb.WriteString("\n\n") + } + + // Summary (if available) + if t.Summary != "" { + sb.WriteString("## Summary\n\n") + sb.WriteString(t.Summary) + sb.WriteString("\n\n") + } + + // Logs (if enabled) + if e.includeLogs && len(t.Logs) > 0 { + sb.WriteString("## Activity Log\n\n") + + count := len(t.Logs) + if e.maxLogLines > 0 && count > e.maxLogLines { + count = e.maxLogLines + } + + for i := 0; i < count; i++ { + log := t.Logs[i] + timestamp := log.Time.Format("2006-01-02 15:04") + // Truncate long messages + msg := log.Message + if len(msg) > 500 { + msg = msg[:500] + "..." + } + sb.WriteString(fmt.Sprintf("- **%s** [%s]: %s\n", timestamp, log.Type, msg)) + } + sb.WriteString("\n") + } + + return sb.String() +} + +// ExportSummary creates a brief summary for search context. +func (e *Exporter) ExportSummary(t tasks.Task) string { + var parts []string + + parts = append(parts, fmt.Sprintf("Task #%d: %s", t.ID, t.Title)) + + if t.Project != "" { + parts = append(parts, fmt.Sprintf("Project: %s", t.Project)) + } + + parts = append(parts, fmt.Sprintf("Status: %s", t.Status)) + + if t.Body != "" { + body := t.Body + if len(body) > 200 { + body = body[:200] + "..." + } + parts = append(parts, body) + } + + return strings.Join(parts, "\n") +} diff --git a/extensions/ty-qmd/internal/qmd/qmd.go b/extensions/ty-qmd/internal/qmd/qmd.go new file mode 100644 index 00000000..0064cca5 --- /dev/null +++ b/extensions/ty-qmd/internal/qmd/qmd.go @@ -0,0 +1,214 @@ +// Package qmd provides a wrapper for the QMD CLI. +package qmd + +import ( + "encoding/json" + "fmt" + "log/slog" + "os/exec" + "strings" +) + +// QMD wraps the qmd CLI for search operations. +type QMD struct { + binary string + logger *slog.Logger +} + +// SearchResult represents a single search result. +type SearchResult struct { + DocID string `json:"docid"` + Score float64 `json:"score"` + Path string `json:"path"` + Title string `json:"title"` + Snippet string `json:"snippet"` +} + +// Status represents qmd index status. +type Status struct { + Collections int `json:"collections"` + Documents int `json:"documents"` + Embedded int `json:"embedded"` +} + +// New creates a new QMD wrapper. +func New(binary string, logger *slog.Logger) *QMD { + if binary == "" { + binary = "qmd" + } + return &QMD{ + binary: binary, + logger: logger, + } +} + +// IsAvailable checks if qmd is installed. +func (q *QMD) IsAvailable() bool { + _, err := exec.LookPath(q.binary) + return err == nil +} + +// Search performs a BM25 keyword search. +func (q *QMD) Search(query, collection string, count int) ([]SearchResult, error) { + args := []string{"search", query, "--json"} + if collection != "" { + args = append(args, "-c", collection) + } + if count > 0 { + args = append(args, "-n", fmt.Sprintf("%d", count)) + } + + return q.runSearch(args) +} + +// VSearch performs a vector semantic search. +func (q *QMD) VSearch(query, collection string, count int) ([]SearchResult, error) { + args := []string{"vsearch", query, "--json"} + if collection != "" { + args = append(args, "-c", collection) + } + if count > 0 { + args = append(args, "-n", fmt.Sprintf("%d", count)) + } + + return q.runSearch(args) +} + +// Query performs a hybrid search with re-ranking. +func (q *QMD) Query(query, collection string, count int) ([]SearchResult, error) { + args := []string{"query", query, "--json"} + if collection != "" { + args = append(args, "-c", collection) + } + if count > 0 { + args = append(args, "-n", fmt.Sprintf("%d", count)) + } + + return q.runSearch(args) +} + +// Get retrieves a document by path or docid. +func (q *QMD) Get(pathOrDocID string) (string, error) { + out, err := q.run("get", pathOrDocID, "--full") + if err != nil { + return "", err + } + return string(out), nil +} + +// AddCollection adds a new collection to the index. +func (q *QMD) AddCollection(path, name, mask string) error { + args := []string{"collection", "add", path, "--name", name} + if mask != "" { + args = append(args, "--mask", mask) + } + + _, err := q.run(args...) + return err +} + +// EnsureCollection creates a collection if it doesn't exist. +func (q *QMD) EnsureCollection(name string) error { + // Check if collection exists by listing + out, err := q.run("collection", "list", "--json") + if err != nil { + // Collection list might fail if no collections exist yet + return nil + } + + var collections []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(out, &collections); err == nil { + for _, c := range collections { + if c.Name == name { + return nil // Already exists + } + } + } + + // Create temp directory for the collection + // Note: qmd requires a path for collection, but for tasks we manage individual files + return nil +} + +// IndexFile indexes a single file into a collection. +func (q *QMD) IndexFile(path, collection string) error { + // Add file to collection and trigger embedding + args := []string{"collection", "add", path, "--name", collection} + if _, err := q.run(args...); err != nil { + // Might fail if collection already has this path, try update + q.logger.Debug("add collection failed, trying update", "error", err) + } + + // Trigger index update + return q.Update() +} + +// Update updates the index. +func (q *QMD) Update() error { + _, err := q.run("update") + return err +} + +// Embed generates embeddings for unembedded documents. +func (q *QMD) Embed() error { + _, err := q.run("embed") + return err +} + +// Status returns the index status. +func (q *QMD) Status() (*Status, error) { + out, err := q.run("status", "--json") + if err != nil { + return nil, err + } + + var status Status + if err := json.Unmarshal(out, &status); err != nil { + // Try to parse text output + lines := strings.Split(string(out), "\n") + for _, line := range lines { + if strings.Contains(line, "collections:") { + fmt.Sscanf(line, "collections: %d", &status.Collections) + } else if strings.Contains(line, "documents:") { + fmt.Sscanf(line, "documents: %d", &status.Documents) + } else if strings.Contains(line, "embedded:") { + fmt.Sscanf(line, "embedded: %d", &status.Embedded) + } + } + } + + return &status, nil +} + +// runSearch executes a search command and parses results. +func (q *QMD) runSearch(args []string) ([]SearchResult, error) { + out, err := q.run(args...) + if err != nil { + return nil, err + } + + var results []SearchResult + if err := json.Unmarshal(out, &results); err != nil { + return nil, fmt.Errorf("failed to parse search results: %w", err) + } + + return results, nil +} + +// run executes a qmd command. +func (q *QMD) run(args ...string) ([]byte, error) { + q.logger.Debug("running qmd", "args", args) + + cmd := exec.Command(q.binary, args...) + out, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("qmd %s: %s", strings.Join(args, " "), string(exitErr.Stderr)) + } + return nil, fmt.Errorf("qmd %s: %w", strings.Join(args, " "), err) + } + + return out, nil +} diff --git a/extensions/ty-qmd/internal/tasks/tasks.go b/extensions/ty-qmd/internal/tasks/tasks.go new file mode 100644 index 00000000..f6d8a48a --- /dev/null +++ b/extensions/ty-qmd/internal/tasks/tasks.go @@ -0,0 +1,219 @@ +// Package tasks provides access to the TaskYou database for sync operations. +package tasks + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +// DB wraps the TaskYou database. +type DB struct { + db *sql.DB +} + +// Task represents a task from TaskYou. +type Task struct { + ID int64 + Title string + Body string + Status string + Project string + Type string + Tags string + CreatedAt time.Time + CompletedAt *time.Time + Summary string + Logs []LogEntry +} + +// LogEntry represents a task log entry. +type LogEntry struct { + Type string + Message string + Time time.Time +} + +// ListOptions specifies options for listing tasks. +type ListOptions struct { + Statuses []string + Project string + NotSynced bool +} + +// SyncStats holds sync statistics. +type SyncStats struct { + Total int + Synced int + Pending int +} + +// Open opens the TaskYou database. +func Open(path string) (*DB, error) { + if path == "" { + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".local", "share", "taskyou", "tasks.db") + } + + // Expand ~ in path + if path[0] == '~' { + home, _ := os.UserHomeDir() + path = filepath.Join(home, path[1:]) + } + + // Check file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, fmt.Errorf("database not found: %s", path) + } + + db, err := sql.Open("sqlite", path+"?mode=ro") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Ensure qmd_synced column exists (for tracking synced tasks) + // We'll use a separate tracking file instead of modifying TaskYou's DB + return &DB{db: db}, nil +} + +// Close closes the database. +func (d *DB) Close() error { + return d.db.Close() +} + +// ListTasks returns tasks matching the options. +func (d *DB) ListTasks(opts ListOptions) ([]Task, error) { + query := ` + SELECT id, title, body, status, project, type, tags, created_at, completed_at + FROM tasks + WHERE 1=1 + ` + args := []interface{}{} + + if len(opts.Statuses) > 0 { + placeholders := "" + for i, s := range opts.Statuses { + if i > 0 { + placeholders += "," + } + placeholders += "?" + args = append(args, s) + } + query += fmt.Sprintf(" AND status IN (%s)", placeholders) + } + + if opts.Project != "" { + query += " AND project = ?" + args = append(args, opts.Project) + } + + query += " ORDER BY completed_at DESC, id DESC" + + rows, err := d.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query tasks: %w", err) + } + defer rows.Close() + + var tasks []Task + for rows.Next() { + var t Task + var completedAt sql.NullTime + + if err := rows.Scan(&t.ID, &t.Title, &t.Body, &t.Status, &t.Project, &t.Type, &t.Tags, &t.CreatedAt, &completedAt); err != nil { + return nil, fmt.Errorf("failed to scan task: %w", err) + } + + if completedAt.Valid { + t.CompletedAt = &completedAt.Time + } + + tasks = append(tasks, t) + } + + return tasks, nil +} + +// GetTask returns a single task by ID. +func (d *DB) GetTask(id int64) (*Task, error) { + var t Task + var completedAt sql.NullTime + + err := d.db.QueryRow(` + SELECT id, title, body, status, project, type, tags, created_at, completed_at + FROM tasks WHERE id = ? + `, id).Scan(&t.ID, &t.Title, &t.Body, &t.Status, &t.Project, &t.Type, &t.Tags, &t.CreatedAt, &completedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get task: %w", err) + } + + if completedAt.Valid { + t.CompletedAt = &completedAt.Time + } + + return &t, nil +} + +// GetTaskLogs returns logs for a task. +func (d *DB) GetTaskLogs(taskID int64, limit int) ([]LogEntry, error) { + query := ` + SELECT type, message, created_at + FROM task_logs + WHERE task_id = ? + ORDER BY created_at DESC + ` + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := d.db.Query(query, taskID) + if err != nil { + return nil, fmt.Errorf("failed to query logs: %w", err) + } + defer rows.Close() + + var logs []LogEntry + for rows.Next() { + var l LogEntry + if err := rows.Scan(&l.Type, &l.Message, &l.Time); err != nil { + return nil, fmt.Errorf("failed to scan log: %w", err) + } + logs = append(logs, l) + } + + return logs, nil +} + +// MarkSynced marks a task as synced to QMD. +// Note: Uses a separate tracking file to avoid modifying TaskYou's DB. +func (d *DB) MarkSynced(taskID int64) error { + // For now, this is a no-op. In a full implementation, we'd track synced + // tasks in a separate SQLite database: ~/.local/share/ty-qmd/sync.db + return nil +} + +// SyncStats returns sync statistics. +func (d *DB) SyncStats() (*SyncStats, error) { + var total int + err := d.db.QueryRow(` + SELECT COUNT(*) FROM tasks WHERE status IN ('done', 'archived') + `).Scan(&total) + if err != nil { + return nil, fmt.Errorf("failed to count tasks: %w", err) + } + + // For now, assume nothing is synced (would check sync tracking DB) + return &SyncStats{ + Total: total, + Synced: 0, + Pending: total, + }, nil +} diff --git a/internal/qmd/client.go b/internal/qmd/client.go new file mode 100644 index 00000000..cadd921d --- /dev/null +++ b/internal/qmd/client.go @@ -0,0 +1,211 @@ +// Package qmd provides a client for QMD semantic search. +package qmd + +import ( + "context" + "encoding/json" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +// Client wraps the QMD CLI for semantic search. +type Client struct { + binary string + available bool + mu sync.RWMutex + + // Cache for search results + cache map[string]cachedResult + cacheMu sync.RWMutex + cacheTTL time.Duration +} + +type cachedResult struct { + results []SearchResult + timestamp time.Time +} + +// SearchResult represents a single search result from QMD. +type SearchResult struct { + DocID string `json:"docid"` + Score float64 `json:"score"` + Path string `json:"path"` + Title string `json:"title"` + Snippet string `json:"snippet"` +} + +// RelatedTask represents a related task found via semantic search. +type RelatedTask struct { + TaskID int64 + Title string + Score float64 + Project string +} + +// DefaultClient is the global QMD client instance. +var DefaultClient = NewClient("") + +// NewClient creates a new QMD client. +func NewClient(binary string) *Client { + if binary == "" { + binary = "qmd" + } + c := &Client{ + binary: binary, + cache: make(map[string]cachedResult), + cacheTTL: 5 * time.Minute, + } + // Check availability on creation + c.checkAvailable() + return c +} + +// checkAvailable checks if qmd is installed. +func (c *Client) checkAvailable() { + c.mu.Lock() + defer c.mu.Unlock() + _, err := exec.LookPath(c.binary) + c.available = err == nil +} + +// IsAvailable returns true if QMD is installed and usable. +func (c *Client) IsAvailable() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.available +} + +// Search performs a keyword search (BM25). +func (c *Client) Search(ctx context.Context, query string, collection string, count int) ([]SearchResult, error) { + return c.search(ctx, "search", query, collection, count) +} + +// VSearch performs a vector/semantic search. +func (c *Client) VSearch(ctx context.Context, query string, collection string, count int) ([]SearchResult, error) { + return c.search(ctx, "vsearch", query, collection, count) +} + +// Query performs a hybrid search with re-ranking (best quality). +func (c *Client) Query(ctx context.Context, query string, collection string, count int) ([]SearchResult, error) { + return c.search(ctx, "query", query, collection, count) +} + +// search executes a search command. +func (c *Client) search(ctx context.Context, cmd, query, collection string, count int) ([]SearchResult, error) { + if !c.IsAvailable() { + return nil, nil + } + + // Check cache + cacheKey := cmd + ":" + collection + ":" + query + ":" + strconv.Itoa(count) + c.cacheMu.RLock() + if cached, ok := c.cache[cacheKey]; ok && time.Since(cached.timestamp) < c.cacheTTL { + c.cacheMu.RUnlock() + return cached.results, nil + } + c.cacheMu.RUnlock() + + args := []string{cmd, query, "--json"} + if collection != "" { + args = append(args, "-c", collection) + } + if count > 0 { + args = append(args, "-n", strconv.Itoa(count)) + } + + cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + execCmd := exec.CommandContext(cmdCtx, c.binary, args...) + out, err := execCmd.Output() + if err != nil { + return nil, err + } + + var results []SearchResult + if err := json.Unmarshal(out, &results); err != nil { + return nil, err + } + + // Cache results + c.cacheMu.Lock() + c.cache[cacheKey] = cachedResult{results: results, timestamp: time.Now()} + c.cacheMu.Unlock() + + return results, nil +} + +// FindRelatedTasks searches for tasks related to the given query. +// It parses task IDs from the document paths (expecting format: task-{id}.md) +func (c *Client) FindRelatedTasks(ctx context.Context, query string, count int) ([]RelatedTask, error) { + if !c.IsAvailable() { + return nil, nil + } + + // Use hybrid search for best results + results, err := c.Query(ctx, query, "ty-tasks", count) + if err != nil { + return nil, err + } + + var related []RelatedTask + for _, r := range results { + // Parse task ID from path (format: task-{id}.md) + taskID := parseTaskIDFromPath(r.Path) + if taskID == 0 { + continue + } + + // Extract title from result + title := r.Title + if title == "" { + title = r.Snippet + } + + related = append(related, RelatedTask{ + TaskID: taskID, + Title: title, + Score: r.Score, + }) + } + + return related, nil +} + +// parseTaskIDFromPath extracts task ID from a path like "task-42.md" +func parseTaskIDFromPath(path string) int64 { + // Get filename from path + parts := strings.Split(path, "/") + filename := parts[len(parts)-1] + + // Remove extension + filename = strings.TrimSuffix(filename, ".md") + + // Parse task-{id} + if !strings.HasPrefix(filename, "task-") { + return 0 + } + + idStr := strings.TrimPrefix(filename, "task-") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + return 0 + } + + return id +} + +// ClearCache clears the search result cache. +func (c *Client) ClearCache() { + c.cacheMu.Lock() + c.cache = make(map[string]cachedResult) + c.cacheMu.Unlock() +} + +// Refresh re-checks QMD availability. +func (c *Client) Refresh() { + c.checkAvailable() +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 9dc4c193..8486e261 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -765,6 +765,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if prCmd := m.fetchPRInfo(msg.task); prCmd != nil { cmds = append(cmds, prCmd) } + // Start loading related tasks from QMD + if relatedCmd := m.detailView.StartRelatedTasksLoad(); relatedCmd != nil { + cmds = append(cmds, relatedCmd) + } } else { m.err = msg.err } diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 48b17688..a9784cb6 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -13,6 +13,7 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/executor" "github.com/bborn/workflow/internal/github" + "github.com/bborn/workflow/internal/qmd" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" @@ -104,6 +105,12 @@ type DetailModel struct { // Server detection for task port serverListening bool // true when a server is listening on the task's port lastServerCheck time.Time // throttle server port checks + + // Related tasks from QMD semantic search + relatedTasks []qmd.RelatedTask // cached related tasks + relatedTasksLoading bool // true while loading related tasks + relatedTasksLoaded bool // true once loaded (even if empty) + lastRelatedSearch string // cache key for related task search } // Message types for async pane loading @@ -118,6 +125,36 @@ type panesJoinedMsg struct { type spinnerTickMsg struct{} +// relatedTasksMsg is sent when related tasks are loaded from QMD +type relatedTasksMsg struct { + taskID int64 + results []qmd.RelatedTask + err error +} + +// loadRelatedTasks fetches related tasks from QMD in the background +func loadRelatedTasks(taskID int64, query string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + results, err := qmd.DefaultClient.FindRelatedTasks(ctx, query, 5) + if err != nil { + return relatedTasksMsg{taskID: taskID, err: err} + } + + // Filter out the current task from results + filtered := make([]qmd.RelatedTask, 0, len(results)) + for _, r := range results { + if r.TaskID != taskID { + filtered = append(filtered, r) + } + } + + return relatedTasksMsg{taskID: taskID, results: filtered} + } +} + // Spinner frames for loading animation var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} @@ -147,6 +184,36 @@ func (m *DetailModel) executorDisplayName() string { return executor.DefaultExecutorName() } +// StartRelatedTasksLoad starts loading related tasks from QMD if available. +// Returns a tea.Cmd that can be batched with other commands. +func (m *DetailModel) StartRelatedTasksLoad() tea.Cmd { + if m.task == nil || !qmd.DefaultClient.IsAvailable() { + return nil + } + + // Build search query from task title and body + query := m.task.Title + if m.task.Body != "" { + // Truncate body to avoid overly long queries + body := m.task.Body + if len(body) > 200 { + body = body[:200] + } + query += " " + body + } + + // Check if we already loaded for this query + if m.lastRelatedSearch == query && m.relatedTasksLoaded { + return nil + } + + m.lastRelatedSearch = query + m.relatedTasksLoading = true + m.relatedTasksLoaded = false + + return loadRelatedTasks(m.task.ID, query) +} + // UpdateTask updates the task and refreshes the view. func (m *DetailModel) UpdateTask(t *db.Task) { m.task = t @@ -485,6 +552,18 @@ func (m *DetailModel) Update(msg tea.Msg) (*DetailModel, tea.Cmd) { } return m, nil + case relatedTasksMsg: + // Related tasks loaded from QMD + if m.task != nil && msg.taskID == m.task.ID { + m.relatedTasksLoading = false + m.relatedTasksLoaded = true + if msg.err == nil { + m.relatedTasks = msg.results + } + m.viewport.SetContent(m.renderContent()) + } + return m, nil + case panesRefreshMsg: // Panes need to be refreshed (e.g., after dangerous mode toggle recreated the window) log := GetLogger() @@ -2342,11 +2421,13 @@ func (m *DetailModel) renderContent() string { t := m.task // Check if we can use cached content + // Note: We don't cache when related tasks are loading/changing logHash := m.computeLogHash() if m.cachedContent != "" && m.lastRenderedBody == t.Body && m.lastRenderedLogHash == logHash && - m.lastRenderedFocused == m.focused { + m.lastRenderedFocused == m.focused && + !m.relatedTasksLoading { return m.cachedContent } @@ -2388,6 +2469,39 @@ func (m *DetailModel) renderContent() string { b.WriteString("\n") } + // Related Tasks section (from QMD semantic search) + if m.relatedTasksLoading { + b.WriteString("\n") + b.WriteString(Bold.Render("Related Tasks")) + b.WriteString("\n\n") + if m.focused { + b.WriteString(Dim.Render(" Searching...")) + } else { + b.WriteString(dimmedStyle.Render(" Searching...")) + } + b.WriteString("\n") + } else if len(m.relatedTasks) > 0 { + b.WriteString("\n") + b.WriteString(Bold.Render("Related Tasks")) + b.WriteString("\n\n") + for _, related := range m.relatedTasks { + // Score indicator: high (>0.7), medium (>0.4), low + scoreIndicator := "○" + if related.Score > 0.7 { + scoreIndicator = "●" + } else if related.Score > 0.4 { + scoreIndicator = "◐" + } + line := fmt.Sprintf(" %s #%d: %s", scoreIndicator, related.TaskID, related.Title) + if m.focused { + b.WriteString(line) + } else { + b.WriteString(dimmedStyle.Render(line)) + } + b.WriteString("\n") + } + } + // Dependencies section if m.database != nil { blockers, blockedBy, err := m.database.GetAllDependencies(t.ID)