Initial commit: knox knowledge index
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user