Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

116 lines
4.1 KiB
Go

package mcp
import (
"context"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// whyEntry is one knowledge source that motivates the queried symbol.
type whyEntry struct {
SourceID string `json:"source_id"`
Kind string `json:"kind"` // rationale | content
RationaleKind string `json:"rationale_kind,omitempty"`
AssetKind string `json:"asset_kind,omitempty"`
Name string `json:"name,omitempty"`
Text string `json:"text,omitempty"`
Signal string `json:"signal,omitempty"`
}
// whyResult is the why-query response: the rationale that motivates a symbol.
type whyResult struct {
Symbol string `json:"symbol"`
Count int `json:"count"`
Rationale []whyEntry `json:"rationale"`
Note string `json:"note,omitempty"`
}
func (s *Server) registerWhyTool() {
s.addTool(
mcp.NewTool("why",
mcp.WithDescription("Explain WHY a code symbol exists: a graph-first, one-hop walk over the rationale that motivates it — projected store_memory decisions / incidents and the content documents (ADRs, specs, slides) whose text names it. Use to recover the reasoning behind code before changing it. Curated rationale ranks before lexical content links."),
mcp.WithString("id", mcp.Required(), mcp.Description("Symbol node ID or name to explain.")),
mcp.WithNumber("limit", mcp.Description("Max rationale sources (default: 20).")),
mcp.WithString("scope", mcp.Description("Memory scope to reconcile on read: workspace (default) or global.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleWhy,
)
}
func (s *Server) handleWhy(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
target, err := req.RequireString("id")
if err != nil {
return mcp.NewToolResultError("id is required"), nil
}
id, candidates := s.resolveSymbolTarget(ctx, target)
if len(candidates) > 0 {
return s.symbolDisambiguationResult(ctx, req, "why", target, candidates)
}
// Reconcile-on-read: project any memories that predate the daemon so the
// graph-first traversal below sees them.
s.reconcileRationale(strings.TrimSpace(req.GetString("scope", "")))
entries := s.whyEntriesFor(ctx, id)
if limit := req.GetInt("limit", 20); limit > 0 && len(entries) > limit {
entries = entries[:limit]
}
res := whyResult{Symbol: id, Count: len(entries), Rationale: entries}
if len(entries) == 0 {
res.Note = "No recorded rationale links this symbol. Try search_symbols corpus:content for related documents, or store_memory to record the decision."
}
return s.respondJSONOrTOON(ctx, req, res)
}
// whyEntriesFor walks the incoming EdgeMotivates edges of a symbol and returns
// the knowledge sources that motivate it — projected rationale nodes and
// content chunks — ranked curated-rationale-first. One graph hop, no fan-out.
func (s *Server) whyEntriesFor(ctx context.Context, id string) []whyEntry {
var entries []whyEntry
seen := map[string]bool{}
for _, e := range s.engineFor(ctx).GetInEdges(id) {
if e == nil || e.Kind != graph.EdgeMotivates || seen[e.From] {
continue
}
seen[e.From] = true
n := s.graph.GetNode(e.From)
if n == nil {
continue
}
entry := whyEntry{SourceID: e.From, Name: n.Name, Signal: whyMetaStr(e.Meta, "signal")}
switch n.Kind {
case graph.KindRationale:
entry.Kind = "rationale"
entry.RationaleKind = whyMetaStr(n.Meta, "rationale_kind")
entry.Text = whyMetaStr(n.Meta, "section_text")
case graph.KindDoc:
entry.Kind = "content"
entry.AssetKind = whyMetaStr(n.Meta, "asset_kind")
entry.Text = whyMetaStr(n.Meta, "section_text")
default:
entry.Kind = string(n.Kind)
}
entries = append(entries, entry)
}
// Curated rationale (a projected memory) outranks a lexical content match.
sort.SliceStable(entries, func(i, j int) bool { return whyRank(entries[i]) < whyRank(entries[j]) })
return entries
}
// whyRank orders rationale (curated memory) before content (lexical match).
func whyRank(e whyEntry) int {
if e.Kind == "rationale" {
return 0
}
return 1
}
func whyMetaStr(m map[string]any, k string) string {
v, _ := m[k].(string)
return v
}