472 lines
15 KiB
Go
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
|
|
},
|
|
}
|
|
}
|