// Package kbclient is a typed HTTP client for the HarborForge.Backend // knowledge-base REST API. Used by the dynamic-kb-* tools to power // agent browse + cache flows. // // Route shapes verified against // HarborForge.Backend/app/api/routers/knowledge.py: // // GET /knowledge-bases[?project=] list KBs // GET /knowledge-bases/{kb_id_or_code}/topics list topics in a KB // GET /knowledge-bases/{kb_id_or_code}/tree full hierarchy // GET /knowledge-facts/{fact_id} single fact // // HF's KB hierarchy is KB → Topics → Categories → Facts. ListFacts // uses the /tree endpoint and filters client-side to the requested // topic ids (the backend has no flat per-topic-facts route as of // the route audit on 2026-06-08). // // Auth: v1 uses plugin-level APIKey via Bearer header. Per-agent // token resolution (matching agent's hf-token from secret-mgr) is a // TODO — needs SDK extension for plugin → secret-mgr access. package kbclient import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // Client is the HTTP wrapper. Construct once at plugin init; safe to // share across goroutines. type Client struct { BaseURL string APIKey string HTTP *http.Client } // New returns a client with a 30s default timeout. BaseURL has any // trailing slash trimmed. func New(baseURL, apiKey string) *Client { return &Client{ BaseURL: strings.TrimRight(baseURL, "/"), APIKey: apiKey, HTTP: &http.Client{Timeout: 30 * time.Second}, } } // ---- Payload types matching HF backend response shapes ---- // KBSummary is one row from GET /knowledge-bases. type KBSummary struct { ID int `json:"id"` KnowledgeBaseCode string `json:"knowledge_base_code"` Title string `json:"title"` Description string `json:"description"` } // TopicSummary is one row from GET /knowledge-bases/{id}/topics. type TopicSummary struct { ID int `json:"id"` Topic string `json:"topic"` Description string `json:"description"` } // FactSummary is one item the dynamic-kb-list-facts tool surfaces to // the agent. Built client-side from the tree response — title + a // snippet of the content. Kept light so list-facts output stays // scan-able even on large KBs. type FactSummary struct { ID int `json:"id"` TopicID int `json:"topic_id"` TopicSlug string `json:"topic_slug"` Title string `json:"title"` Snippet string `json:"snippet"` } // Fact is the full fact body fetched by dynamic-kb-cache before // writing into kb-block.json. type Fact struct { ID int `json:"id"` KBCode string `json:"kb_code"` TopicSlug string `json:"topic_slug"` Content string `json:"content"` } // ---- HTTP methods ---- // ListKBs hits GET /knowledge-bases[?project=]. Empty // projectCode = unfiltered. func (c *Client) ListKBs(ctx context.Context, projectCode string) ([]KBSummary, error) { path := "/knowledge-bases" if projectCode != "" { path += "?project=" + url.QueryEscape(projectCode) } var out []KBSummary if err := c.getJSON(ctx, path, &out); err != nil { return nil, err } return out, nil } // ListTopics hits GET /knowledge-bases/{kbCode}/topics. kbCode is the // human KB code (e.g. "KB-PAYROT"); the backend resolves either int // id or code in the same path slot. func (c *Client) ListTopics(ctx context.Context, kbCode string) ([]TopicSummary, error) { if kbCode == "" { return nil, fmt.Errorf("kbclient: kb-code required") } path := "/knowledge-bases/" + url.PathEscape(kbCode) + "/topics" var out []TopicSummary if err := c.getJSON(ctx, path, &out); err != nil { return nil, err } return out, nil } // kbTree mirrors the shape of GET /knowledge-bases/{id}/tree. We only // pull the fields we need to build FactSummary; unknown fields are // dropped silently. type kbTree struct { KnowledgeBaseCode string `json:"knowledge_base_code"` Topics []treeNode `json:"topics"` } type treeNode struct { ID int `json:"id"` Topic string `json:"topic"` // topic-level Category string `json:"category"` // category-level Title string `json:"title"` // fact-level Content string `json:"content"` // fact-level Categories []treeNode `json:"categories"` // children of topic / category Facts []treeNode `json:"facts"` // fact children } // ListFacts hits GET /knowledge-bases/{kbCode}/tree, walks the tree, // and flattens facts under the requested topic ids into FactSummary // rows. Snippet = first 120 chars of content (UTF-8 safe-truncated). func (c *Client) ListFacts(ctx context.Context, kbCode string, topicIDs []int) ([]FactSummary, error) { if kbCode == "" { return nil, fmt.Errorf("kbclient: kb-code required") } if len(topicIDs) == 0 { return nil, fmt.Errorf("kbclient: topic-ids required") } path := "/knowledge-bases/" + url.PathEscape(kbCode) + "/tree" var tree kbTree if err := c.getJSON(ctx, path, &tree); err != nil { return nil, err } wantTopic := map[int]bool{} for _, id := range topicIDs { wantTopic[id] = true } var out []FactSummary for _, topic := range tree.Topics { if !wantTopic[topic.ID] { continue } walkFactsInto(&out, topic.ID, topic.Topic, topic.Categories, topic.Facts) } return out, nil } // walkFactsInto descends category subtree, collecting facts. cur is // the topic context (id + slug) for tagging. func walkFactsInto(out *[]FactSummary, topicID int, topicSlug string, cats, facts []treeNode) { for _, f := range facts { *out = append(*out, FactSummary{ ID: f.ID, TopicID: topicID, TopicSlug: topicSlug, Title: f.Title, Snippet: snippet(f.Content, 120), }) } for _, c := range cats { walkFactsInto(out, topicID, topicSlug, c.Categories, c.Facts) } } // GetFacts pulls each fact's full content by ID. Calls // /knowledge-facts/{id} per fact; rate-limited to sequential issue // (small N expected — agents cache a handful per call). Pass kbCode // for tagging the returned Fact records; backend doesn't return it. func (c *Client) GetFacts(ctx context.Context, kbCode string, factIDs []int) ([]Fact, error) { if kbCode == "" { return nil, fmt.Errorf("kbclient: kb-code required") } out := make([]Fact, 0, len(factIDs)) for _, id := range factIDs { path := "/knowledge-facts/" + strconv.Itoa(id) var node struct { ID int `json:"id"` Title string `json:"title"` Content string `json:"content"` TopicID int `json:"topic_id"` TopicSlug string `json:"topic"` CategoryID int `json:"category_id"` } if err := c.getJSON(ctx, path, &node); err != nil { // 404 → skip silently; other errors fail the batch if isNotFound(err) { continue } return nil, err } out = append(out, Fact{ ID: node.ID, KBCode: kbCode, TopicSlug: node.TopicSlug, Content: node.Content, }) } return out, nil } // ---- transport plumbing ---- func (c *Client) getJSON(ctx context.Context, path string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) if err != nil { return err } if c.APIKey != "" { req.Header.Set("Authorization", "Bearer "+c.APIKey) } req.Header.Set("Accept", "application/json") res, err := c.HTTP.Do(req) if err != nil { return fmt.Errorf("GET %s: %w", path, err) } defer res.Body.Close() body, _ := io.ReadAll(res.Body) if res.StatusCode == 404 { return notFoundErr{path: path} } if res.StatusCode >= 400 { return fmt.Errorf("GET %s: HTTP %d: %s", path, res.StatusCode, truncate(body, 200)) } if len(body) == 0 { return nil } if err := json.Unmarshal(body, out); err != nil { return fmt.Errorf("GET %s decode: %w (body=%q)", path, err, truncate(body, 200)) } return nil } type notFoundErr struct{ path string } func (e notFoundErr) Error() string { return "GET " + e.path + ": 404 not found" } func isNotFound(err error) bool { _, ok := err.(notFoundErr) return ok } func truncate(b []byte, n int) string { if len(b) > n { return string(b[:n]) + "..." } return string(b) } // snippet returns the first n bytes of s, padded with "..." if cut. // Naive byte slice; HF content is UTF-8 and we accept the small risk // of cutting mid-rune for log-level rendering (the agent gets full // content via GetFacts before caching). func snippet(s string, n int) string { s = strings.TrimSpace(s) if len(s) <= n { return s } return s[:n] + "..." } // Compile-time guard: caller code expected to wrap bytes.Buffer usage // (currently unused but here for future POST/PATCH endpoints). var _ = bytes.NewReader([]byte{})