656 lines
26 KiB
Go
656 lines
26 KiB
Go
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)},
|
|
}
|
|
}
|