Files

472 lines
15 KiB
Go

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 <title>",
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
},
}
}