package cmd import ( "fmt" "strconv" "strings" "github.com/david/knox/internal/db" "github.com/david/knox/internal/watch" "github.com/spf13/cobra" ) func NewThreadCmd(kdb *db.KnoxDB) *cobra.Command { cmd := &cobra.Command{ Use: "thread", Short: "Manage knowledge development threads (motivation/intent tracking)", Long: `Threads capture WHY observations were collected — the intent, motivation, and in-flight knowledge development work. Subcommands: create, list, link, link-entry, relate, golden, prune, rescan, close, show, note`, } cmd.AddCommand(newThreadCreateCmd(kdb)) cmd.AddCommand(newThreadAutoCmd(kdb)) cmd.AddCommand(newThreadDraftCmd(kdb)) cmd.AddCommand(newThreadUpdateCmd(kdb)) cmd.AddCommand(newThreadListCmd(kdb)) cmd.AddCommand(newThreadLinkCmd(kdb)) cmd.AddCommand(newThreadCloseCmd(kdb)) cmd.AddCommand(newThreadShowCmd(kdb)) cmd.AddCommand(newThreadNoteCmd(kdb)) cmd.AddCommand(newThreadLinkEntryCmd(kdb)) cmd.AddCommand(newThreadRelateCmd(kdb)) cmd.AddCommand(newThreadGoldenCmd(kdb)) cmd.AddCommand(newThreadPruneCmd(kdb)) cmd.AddCommand(newThreadRescanCmd(kdb)) return cmd } func newThreadCreateCmd(kdb *db.KnoxDB) *cobra.Command { var motivation, priority, tags, provenance string cmd := &cobra.Command{ Use: "create ", Short: "Create a new knowledge development thread", Args: cobra.MinimumNArgs(1), RunE: func(c *cobra.Command, args []string) error { title := strings.Join(args, " ") id, err := kdb.CreateThread(title, motivation, priority, tags, provenance) if err != nil { return err } fmt.Printf("Created thread #%d: %s\n", id, title) return nil }, } cmd.Flags().StringVarP(&motivation, "motivation", "m", "", "Why this thread exists (the 'so what')") cmd.Flags().StringVarP(&priority, "priority", "p", "medium", "Priority: high, medium, low") cmd.Flags().StringVarP(&tags, "tags", "t", "", "Comma-separated tags") cmd.Flags().StringVarP(&provenance, "provenance", "", "{}", "JSON provenance metadata") return cmd } func newThreadAutoCmd(kdb *db.KnoxDB) *cobra.Command { var dryRun bool cmd := &cobra.Command{ Use: "auto", Short: "Auto-create threads from high-intent topic clusters (heuristic, no LLM)", Long: `Clusters recent observations with TF-IDF and promotes clusters that cross the "intent bar" into threads, auto-linking their observations. The intent bar (tunable via KNOX_THREAD_* env vars) requires: enough distinct sources, at least one non-passive work source (git/gitea/session/obsidian), and observations observed recently. Use --dry-run to preview without writing.`, RunE: func(c *cobra.Command, args []string) error { threader := watch.NewAutoThreader(kdb) threader.DryRun = dryRun created, linked, err := threader.AutoThread() if err != nil { return err } fmt.Printf("\nAuto-thread: %d created, %d observations linked\n", created, linked) return nil }, } cmd.Flags().BoolVar(&dryRun, "dry-run", dryRun, "Preview candidate threads without writing") return cmd } func newThreadDraftCmd(kdb *db.KnoxDB) *cobra.Command { return &cobra.Command{ Use: "draft", Short: "List auto-threaded candidates for LLM title/motivation polish", Long: `Shows threads created by the heuristic auto-threader ('auto_thread' provenance) alongside their linked observations, so an LLM or editor can write a better title and motivation. Apply changes with ` + "`knox thread update <id>`" + `.`, RunE: func(c *cobra.Command, args []string) error { threads, err := kdb.ListThreads("") if err != nil { return err } var shown int for _, t := range threads { if !isAutoThreaded(t) { continue } obs, _ := kdb.ThreadObservations(t.ID) fmt.Printf("Thread #%d [%s] %d obs\n", t.ID, t.Priority, t.EntryCount) fmt.Printf(" title: %s\n", t.Title) fmt.Printf(" motivation: %s\n", t.Motivation) if t.Tags != "[]" { fmt.Printf(" tags: %s\n", t.Tags) } fmt.Println(" linked observations:") for _, o := range obs { title := truncateStr(o.Title, 70) if o.Summary != "" { title += " — " + truncateStr(o.Summary, 60) } fmt.Printf(" • [%s] %s\n", o.SourceID, title) } fmt.Println() shown++ } if shown == 0 { fmt.Println("No auto-threaded candidates found. Run `knox thread auto` (or wait for the watch tick) first.") } return nil }, } } func isAutoThreaded(t db.Thread) bool { if strings.Contains(t.Provenance, "auto_thread") { return true } return strings.HasPrefix(t.Motivation, "Auto-detected") } func newThreadUpdateCmd(kdb *db.KnoxDB) *cobra.Command { var title, motivation, priority, tags string cmd := &cobra.Command{ Use: "update <thread-id>", Short: "Rewrite a thread's title/motivation/priority/tags", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { id, _ := strconv.ParseInt(args[0], 10, 64) if id == 0 { return fmt.Errorf("thread-id must be numeric") } changed, err := kdb.UpdateThread(id, title, motivation, priority, tags) if err != nil { return err } if !changed { fmt.Println("No changes applied.") return nil } fmt.Printf("Updated thread #%d\n", id) return nil }, } cmd.Flags().StringVarP(&title, "title", "t", "", "New title") cmd.Flags().StringVarP(&motivation, "motivation", "m", "", "New motivation") cmd.Flags().StringVarP(&priority, "priority", "p", "", "New priority (high/medium/low)") cmd.Flags().StringVar(&tags, "tags", "", "New space-separated tags") return cmd } func newThreadListCmd(kdb *db.KnoxDB) *cobra.Command { var status string cmd := &cobra.Command{ Use: "list", Short: "List knowledge development threads", RunE: func(c *cobra.Command, args []string) error { threads, err := kdb.ListThreads(status) if err != nil { return err } if len(threads) == 0 { fmt.Println("No threads. Create one with `knox thread create`.") return nil } fmt.Printf("%-4s %-40s %-10s %-8s %s\n", "ID", "Title", "Status", "Priority", "Entries") fmt.Println(strings.Repeat("-", 75)) for _, t := range threads { fmt.Printf("%-4d %-40s %-10s %-8s %d\n", t.ID, truncateStr(t.Title, 38), t.Status, t.Priority, t.EntryCount) if t.Motivation != "" { fmt.Printf(" %s\n", truncateStr(t.Motivation, 70)) } } return nil }, } cmd.Flags().StringVarP(&status, "status", "s", "", "Filter by status (active, stalled, resolved)") return cmd } func newThreadLinkCmd(kdb *db.KnoxDB) *cobra.Command { var relevance string cmd := &cobra.Command{ Use: "link <thread-id> <observation-id>", Short: "Link an observation to a thread", Args: cobra.ExactArgs(2), RunE: func(c *cobra.Command, args []string) error { threadID, _ := strconv.ParseInt(args[0], 10, 64) obsID, _ := strconv.ParseInt(args[1], 10, 64) if threadID == 0 || obsID == 0 { return fmt.Errorf("thread-id and observation-id must be numeric") } if err := kdb.LinkObservationToThread(threadID, obsID, relevance); err != nil { return err } fmt.Printf("Linked observation #%d to thread #%d\n", obsID, threadID) return nil }, } cmd.Flags().StringVarP(&relevance, "relevance", "r", "", "Why this observation matters to the thread") return cmd } func newThreadCloseCmd(kdb *db.KnoxDB) *cobra.Command { return &cobra.Command{ Use: "close <thread-id>", Short: "Mark a thread as resolved", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { id, _ := strconv.ParseInt(args[0], 10, 64) if id == 0 { return fmt.Errorf("thread-id must be numeric") } thread, _ := kdb.GetThread(id) if thread == nil { return fmt.Errorf("thread #%d not found", id) } if err := kdb.CloseThread(id); err != nil { return err } fmt.Printf("Closed thread #%d: %s\n", id, thread.Title) return nil }, } } func newThreadShowCmd(kdb *db.KnoxDB) *cobra.Command { return &cobra.Command{ Use: "show <thread-id>", Short: "Show thread details with provenance, linked observations, and notes", Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { id, _ := strconv.ParseInt(args[0], 10, 64) if id == 0 { return fmt.Errorf("thread-id must be numeric") } t, err := kdb.GetThread(id) if err != nil { return err } if t == nil { return fmt.Errorf("thread #%d not found", id) } goldenID, _ := kdb.GoldenThreadID() fmt.Printf("Thread #%d: %s", t.ID, t.Title) if goldenID == id { fmt.Print(" [GOLDEN]") } fmt.Println() fmt.Printf(" Status: %s\n", t.Status) fmt.Printf(" Priority: %s\n", t.Priority) fmt.Printf(" Created: %s\n", t.CreatedAt) fmt.Printf(" Updated: %s\n", t.UpdatedAt) if t.ResolvedAt != "" { fmt.Printf(" Resolved: %s\n", t.ResolvedAt) } if t.Motivation != "" { fmt.Printf(" Motivation: %s\n", t.Motivation) } if t.Tags != "[]" { fmt.Printf(" Tags: %s\n", t.Tags) } if t.Provenance != "{}" { fmt.Printf(" Provenance: %s\n", t.Provenance) } obs, _ := kdb.ThreadObservations(id) if len(obs) > 0 { fmt.Printf("\nLinked observations (%d):\n", len(obs)) for _, o := range obs { fmt.Printf(" #%-4d %-30s [%s] trigger=%s\n", o.ID, truncateStr(o.Title, 28), o.SourceID, o.Trigger) } } provenance, _ := kdb.ThreadProvenance(id) if len(provenance) > 0 { fmt.Printf("\nProvenance graph (%d links):\n", len(provenance)) for _, p := range provenance { fmt.Printf(" %s --[%s]--> thread:%d\n", shortFP(p.Fingerprint), p.Relation, id) } } notes, _ := kdb.ThreadNotes(id) if len(notes) > 0 { fmt.Printf("\nNotes (%d):\n", len(notes)) for _, n := range notes { fmt.Printf(" [%s] %s\n", n.CreatedAt, n.Note) } } return nil }, } } func newThreadLinkEntryCmd(kdb *db.KnoxDB) *cobra.Command { var relation string cmd := &cobra.Command{ Use: "link-entry <thread-id> <entry-fingerprint>", Short: "Link an entry to a thread via provenance graph", Args: cobra.ExactArgs(2), RunE: func(c *cobra.Command, args []string) error { id, _ := strconv.ParseInt(args[0], 10, 64) if id == 0 { return fmt.Errorf("thread-id must be numeric") } if err := kdb.LinkEntryToThread(id, args[1], relation); err != nil { return err } fmt.Printf("Linked entry %s to thread #%d [%s]\n", shortFP(args[1]), id, relation) return nil }, } cmd.Flags().StringVarP(&relation, "relation", "r", "produced", "Relation type (produced, references, motivated)") return cmd } func newThreadRelateCmd(kdb *db.KnoxDB) *cobra.Command { var relation string cmd := &cobra.Command{ Use: "relate <parent-id> <child-id>", Short: "Relate two threads (parent spawned child, etc.)", Args: cobra.ExactArgs(2), RunE: func(c *cobra.Command, args []string) error { parentID, _ := strconv.ParseInt(args[0], 10, 64) childID, _ := strconv.ParseInt(args[1], 10, 64) if parentID == 0 || childID == 0 { return fmt.Errorf("both thread IDs must be numeric") } if err := kdb.LinkEntryToThread(childID, db.ThreadFP(parentID), relation); err != nil { return err } fmt.Printf("Related thread #%d -> #%d [%s]\n", parentID, childID, relation) return nil }, } cmd.Flags().StringVarP(&relation, "relation", "r", "spawned", "Relation type (spawned, merged_into, references, supersedes)") return cmd } func newThreadGoldenCmd(kdb *db.KnoxDB) *cobra.Command { var unset bool cmd := &cobra.Command{ Use: "golden [thread-id]", Short: "Set/show/clear the golden thread (current focus). Auto-links new observations.", RunE: func(c *cobra.Command, args []string) error { if unset { kdb.SetGoldenThread(0) fmt.Println("Golden thread cleared.") return nil } if len(args) > 0 { id, _ := strconv.ParseInt(args[0], 10, 64) if id == 0 { return fmt.Errorf("thread-id must be numeric") } if err := kdb.SetGoldenThread(id); err != nil { return err } t, _ := kdb.GetThread(id) if t != nil { fmt.Printf("Golden thread set to #%d: %s\n", id, t.Title) } return nil } goldenID, _ := kdb.GoldenThreadID() if goldenID == 0 { fmt.Println("No golden thread set.") return nil } t, _ := kdb.GetThread(goldenID) if t != nil { fmt.Printf("Golden thread: #%d %s [%s/%s]\n %s\n", t.ID, t.Title, t.Status, t.Priority, t.Motivation) } return nil }, } cmd.Flags().BoolVar(&unset, "unset", false, "Clear the golden thread") return cmd } func newThreadNoteCmd(kdb *db.KnoxDB) *cobra.Command { return &cobra.Command{ Use: "note <thread-id> <text>", Short: "Add a note to a thread", Args: cobra.MinimumNArgs(2), RunE: func(c *cobra.Command, args []string) error { id, _ := strconv.ParseInt(args[0], 10, 64) if id == 0 { return fmt.Errorf("thread-id must be numeric") } note := strings.Join(args[1:], " ") nid, err := kdb.AddThreadNote(id, note) if err != nil { return err } fmt.Printf("Added note #%d to thread #%d\n", nid, id) return nil }, } } func newThreadPruneCmd(kdb *db.KnoxDB) *cobra.Command { return &cobra.Command{ Use: "prune <thread-id>", Short: "Remove irrelevant auto-linked observations from a thread", Long: `Scans auto-linked observations and removes ones that don't share keywords with the thread's title, motivation, or tags.`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { id, _ := strconv.ParseInt(args[0], 10, 64) if id == 0 { return fmt.Errorf("thread-id must be numeric") } t, err := kdb.GetThread(id) if err != nil || t == nil { return fmt.Errorf("thread #%d not found", id) } keywords, bigrams, err := kdb.ThreadRelevanceProfile(id) if err != nil { return err } if len(keywords) == 0 { return fmt.Errorf("no significant keywords found in thread") } count, err := kdb.PruneThread(id) if err != nil { return err } fmt.Printf("Pruned %d irrelevant links from thread #%d (%s)\n", count, id, t.Title) fmt.Printf(" Kept observations matching: %v (bigrams: %v)\n", keywords, bigrams) return nil }, } } func newThreadRescanCmd(kdb *db.KnoxDB) *cobra.Command { return &cobra.Command{ Use: "rescan <thread-id>", Short: "Link unlinked observations that pass the thread's relevance profile", Long: `Scans all observations and links ones matching the thread's DF-filtered keyword profile. Inverse of prune.`, Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { id, _ := strconv.ParseInt(args[0], 10, 64) if id == 0 { return fmt.Errorf("thread-id must be numeric") } t, err := kdb.GetThread(id) if err != nil || t == nil { return fmt.Errorf("thread #%d not found", id) } count, err := kdb.RescanThread(id) if err != nil { return err } fmt.Printf("Linked %d observations to thread #%d (%s)\n", count, id, t.Title) return nil }, } }