Initial commit: knox knowledge index
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/ingest"
|
||||
)
|
||||
|
||||
func NewBrowserCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "browser",
|
||||
Short: "Ingest browser history from Chromium and Min into the knowledge index",
|
||||
Long: `Reads browser history databases (Chromium and Min), fingerprints URLs,
|
||||
and stores them as observations. Tracks what you've searched for and read.
|
||||
|
||||
Run periodically to capture browsing exhaust.`,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
ing := ingest.NewBrowserHistoryIngester()
|
||||
log.Printf("[knox] ingesting browser history...")
|
||||
|
||||
results, err := ing.IngestAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("browser ingest: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
fmt.Println("No browser history found (Chromium/Min not detected or empty).")
|
||||
return nil
|
||||
}
|
||||
|
||||
var newCount, dupCount int
|
||||
for _, result := range results {
|
||||
_, isNew, err := kdb.RecordObservation(db.ObservationRecord{
|
||||
Fingerprint: result.Fingerprint,
|
||||
SourceID: result.SourceID,
|
||||
SourcePath: result.SourcePath,
|
||||
ContentType: result.ContentType,
|
||||
Title: result.Title,
|
||||
Summary: result.Summary,
|
||||
CreatedAt: result.CreatedAt,
|
||||
Confidence: result.Confidence,
|
||||
IngesterVersion: result.IngesterVersion,
|
||||
Trigger: "browser_ingest",
|
||||
Provenance: ingest.ProvenanceJSON(result.Provenance),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[knox] db error: %v", err)
|
||||
continue
|
||||
}
|
||||
if isNew {
|
||||
newCount++
|
||||
} else {
|
||||
dupCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Browser history: %d new, %d already seen\n", newCount, dupCount)
|
||||
|
||||
// Show top domains
|
||||
domains := make(map[string]int)
|
||||
for _, r := range results {
|
||||
domains[extractDomain(r.Summary)]++
|
||||
}
|
||||
fmt.Println("\nTop domains:")
|
||||
for _, d := range topDomains(domains, 10) {
|
||||
fmt.Printf(" %-40s %d\n", d.Name, d.Count)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func topDomains(domains map[string]int, n int) []struct {
|
||||
Name string
|
||||
Count int
|
||||
} {
|
||||
var sorted []struct {
|
||||
Name string
|
||||
Count int
|
||||
}
|
||||
for name, count := range domains {
|
||||
sorted = append(sorted, struct {
|
||||
Name string
|
||||
Count int
|
||||
}{name, count})
|
||||
}
|
||||
// Simple bubble sort for small n
|
||||
for i := 0; i < len(sorted)-1; i++ {
|
||||
for j := 0; j < len(sorted)-1-i; j++ {
|
||||
if sorted[j].Count < sorted[j+1].Count {
|
||||
sorted[j], sorted[j+1] = sorted[j+1], sorted[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(sorted) > n {
|
||||
sorted = sorted[:n]
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
func extractDomain(url string) string {
|
||||
url = strings.TrimPrefix(url, "https://")
|
||||
url = strings.TrimPrefix(url, "http://")
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/ingest"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewGitCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
var roots []string
|
||||
var recursive bool
|
||||
cmd := &cobra.Command{
|
||||
Args: cobra.NoArgs,
|
||||
Use: "git",
|
||||
Short: "Ingest local git repository status into the knowledge index",
|
||||
Long: `Scans git repos under one or more root dirs (default ~/src) and records
|
||||
each project's status — branch, clean/dirty, ahead/behind, HEAD, last commit.
|
||||
With --recursive (default) repos are found at any depth, following symlinked
|
||||
directories, so nested repos like ~/assistant/kitmaker are picked up. Use
|
||||
'knox git' for a one-shot ingest, or rely on the watch daemon's git timer.`,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
ing := &ingest.GitIngester{Roots: roots, Recursive: recursive}
|
||||
log.Printf("[knox] ingesting git repo status...")
|
||||
|
||||
results, err := ing.IngestAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git ingest: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
fmt.Println("No git repos found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
ingest.SortResults(results)
|
||||
var newCount int
|
||||
for _, result := range results {
|
||||
_, isNew, err := kdb.RecordObservation(db.ObservationRecord{
|
||||
Fingerprint: result.Fingerprint,
|
||||
SourceID: result.SourceID,
|
||||
SourcePath: result.SourcePath,
|
||||
Project: result.Project,
|
||||
ContentType: result.ContentType,
|
||||
Title: result.Title,
|
||||
Summary: result.Summary,
|
||||
CreatedAt: result.CreatedAt,
|
||||
Confidence: result.Confidence,
|
||||
IngesterVersion: result.IngesterVersion,
|
||||
Trigger: "git_ingest",
|
||||
Provenance: ingest.ProvenanceJSON(result.Provenance),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[knox] db error: %v", err)
|
||||
continue
|
||||
}
|
||||
if isNew {
|
||||
newCount++
|
||||
}
|
||||
fmt.Printf("%-24s %s\n", result.Project, result.Summary)
|
||||
}
|
||||
fmt.Printf("\nGit: %d repos (%d new)\n", len(results), newCount)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVarP(&roots, "root", "r", nil, "Directories to scan for git repos (default ~/src; repeat or comma-separate for multiple roots)")
|
||||
cmd.Flags().BoolVar(&recursive, "recursive", true, "Recurse into subdirectories (following symlinks) to find nested git repos")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/ingest"
|
||||
)
|
||||
|
||||
func NewGiteaCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "gitea",
|
||||
Short: "Ingest Gitea repositories, issues, and PRs into the knowledge index",
|
||||
Long: `Uses the 'tea' CLI to fetch repositories, open issues, and open
|
||||
pull requests from Gitea. Fingerprints each by ID for dedup.`,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
ing := ingest.NewGiteaIngester()
|
||||
log.Printf("[knox] ingesting Gitea data via tea...")
|
||||
|
||||
results, err := ing.IngestAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("gitea ingest: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
fmt.Println("No Gitea data found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var repos, issues, pulls int
|
||||
var newCount int
|
||||
for _, result := range results {
|
||||
_, isNew, err := kdb.RecordObservation(db.ObservationRecord{
|
||||
Fingerprint: result.Fingerprint,
|
||||
SourceID: result.SourceID,
|
||||
SourcePath: result.SourcePath,
|
||||
Project: result.Project,
|
||||
ContentType: result.ContentType,
|
||||
Title: result.Title,
|
||||
Summary: result.Summary,
|
||||
CreatedAt: result.CreatedAt,
|
||||
Confidence: result.Confidence,
|
||||
IngesterVersion: result.IngesterVersion,
|
||||
Trigger: "gitea_ingest",
|
||||
Provenance: ingest.ProvenanceJSON(result.Provenance),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[knox] db error: %v", err)
|
||||
continue
|
||||
}
|
||||
if isNew {
|
||||
newCount++
|
||||
}
|
||||
switch result.ContentType {
|
||||
case "repo":
|
||||
repos++
|
||||
case "issue":
|
||||
issues++
|
||||
case "pull":
|
||||
pulls++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Gitea: %d repos, %d issues, %d PRs (%d new)\n", repos, issues, pulls, newCount)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/ingest"
|
||||
"github.com/david/knox/internal/watch"
|
||||
)
|
||||
|
||||
func NewIngestCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
var dirs []string
|
||||
cmd := &cobra.Command{
|
||||
Use: "ingest",
|
||||
Short: "One-time full sweep of all sources into the knowledge index",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(dirs) == 0 {
|
||||
dirs = defaultWatchDirs()
|
||||
}
|
||||
|
||||
ingesters := []ingest.Ingester{
|
||||
ingest.NewSessionDiffIngester(),
|
||||
ingest.NewLogIngester(),
|
||||
ingest.NewSkillsIngester(),
|
||||
}
|
||||
|
||||
var totalNew, totalUpdated int
|
||||
|
||||
record := func(result *ingest.IngestResult) {
|
||||
_, isNew, err := kdb.RecordObservation(db.ObservationRecord{
|
||||
Fingerprint: result.Fingerprint,
|
||||
SourceID: result.SourceID,
|
||||
SourcePath: result.SourcePath,
|
||||
Project: result.Project,
|
||||
ContentType: result.ContentType,
|
||||
Title: result.Title,
|
||||
Summary: result.Summary,
|
||||
CreatedAt: result.CreatedAt,
|
||||
LineStart: result.LineStart,
|
||||
LineEnd: result.LineEnd,
|
||||
Confidence: result.Confidence,
|
||||
IngesterVersion: result.IngesterVersion,
|
||||
Trigger: "ingest",
|
||||
Provenance: ingest.ProvenanceJSON(result.Provenance),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[knox] db error: %v", err)
|
||||
return
|
||||
}
|
||||
if isNew {
|
||||
totalNew++
|
||||
} else {
|
||||
totalUpdated++
|
||||
}
|
||||
|
||||
if result.SourceID == "opencode-session" {
|
||||
sessionID, _ := result.Provenance["session_id"].(string)
|
||||
if sessionID != "" {
|
||||
kdb.UpsertSession(sessionID, result.Project, result.Title, "active")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ing := range ingesters {
|
||||
sid := ing.SourceID()
|
||||
log.Printf("[knox] ingesting %s...", sid)
|
||||
|
||||
for _, dir := range dirs {
|
||||
patterns := []string{
|
||||
dir + "/*",
|
||||
dir + "/*/SKILL.md",
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
entries, _ := filepath.Glob(pattern)
|
||||
for _, path := range entries {
|
||||
if !watch.MatchesIngester(path, sid) {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := ing.Ingest(path)
|
||||
if err != nil || result == nil {
|
||||
continue
|
||||
}
|
||||
record(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-file sources: obsidian vault, browser history, gitea
|
||||
if vault, err := ingest.DetectObsidianVault(); err == nil {
|
||||
log.Printf("[knox] ingesting obsidian...")
|
||||
if results, err := ingest.NewObsidianIngester(vault).IngestAll(); err == nil {
|
||||
for _, r := range results {
|
||||
record(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("[knox] ingesting browser-history...")
|
||||
if results, err := ingest.NewBrowserHistoryIngester().IngestAll(); err == nil {
|
||||
for _, r := range results {
|
||||
record(r)
|
||||
}
|
||||
}
|
||||
log.Printf("[knox] ingesting gitea...")
|
||||
if results, err := ingest.NewGiteaIngester().IngestAll(); err == nil {
|
||||
for _, r := range results {
|
||||
record(r)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nIngest complete: %d new entries, %d updated\n", totalNew, totalUpdated)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVarP(&dirs, "dir", "d", nil, "Directories to scan (default: opencode storage/log + skills)")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/index"
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
)
|
||||
|
||||
type detailLevel int
|
||||
|
||||
const (
|
||||
detailBrief detailLevel = iota
|
||||
detailNormal
|
||||
detailFull
|
||||
)
|
||||
|
||||
func parseDetail(s string) detailLevel {
|
||||
switch strings.ToLower(s) {
|
||||
case "brief":
|
||||
return detailBrief
|
||||
case "full":
|
||||
return detailFull
|
||||
default:
|
||||
return detailNormal
|
||||
}
|
||||
}
|
||||
|
||||
func NewMCPServer(kdb *db.KnoxDB) *server.MCPServer {
|
||||
s := server.NewMCPServer(
|
||||
"knox-knowledge",
|
||||
"0.2.0",
|
||||
server.WithResourceCapabilities(true, true),
|
||||
server.WithPromptCapabilities(true),
|
||||
server.WithLogging(),
|
||||
)
|
||||
|
||||
// ─── knox_search ──────────────────────────────────────────
|
||||
searchTool := mcp.NewTool("knox_search",
|
||||
mcp.WithDescription("Search the knowledge index. Supports zoom levels: brief (lean), normal (default), full (verbose). Scope filters: all, skills, sessions, logs."),
|
||||
mcp.WithString("query", mcp.Required(), mcp.Description("Search query")),
|
||||
mcp.WithNumber("limit", mcp.Description("Max results (default 10)")),
|
||||
mcp.WithString("detail", mcp.Description("Zoom level: brief, normal, or full (default normal)")),
|
||||
mcp.WithString("scope", mcp.Description("Source scope: all, skills, sessions, logs (default all)")),
|
||||
)
|
||||
|
||||
s.AddTool(searchTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
query, _ := req.Params.Arguments["query"].(string)
|
||||
limit := clampInt(req, "limit", 10, 1, 50)
|
||||
detail := parseDetail(getString(req, "detail", "normal"))
|
||||
scope := getString(req, "scope", "all")
|
||||
|
||||
results, err := kdb.Search(query, limit)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
results = filterByScope(results, scope)
|
||||
if len(results) == 0 {
|
||||
return mcp.NewToolResultText(fmt.Sprintf("No results for %q in scope %q", query, scope)), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(formatEntries(results, detail, kdb)), nil
|
||||
})
|
||||
|
||||
// ─── knox_recent ───────────────────────────────────────────
|
||||
recentTool := mcp.NewTool("knox_recent",
|
||||
mcp.WithDescription("Show recent knowledge entries with zoom levels: brief, normal (default), full."),
|
||||
mcp.WithNumber("limit", mcp.Description("Max results (default 10)")),
|
||||
mcp.WithString("detail", mcp.Description("Zoom level: brief, normal, or full (default normal)")),
|
||||
mcp.WithString("scope", mcp.Description("Source scope: all, skills, sessions, logs (default all)")),
|
||||
)
|
||||
|
||||
s.AddTool(recentTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
limit := clampInt(req, "limit", 10, 1, 50)
|
||||
detail := parseDetail(getString(req, "detail", "normal"))
|
||||
scope := getString(req, "scope", "all")
|
||||
|
||||
entries, err := kdb.RecentEntries(limit)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
entries = filterByScope(entries, scope)
|
||||
if len(entries) == 0 {
|
||||
return mcp.NewToolResultText("No entries yet."), nil
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(formatEntries(entries, detail, kdb)), nil
|
||||
})
|
||||
|
||||
// ─── knox_get ──────────────────────────────────────────────
|
||||
getTool := mcp.NewTool("knox_get",
|
||||
mcp.WithDescription("Get full detail for a specific entry by fingerprint (drill-down from search results)."),
|
||||
mcp.WithString("fingerprint", mcp.Required(), mcp.Description("Entry fingerprint (full or first 8 chars)")),
|
||||
)
|
||||
|
||||
s.AddTool(getTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
fp := getString(req, "fingerprint", "")
|
||||
|
||||
entry, err := kdb.FindEntry(fp)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if entry == nil {
|
||||
// Try fingerprint prefix match
|
||||
entries, err := kdb.FindEntryByPrefix(fp, 5)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if len(entries) == 1 {
|
||||
entry = &entries[0]
|
||||
} else if len(entries) > 1 {
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Multiple matches for %q — use full fingerprint:\n%s", fp, formatEntriesBrief(entries))), nil
|
||||
} else {
|
||||
return mcp.NewToolResultText(fmt.Sprintf("No entry found for fingerprint %q", fp)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Full detail: entry + observations + provenance chain
|
||||
var b strings.Builder
|
||||
b.WriteString(formatEntryFull(entry))
|
||||
|
||||
obs, _ := kdb.ObservationsByFingerprint(entry.Fingerprint, 20)
|
||||
if len(obs) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\nObservations (%d):\n", len(obs)))
|
||||
for _, o := range obs {
|
||||
b.WriteString(fmt.Sprintf(" [%s] trigger=%s confidence=%.1f line=%d ver=%s\n",
|
||||
o.CollectedAt, o.Trigger, o.Confidence, o.LineEnd, o.IngesterVersion))
|
||||
}
|
||||
}
|
||||
|
||||
chain, _ := kdb.ProvenanceChain(entry.Fingerprint, 3)
|
||||
if len(chain) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\nProvenance links (%d):\n", len(chain)))
|
||||
for _, l := range chain {
|
||||
b.WriteString(fmt.Sprintf(" %s --[%s]--> %s (hop %d)\n", shortFP(l.Fingerprint), l.Relation, shortFP(entry.Fingerprint), l.HopDistance))
|
||||
}
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(b.String()), nil
|
||||
})
|
||||
|
||||
// ─── knox_stats ────────────────────────────────────────────
|
||||
statsTool := mcp.NewTool("knox_stats",
|
||||
mcp.WithDescription("Get knowledge index statistics"),
|
||||
)
|
||||
|
||||
s.AddTool(statsTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
stats, err := kdb.Stats()
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
b, _ := json.MarshalIndent(stats, "", " ")
|
||||
return mcp.NewToolResultText(string(b)), nil
|
||||
})
|
||||
|
||||
// ─── knox_count ────────────────────────────────────────────
|
||||
countTool := mcp.NewTool("knox_count",
|
||||
mcp.WithDescription("Return hit count matching a query, grouped by source. Lightweight — no result bodies, minimal tokens."),
|
||||
mcp.WithString("query", mcp.Required(), mcp.Description("Search query")),
|
||||
)
|
||||
|
||||
s.AddTool(countTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
query, _ := req.Params.Arguments["query"].(string)
|
||||
results, err := kdb.Search(query, 500)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
total := len(results)
|
||||
bySource := make(map[string]int)
|
||||
for _, r := range results {
|
||||
bySource[r.SourceID]++
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Matches for %q: %d total\n", query, total))
|
||||
for src, count := range bySource {
|
||||
b.WriteString(fmt.Sprintf(" %-20s %d\n", src+":", count))
|
||||
}
|
||||
return mcp.NewToolResultText(b.String()), nil
|
||||
})
|
||||
|
||||
// ─── knox_reflect ──────────────────────────────────────────
|
||||
reflectTool := mcp.NewTool("knox_reflect",
|
||||
mcp.WithDescription("Process pending sessions for reflection and gap analysis"),
|
||||
)
|
||||
|
||||
s.AddTool(reflectTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
reflector := index.NewReflector(kdb)
|
||||
results, err := reflector.Reflect()
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return mcp.NewToolResultText("No pending sessions to reflect on."), nil
|
||||
}
|
||||
return mcp.NewToolResultText(reflector.FormatResult(results)), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_list ───────────────────────────────────────
|
||||
threadListTool := mcp.NewTool("knox_thread_list",
|
||||
mcp.WithDescription("List knowledge development threads with their motivation, status, and linked entry count."),
|
||||
mcp.WithString("status", mcp.Description("Filter: active, stalled, resolved (default all)")),
|
||||
)
|
||||
|
||||
s.AddTool(threadListTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
status := getString(req, "status", "")
|
||||
threads, err := kdb.ListThreads(status)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if len(threads) == 0 {
|
||||
return mcp.NewToolResultText("No threads."), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, t := range threads {
|
||||
b.WriteString(fmt.Sprintf("#%d %-40s [%s/%s] %d entries\n", t.ID, t.Title, t.Status, t.Priority, t.EntryCount))
|
||||
if t.Motivation != "" {
|
||||
b.WriteString(fmt.Sprintf(" %s\n", t.Motivation))
|
||||
}
|
||||
}
|
||||
return mcp.NewToolResultText(b.String()), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_create ─────────────────────────────────────
|
||||
threadCreateTool := mcp.NewTool("knox_thread_create",
|
||||
mcp.WithDescription("Create a new knowledge development thread to track intent/motivation behind observations."),
|
||||
mcp.WithString("title", mcp.Required(), mcp.Description("Thread title")),
|
||||
mcp.WithString("motivation", mcp.Description("Why this thread exists (the 'so what')")),
|
||||
mcp.WithString("priority", mcp.Description("high, medium, or low (default medium)")),
|
||||
mcp.WithString("provenance", mcp.Description("JSON provenance metadata: {\"trigger\":\"...\",\"session_id\":\"...\"}")),
|
||||
)
|
||||
|
||||
s.AddTool(threadCreateTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
title := getString(req, "title", "")
|
||||
motivation := getString(req, "motivation", "")
|
||||
priority := getString(req, "priority", "medium")
|
||||
provenance := getString(req, "provenance", "{}")
|
||||
id, err := kdb.CreateThread(title, motivation, priority, "[]", provenance)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Created thread #%d: %s", id, title)), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_update ─────────────────────────────────────
|
||||
threadUpdateTool := mcp.NewTool("knox_thread_update",
|
||||
mcp.WithDescription("Rewrite a thread's editorial fields (title/motivation/priority/tags). Used to polish auto-generated threads."),
|
||||
mcp.WithNumber("thread_id", mcp.Required(), mcp.Description("Thread ID to update")),
|
||||
mcp.WithString("title", mcp.Description("New title (leave empty to keep)")),
|
||||
mcp.WithString("motivation", mcp.Description("New motivation (leave empty to keep)")),
|
||||
mcp.WithString("priority", mcp.Description("New priority: high/medium/low (empty to keep)")),
|
||||
mcp.WithString("tags", mcp.Description("New space-separated tags (empty to keep)")),
|
||||
)
|
||||
|
||||
s.AddTool(threadUpdateTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
id := int64(clampInt(req, "thread_id", 0, 1, 999999))
|
||||
title := getString(req, "title", "")
|
||||
motivation := getString(req, "motivation", "")
|
||||
priority := getString(req, "priority", "")
|
||||
tags := getString(req, "tags", "")
|
||||
changed, err := kdb.UpdateThread(id, title, motivation, priority, tags)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if !changed {
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Thread #%d unchanged (no edits differed).", id)), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Updated thread #%d", id)), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_draft ──────────────────────────────────────
|
||||
// Emits auto-threaded candidates with enough context (sources, linked
|
||||
// observations) for an agent to write tight titles/motivations.
|
||||
threadDraftTool := mcp.NewTool("knox_thread_draft",
|
||||
mcp.WithDescription("List auto-threaded candidates needing LLM polish. Returns each heuristic thread's current title/motivation and its linked observations so you can rewrite them via knox_thread_update."),
|
||||
mcp.WithNumber("limit", mcp.Description("Max candidates to return (default 20)")),
|
||||
)
|
||||
|
||||
s.AddTool(threadDraftTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
limit := clampInt(req, "limit", 20, 1, 200)
|
||||
threads, err := kdb.ListThreads("")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
selected := make([]db.Thread, 0, len(threads))
|
||||
for _, t := range threads {
|
||||
if strings.Contains(t.Provenance, "auto_thread") || strings.HasPrefix(t.Motivation, "Auto-detected") {
|
||||
selected = append(selected, t)
|
||||
}
|
||||
if len(selected) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return mcp.NewToolResultText("No auto-threaded candidates. Run `knox thread auto` or wait for the watch tick first."), nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
for _, t := range selected {
|
||||
obs, _ := kdb.ThreadObservations(t.ID)
|
||||
fmt.Fprintf(&b, "THREAD #%d [%s] %d obs\n title: %s\n motivation: %s\n",
|
||||
t.ID, t.Priority, t.EntryCount, t.Title, t.Motivation)
|
||||
if t.Tags != "[]" {
|
||||
fmt.Fprintf(&b, " tags: %s\n", t.Tags)
|
||||
}
|
||||
b.WriteString(" linked observations:\n")
|
||||
for _, o := range obs {
|
||||
line := strings.TrimSpace(o.Title)
|
||||
if o.Summary != "" {
|
||||
line += " — " + strings.TrimSpace(o.Summary)
|
||||
}
|
||||
fmt.Fprintf(&b, " • [%s] %s\n", o.SourceID, truncateStr(line, 160))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return mcp.NewToolResultText(b.String()), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_link ───────────────────────────────────────
|
||||
threadLinkTool := mcp.NewTool("knox_thread_link",
|
||||
mcp.WithDescription("Link an observation to a thread by observation ID. Use knox_get first to find observation IDs."),
|
||||
mcp.WithNumber("thread_id", mcp.Required(), mcp.Description("Thread ID")),
|
||||
mcp.WithNumber("observation_id", mcp.Required(), mcp.Description("Observation ID to link")),
|
||||
mcp.WithString("relevance", mcp.Description("Why this observation matters to the thread")),
|
||||
)
|
||||
|
||||
s.AddTool(threadLinkTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
threadID := int64(clampInt(req, "thread_id", 0, 1, 999999))
|
||||
obsID := int64(clampInt(req, "observation_id", 0, 1, 999999))
|
||||
relevance := getString(req, "relevance", "")
|
||||
if err := kdb.LinkObservationToThread(threadID, obsID, relevance); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Linked observation #%d to thread #%d", obsID, threadID)), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_search ─────────────────────────────────────
|
||||
threadSearchTool := mcp.NewTool("knox_thread_search",
|
||||
mcp.WithDescription("Search threads by motivation, title, or tags. Find in-flight knowledge work by intent."),
|
||||
mcp.WithString("query", mcp.Required(), mcp.Description("Search motivation, title, or tags")),
|
||||
)
|
||||
|
||||
s.AddTool(threadSearchTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
query := getString(req, "query", "")
|
||||
threads, err := kdb.SearchThreadsByMotivation(query)
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
if len(threads) == 0 {
|
||||
return mcp.NewToolResultText(fmt.Sprintf("No threads matching %q", query)), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, t := range threads {
|
||||
b.WriteString(fmt.Sprintf("#%d %-40s [%s/%s] %d entries\n", t.ID, t.Title, t.Status, t.Priority, t.EntryCount))
|
||||
if t.Motivation != "" {
|
||||
b.WriteString(fmt.Sprintf(" %s\n", t.Motivation))
|
||||
}
|
||||
}
|
||||
return mcp.NewToolResultText(b.String()), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_link_entry ─────────────────────────────────
|
||||
threadLinkEntryTool := mcp.NewTool("knox_thread_link_entry",
|
||||
mcp.WithDescription("Link an entry to a thread via provenance graph. Use after knox_search to connect findings to intent."),
|
||||
mcp.WithNumber("thread_id", mcp.Required(), mcp.Description("Thread ID")),
|
||||
mcp.WithString("fingerprint", mcp.Required(), mcp.Description("Entry fingerprint")),
|
||||
mcp.WithString("relation", mcp.Description("Relation type: produced (default), references, motivated")),
|
||||
)
|
||||
|
||||
s.AddTool(threadLinkEntryTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
threadID := int64(clampInt(req, "thread_id", 0, 1, 999999))
|
||||
fp := getString(req, "fingerprint", "")
|
||||
relation := getString(req, "relation", "produced")
|
||||
if err := kdb.LinkEntryToThread(threadID, fp, relation); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Linked %s to thread #%d [%s]", shortFP(fp), threadID, relation)), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_relate ─────────────────────────────────────
|
||||
threadRelateTool := mcp.NewTool("knox_thread_relate",
|
||||
mcp.WithDescription("Relate two threads (parent spawns child, threads merge, etc.). Builds the thread-inquiry graph."),
|
||||
mcp.WithNumber("parent_id", mcp.Required(), mcp.Description("Parent thread ID")),
|
||||
mcp.WithNumber("child_id", mcp.Required(), mcp.Description("Child thread ID")),
|
||||
mcp.WithString("relation", mcp.Description("Relation: spawned (default), merged_into, references, supersedes")),
|
||||
)
|
||||
|
||||
s.AddTool(threadRelateTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
parentID := int64(clampInt(req, "parent_id", 0, 1, 999999))
|
||||
childID := int64(clampInt(req, "child_id", 0, 1, 999999))
|
||||
relation := getString(req, "relation", "spawned")
|
||||
if err := kdb.LinkEntryToThread(childID, db.ThreadFP(parentID), relation); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Related thread #%d -> #%d [%s]", parentID, childID, relation)), nil
|
||||
})
|
||||
|
||||
// ─── knox_thread_golden ─────────────────────────────────────
|
||||
goldenTool := mcp.NewTool("knox_thread_golden",
|
||||
mcp.WithDescription("Set, get, or clear the golden thread. The golden thread represents your current focus — new observations auto-link to it."),
|
||||
mcp.WithNumber("thread_id", mcp.Description("Thread ID to set as golden. Omit to query current. Pass 0 to clear.")),
|
||||
)
|
||||
|
||||
s.AddTool(goldenTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
threadID := int64(clampInt(req, "thread_id", 0, 0, 999999))
|
||||
|
||||
if threadID == 0 {
|
||||
// Query or clear
|
||||
current, _ := kdb.GoldenThreadID()
|
||||
if current == 0 {
|
||||
return mcp.NewToolResultText("No golden thread set."), nil
|
||||
}
|
||||
// If thread_id was explicitly 0 and a golden exists, clear it
|
||||
if _, ok := req.Params.Arguments["thread_id"]; ok {
|
||||
kdb.SetGoldenThread(0)
|
||||
t, _ := kdb.GetThread(current)
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Golden thread cleared (was #%d: %s)", current, t.Title)), nil
|
||||
}
|
||||
t, _ := kdb.GetThread(current)
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Golden thread: #%d %s [%s]\n %s", t.ID, t.Title, t.Status, t.Motivation)), nil
|
||||
}
|
||||
|
||||
if err := kdb.SetGoldenThread(threadID); err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
t, _ := kdb.GetThread(threadID)
|
||||
return mcp.NewToolResultText(fmt.Sprintf("Golden thread set to #%d: %s", threadID, t.Title)), nil
|
||||
})
|
||||
|
||||
// ─── knox_topics ───────────────────────────────────────────
|
||||
topicsTool := mcp.NewTool("knox_topics",
|
||||
mcp.WithDescription("List auto-detected topic clusters from TF-IDF analysis. Shows what topics appear across all sources."),
|
||||
mcp.WithNumber("limit", mcp.Description("Max topics (default 10)")),
|
||||
)
|
||||
|
||||
s.AddTool(topicsTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
limit := clampInt(req, "limit", 10, 1, 30)
|
||||
entries, _ := kdb.RecentEntries(2000)
|
||||
tfidf := index.BuildTFIDF(entries)
|
||||
clusters := tfidf.Cluster(2, limit)
|
||||
|
||||
if len(clusters) == 0 {
|
||||
return mcp.NewToolResultText("No topic clusters detected."), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, c := range clusters {
|
||||
total := 0
|
||||
for _, v := range c.BySource {
|
||||
total += v
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%d. %s (%d entries)\n", i+1, c.Name, total))
|
||||
var sources []string
|
||||
for src, count := range c.BySource {
|
||||
sources = append(sources, fmt.Sprintf("%s:%d", src, count))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" Sources: %s\n", strings.Join(sources, ", ")))
|
||||
}
|
||||
return mcp.NewToolResultText(b.String()), nil
|
||||
})
|
||||
|
||||
// ─── knox_gaps ─────────────────────────────────────────────
|
||||
gapsTool := mcp.NewTool("knox_gaps",
|
||||
mcp.WithDescription("Find topics with browser activity but no skill coverage. Identifies candidates for new skill creation."),
|
||||
)
|
||||
|
||||
s.AddTool(gapsTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
entries, _ := kdb.RecentEntries(2000)
|
||||
|
||||
skillKeywords := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
if e.SourceID == "skills-catalog" {
|
||||
for _, w := range index.Tokenize(e.Title + " " + e.Summary) {
|
||||
skillKeywords[w] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tfidf := index.BuildTFIDF(entries)
|
||||
clusters := tfidf.Cluster(2, 15)
|
||||
gaps := tfidf.Gaps(clusters, skillKeywords)
|
||||
|
||||
if len(gaps) == 0 {
|
||||
return mcp.NewToolResultText("No gaps found."), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Found %d topics with browser activity but NO skill coverage:\n\n", len(gaps)))
|
||||
for i, g := range gaps {
|
||||
browserCount := g.BySource["browser-history"]
|
||||
b.WriteString(fmt.Sprintf("%d. %s — %d browser visits\n", i+1, g.Name, browserCount))
|
||||
}
|
||||
return mcp.NewToolResultText(b.String()), nil
|
||||
})
|
||||
|
||||
// ─── knox_git_status ───────────────────────────────────────
|
||||
gitStatusTool := mcp.NewTool("knox_git_status",
|
||||
mcp.WithDescription("List local git repository status captured by Knox (branch, clean/dirty, ahead/behind, HEAD, last commit). Pass project to filter."),
|
||||
mcp.WithString("project", mcp.Description("Optional project/repo name substring filter")),
|
||||
)
|
||||
|
||||
s.AddTool(gitStatusTool, func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
entries, err := kdb.EntriesBySource("git")
|
||||
if err != nil {
|
||||
return errorResult(err.Error()), nil
|
||||
}
|
||||
filter, _ := req.Params.Arguments["project"].(string)
|
||||
|
||||
if len(entries) == 0 {
|
||||
return mcp.NewToolResultText("No git status entries yet. Run `knox git` or wait for the daemon's git timer."), nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
shown := 0
|
||||
for _, e := range entries {
|
||||
if filter != "" && !strings.Contains(strings.ToLower(e.Project), strings.ToLower(filter)) {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%-24s %s\n", e.Project, e.Summary))
|
||||
shown++
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\n%d / %d repos", shown, len(entries)))
|
||||
return mcp.NewToolResultText(b.String()), nil
|
||||
})
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── Format helpers ──────────────────────────────────────────
|
||||
|
||||
func formatEntries(entries []db.Entry, detail detailLevel, kdb *db.KnoxDB) string {
|
||||
switch detail {
|
||||
case detailBrief:
|
||||
return formatEntriesBrief(entries)
|
||||
case detailFull:
|
||||
var b strings.Builder
|
||||
for i, e := range entries {
|
||||
if i > 0 {
|
||||
b.WriteString("---\n")
|
||||
}
|
||||
b.WriteString(formatEntryFull(&e))
|
||||
}
|
||||
return b.String()
|
||||
default:
|
||||
var b strings.Builder
|
||||
for _, e := range entries {
|
||||
b.WriteString(formatEntryNormal(&e))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
}
|
||||
|
||||
func formatEntriesBrief(entries []db.Entry) string {
|
||||
var b strings.Builder
|
||||
for _, e := range entries {
|
||||
b.WriteString(fmt.Sprintf("%-8s | %-40s | %-14s | %s\n",
|
||||
shortFP(e.Fingerprint), truncateStr(e.Title, 38), e.SourceID, e.Project))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatEntryNormal(e *db.Entry) string {
|
||||
return fmt.Sprintf("%-8s %-40s [%-14s %-4s] seen:%d conf:%.1f\n %s",
|
||||
shortFP(e.Fingerprint), truncateStr(e.Title, 38), e.SourceID, e.Project,
|
||||
e.RefCount, e.LastConfidence, truncateStr(e.Summary, 90))
|
||||
}
|
||||
|
||||
func formatEntryFull(e *db.Entry) string {
|
||||
return fmt.Sprintf(`fingerprint: %s
|
||||
title: %s
|
||||
source: %s
|
||||
path: %s
|
||||
project: %s
|
||||
type: %s
|
||||
first_seen: %s
|
||||
last_seen: %s
|
||||
ref_count: %d
|
||||
confidence: %.1f
|
||||
summary: %s`,
|
||||
e.Fingerprint, e.Title, e.SourceID, e.SourcePath, e.Project, e.ContentType,
|
||||
e.FirstSeen, e.LastSeen, e.RefCount, e.LastConfidence, e.Summary)
|
||||
}
|
||||
|
||||
// ─── Scope filtering ─────────────────────────────────────────
|
||||
|
||||
func filterByScope(entries []db.Entry, scope string) []db.Entry {
|
||||
switch strings.ToLower(scope) {
|
||||
case "skills":
|
||||
return filterSource(entries, "skills-catalog")
|
||||
case "sessions":
|
||||
return filterSource(entries, "opencode-session")
|
||||
case "logs":
|
||||
return filterSource(entries, "opencode-log")
|
||||
default:
|
||||
return entries
|
||||
}
|
||||
}
|
||||
|
||||
func filterSource(entries []db.Entry, sourceID string) []db.Entry {
|
||||
var filtered []db.Entry
|
||||
for _, e := range entries {
|
||||
if e.SourceID == sourceID {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// ─── Argument helpers ────────────────────────────────────────
|
||||
|
||||
func getString(req mcp.CallToolRequest, key, def string) string {
|
||||
if v, ok := req.Params.Arguments[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func clampInt(req mcp.CallToolRequest, key string, def, min, max int) int {
|
||||
if v, ok := req.Params.Arguments[key].(float64); ok {
|
||||
n := int(v)
|
||||
if n < min {
|
||||
return min
|
||||
}
|
||||
if n > max {
|
||||
return max
|
||||
}
|
||||
return n
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func shortFP(fp string) string {
|
||||
if len(fp) > 8 {
|
||||
return fp[:8]
|
||||
}
|
||||
return fp
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "..."
|
||||
}
|
||||
|
||||
func errorResult(msg string) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
IsError: true,
|
||||
Content: []mcp.Content{mcp.NewTextContent(msg)},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/ingest"
|
||||
)
|
||||
|
||||
func NewObsidianCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
var vault string
|
||||
cmd := &cobra.Command{
|
||||
Use: "obsidian",
|
||||
Short: "Ingest Obsidian notes into the knowledge index",
|
||||
Long: `Scans an Obsidian vault for markdown notes, extracts frontmatter
|
||||
(title, tags, dates), and fingerprints them into the index.
|
||||
|
||||
Auto-detects vault path from ~/.config/obsidian/obsidian.json
|
||||
or override with --vault.`,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
ing := ingest.NewObsidianIngester(vault)
|
||||
log.Printf("[knox] ingesting Obsidian notes...")
|
||||
|
||||
results, err := ing.IngestAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("obsidian ingest: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
fmt.Println("No Obsidian notes found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
var newCount, dupCount int
|
||||
for _, result := range results {
|
||||
_, isNew, err := kdb.RecordObservation(db.ObservationRecord{
|
||||
Fingerprint: result.Fingerprint,
|
||||
SourceID: result.SourceID,
|
||||
SourcePath: result.SourcePath,
|
||||
Project: result.Project,
|
||||
ContentType: result.ContentType,
|
||||
Title: result.Title,
|
||||
Summary: result.Summary,
|
||||
CreatedAt: result.CreatedAt,
|
||||
LineStart: result.LineStart,
|
||||
LineEnd: result.LineEnd,
|
||||
Confidence: result.Confidence,
|
||||
IngesterVersion: result.IngesterVersion,
|
||||
Trigger: "obsidian_ingest",
|
||||
Provenance: ingest.ProvenanceJSON(result.Provenance),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[knox] db error: %v", err)
|
||||
continue
|
||||
}
|
||||
if isNew {
|
||||
newCount++
|
||||
} else {
|
||||
dupCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Obsidian notes: %d new, %d updated\n", newCount, dupCount)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&vault, "vault", "v", "", "Obsidian vault path (auto-detect if empty)")
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/index"
|
||||
)
|
||||
|
||||
func paginate(entries []db.Entry, page, limit int) []db.Entry {
|
||||
start := (page - 1) * limit
|
||||
if start >= len(entries) {
|
||||
return nil
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(entries) {
|
||||
end = len(entries)
|
||||
}
|
||||
return entries[start:end]
|
||||
}
|
||||
|
||||
func NewStatusCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
var jsonOut bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show knowledge index statistics",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
stats, err := kdb.Stats()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if jsonOut {
|
||||
b, _ := json.MarshalIndent(stats, "", " ")
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
}
|
||||
fmt.Println("Knox Knowledge Index")
|
||||
fmt.Println("====================")
|
||||
for k, v := range stats {
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
fmt.Printf(" %-24s %d\n", k+":", val)
|
||||
case string:
|
||||
fmt.Printf(" %-24s %s\n", k+":", val)
|
||||
default:
|
||||
fmt.Printf(" %-24s %v\n", k+":", val)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewQueryCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
var page, limit int
|
||||
cmd := &cobra.Command{
|
||||
Use: "query [search text]",
|
||||
Short: "Search the knowledge index",
|
||||
Long: "Search the knowledge index. Reads from stdin pipe if no positional arg given.",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
query := ""
|
||||
if len(args) > 0 {
|
||||
query = args[0]
|
||||
} else {
|
||||
stdin, _ := os.ReadFile(os.Stdin.Name())
|
||||
query = strings.TrimSpace(string(stdin))
|
||||
}
|
||||
if query == "" {
|
||||
return fmt.Errorf("search query required (positional arg or stdin pipe)")
|
||||
}
|
||||
results, err := kdb.Search(query, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results = paginate(results, page, limit)
|
||||
if len(results) == 0 {
|
||||
fmt.Println("No results found.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Found %d results for %q (page %d, %d per page):\n\n", len(results), query, page, limit)
|
||||
for _, r := range results {
|
||||
fmt.Printf(" %-8s %-30s [%s] %s\n", shortFP(r.Fingerprint), truncateStr(r.Title, 30), r.Project, r.SourceID)
|
||||
if r.Summary != "" {
|
||||
fmt.Printf(" %-8s %s\n", "", truncateStr(r.Summary, 80))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&page, "page", "p", 1, "Page number (1-based)")
|
||||
cmd.Flags().IntVarP(&limit, "limit", "l", 20, "Results per page")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewRecentCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
var page, limit int
|
||||
cmd := &cobra.Command{
|
||||
Use: "recent",
|
||||
Short: "Show recent knowledge entries",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
all, err := kdb.RecentEntries(limit * 10)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries := paginate(all, page, limit)
|
||||
if len(entries) == 0 {
|
||||
fmt.Println("No entries yet. Run `knox watch` to start ingesting.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Recent %d entries (page %d, %d per page):\n\n", len(entries), page, limit)
|
||||
for _, e := range entries {
|
||||
fmt.Printf(" %-8s %-30s [%s] %s\n", shortFP(e.Fingerprint), truncateStr(e.Title, 30), e.Project, e.SourceID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&page, "page", "p", 1, "Page number (1-based)")
|
||||
cmd.Flags().IntVarP(&limit, "limit", "l", 20, "Results per page")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewReflectCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "reflect",
|
||||
Short: "Process pending sessions for reflection",
|
||||
Long: "Scans for sessions not yet indexed, runs reflection, and reports findings.",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
reflector := index.NewReflector(kdb)
|
||||
results, err := reflector.Reflect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) == 0 {
|
||||
fmt.Println("All caught up — no pending sessions to reflect on.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Reflected %d sessions:\n\n", len(results))
|
||||
fmt.Print(reflector.FormatResult(results))
|
||||
|
||||
gaps, _ := reflector.Gaps()
|
||||
if len(gaps) > 0 {
|
||||
fmt.Printf("\nProjects with indexed knowledge:\n")
|
||||
for _, g := range gaps {
|
||||
fmt.Printf(" %-20s %d entries\n", g.Project, g.TotalEntries)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getHomeDir() (string, error) {
|
||||
return os.UserHomeDir()
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/index"
|
||||
)
|
||||
|
||||
func NewTopicsCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "topics",
|
||||
Short: "Auto-detect topic clusters from TF-IDF analysis of the knowledge index",
|
||||
Long: `Scans all entries, computes TF-IDF term scores, and clusters related
|
||||
observations into topics. Shows gap analysis for browser-researched
|
||||
topics not covered by skills.
|
||||
|
||||
Subcommands: list, gaps`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(newTopicListCmd(kdb))
|
||||
cmd.AddCommand(newTopicGapsCmd(kdb))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTopicListCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List auto-detected topic clusters",
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
entries, err := kdb.RecentEntries(2000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tfidf := index.BuildTFIDF(entries)
|
||||
clusters := tfidf.Cluster(2, 15)
|
||||
|
||||
if len(clusters) == 0 {
|
||||
fmt.Println("No topic clusters detected.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("%-3s %-50s %-8s %s\n", "ID", "Topic", "Entries", "Sources")
|
||||
fmt.Println(strings.Repeat("-", 75))
|
||||
for i, c := range clusters {
|
||||
total := 0
|
||||
for _, v := range c.BySource {
|
||||
total += v
|
||||
}
|
||||
sources := ""
|
||||
for src, count := range c.BySource {
|
||||
if sources != "" {
|
||||
sources += ", "
|
||||
}
|
||||
sources += fmt.Sprintf("%s:%d", shortSource(src), count)
|
||||
}
|
||||
fmt.Printf("%-3d %-50s %-8d %s\n", i+1, truncateStr(c.Name, 48), total, sources)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newTopicGapsCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "gaps",
|
||||
Short: "Show topics with browser activity but no skill coverage",
|
||||
Long: `Cross-references browser history against the skills catalog.
|
||||
Flags topics that have significant browser research but zero skill entries.
|
||||
|
||||
These are candidates for new skill creation.`,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
entries, err := kdb.RecentEntries(2000)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build skill keyword set
|
||||
skillKeywords := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
if e.SourceID == "skills-catalog" {
|
||||
for _, w := range tokenize(e.Title + " " + e.Summary) {
|
||||
skillKeywords[w] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tfidf := index.BuildTFIDF(entries)
|
||||
clusters := tfidf.Cluster(2, 15)
|
||||
gaps := tfidf.Gaps(clusters, skillKeywords)
|
||||
|
||||
if len(gaps) == 0 {
|
||||
fmt.Println("No gaps found — all browser-researched topics have skill coverage.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("\nFound %d topics with browser activity but NO skill coverage:\n\n", len(gaps))
|
||||
for i, g := range gaps {
|
||||
browserCount := g.BySource["browser-history"]
|
||||
fmt.Printf("%d. %s\n", i+1, g.Name)
|
||||
fmt.Printf(" %d browser visits · keywords: %v\n", browserCount, g.Keywords)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
fmt.Println("Consider creating skills for these topics with `knox asses` or via opencode.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func shortSource(s string) string {
|
||||
switch s {
|
||||
case "opencode-session":
|
||||
return "session"
|
||||
case "skills-catalog":
|
||||
return "skill"
|
||||
case "browser-history":
|
||||
return "browser"
|
||||
case "obsidian":
|
||||
return "notes"
|
||||
case "opencode-log":
|
||||
return "logs"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tokenize(text string) []string {
|
||||
re := regexp.MustCompile(`[a-z]{4,}`)
|
||||
return re.FindAllString(strings.ToLower(text), -1)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/david/knox/internal/db"
|
||||
"github.com/david/knox/internal/watch"
|
||||
)
|
||||
|
||||
func NewWatchCmd(kdb *db.KnoxDB) *cobra.Command {
|
||||
var dirs []string
|
||||
var quiet bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "watch",
|
||||
Short: "Watch directories for new session data and ingest into index",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(dirs) == 0 {
|
||||
dirs = defaultWatchDirs()
|
||||
}
|
||||
if quiet {
|
||||
log.SetOutput(io.Discard)
|
||||
}
|
||||
log.Printf("[knox] starting watcher, scanning %d directories", len(dirs))
|
||||
w := watch.New(kdb, dirs)
|
||||
if err := w.Start(); err != nil {
|
||||
log.Fatalf("watcher error: %v", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVarP(&dirs, "dir", "d", nil, "Directories to watch (default: opencode storage/log + skills)")
|
||||
cmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Suppress per-entry log output")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func defaultWatchDirs() []string {
|
||||
home, _ := getHomeDir()
|
||||
return []string{
|
||||
home + "/.local/share/opencode/storage/session_diff",
|
||||
home + "/.local/share/opencode/log",
|
||||
home + "/.skills",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user