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

440 lines
13 KiB
Go

package mcp
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/platform"
)
// Retrieval query logging — an append-only JSONL record of every
// retrieval-shaped tool call (question, corpus, result size, latency,
// zero-result signal). This is the daemon-side data substrate that
// offline recall tuning and the retrieval-precision eval harness read
// from; without it there is no record of what was asked or how big the
// answer was. Fail-silent by design: a logging error never disturbs a
// tool call.
//
// Gated by environment, mirroring the established telemetry knobs:
// - GORTEX_QUERY_LOG_DISABLE=1 turn logging off entirely
// - GORTEX_QUERY_LOG=<path> override the log file location
// - GORTEX_QUERY_LOG_RESPONSES=1 also persist the full response body
// - GORTEX_QUERY_LOG_MAX_MB=<n> rotation threshold (default 64 MiB)
//
// Default location: <CacheDir>/query-log.jsonl (disposable telemetry).
// queryLogRecord is one JSONL line. Field order/names are stable wire —
// downstream tooling (analyze kind:"retrieval_log", offline scripts)
// parses these keys.
type queryLogRecord struct {
TS string `json:"ts"`
Tool string `json:"tool"`
Repo string `json:"repo,omitempty"`
Project string `json:"project,omitempty"`
Question string `json:"question"`
Corpus string `json:"corpus,omitempty"`
NodesReturned int `json:"nodes_returned"`
DurationMS float64 `json:"duration_ms"`
ResultBytes int `json:"result_bytes"`
ZeroResult bool `json:"zero_result"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Session string `json:"session,omitempty"`
Response string `json:"response,omitempty"`
}
// queryToolSpec describes how to extract a logged tool's "question"
// and a default corpus label.
type queryToolSpec struct {
questionKeys []string // arg keys tried in order for the question text
corpus string // default corpus label
corpusKey string // arg key whose value overrides corpus (optional)
}
// retrievalToolSpecs enumerates the tools whose calls are recorded. A
// tool absent from this map is never logged — the substrate is about
// retrieval recall, not the full tool surface (tool_profile already
// covers registry introspection).
var retrievalToolSpecs = map[string]queryToolSpec{
"search_symbols": {questionKeys: []string{"query"}, corpus: "code", corpusKey: "corpus"},
"smart_context": {questionKeys: []string{"task"}, corpus: "context"},
"find_usages": {questionKeys: []string{"symbol", "id", "name"}, corpus: "usages"},
"get_callers": {questionKeys: []string{"symbol", "id", "name"}, corpus: "callers"},
"get_call_chain": {questionKeys: []string{"from", "to", "symbol"}, corpus: "call_chain"},
"trace_path": {questionKeys: []string{"source_id", "sink_id"}, corpus: "call_path"},
"search_text": {questionKeys: []string{"query", "pattern"}, corpus: "text"},
"search_ast": {questionKeys: []string{"query", "pattern"}, corpus: "ast"},
"winnow_symbols": {questionKeys: []string{"text_match", "query"}, corpus: "code"},
"context_closure": {questionKeys: []string{"seeds", "symbol", "id"}, corpus: "closure"},
"ask": {questionKeys: []string{"question"}, corpus: "ask"},
"find_implementations": {questionKeys: []string{"symbol", "interface", "id"}, corpus: "implementations"},
"nav": {questionKeys: []string{"query", "from", "symbol"}, corpus: "nav"},
"graph_query": {questionKeys: []string{"query"}, corpus: "graph_query"},
"suggest_queries": {questionKeys: []string{"query", "task"}, corpus: "suggest"},
"search_artifacts": {questionKeys: []string{"query"}, corpus: "artifacts"},
"graph_completion_search": {questionKeys: []string{"query", "prefix"}, corpus: "completion"},
}
// countResultKeys are the JSON array keys a retrieval response is most
// likely to carry its result list under, tried before a generic scan.
var countResultKeys = []string{
"results", "symbols", "usages", "callers", "callees", "relevant_symbols",
"rows", "matches", "candidates", "implementations", "nodes", "chain",
"items", "hits", "members", "edges",
}
// queryLogger appends retrieval records to a JSONL file. Safe for
// concurrent use across sessions. A disabled logger is a cheap no-op.
type queryLogger struct {
mu sync.Mutex
enabled bool
logResponses bool
path string
maxBytes int64
file *os.File
written int64
}
// newQueryLogger constructs the logger from the environment. It never
// fails: an unresolvable path or a disable flag yields a no-op logger.
func newQueryLogger() *queryLogger {
ql := &queryLogger{maxBytes: 64 << 20}
if isTruthyEnv(os.Getenv("GORTEX_QUERY_LOG_DISABLE")) {
return ql // disabled
}
path := strings.TrimSpace(os.Getenv("GORTEX_QUERY_LOG"))
if path == "" {
path = filepath.Join(platform.CacheDir(), "query-log.jsonl")
} else if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
path = filepath.Join(home, path[2:])
}
}
ql.path = path
ql.enabled = true
ql.logResponses = isTruthyEnv(os.Getenv("GORTEX_QUERY_LOG_RESPONSES"))
if v := strings.TrimSpace(os.Getenv("GORTEX_QUERY_LOG_MAX_MB")); v != "" {
if mb, err := strconv.ParseInt(v, 10, 64); err == nil && mb > 0 {
ql.maxBytes = mb << 20
}
}
return ql
}
// isTruthyEnv reports whether an env value means "on".
func isTruthyEnv(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
}
return false
}
// shouldLog reports whether a tool's calls are recorded.
func (q *queryLogger) shouldLog(tool string) bool {
if q == nil || !q.enabled {
return false
}
_, ok := retrievalToolSpecs[tool]
return ok
}
// Path returns the resolved log file path ("" when disabled).
func (q *queryLogger) Path() string {
if q == nil {
return ""
}
return q.path
}
// record builds and appends one record for a completed tool call.
// override (>=0) is an exact result count reported by the handler via
// the request context; when <0 the count is parsed from the response.
func (q *queryLogger) record(s *Server, ctx context.Context, req mcp.CallToolRequest, res *mcp.CallToolResult, hErr error, start time.Time) {
if q == nil || !q.enabled {
return
}
tool := req.Params.Name
spec, ok := retrievalToolSpecs[tool]
if !ok {
return
}
args := req.GetArguments()
question := firstStringArg(args, spec.questionKeys)
corpus := spec.corpus
if spec.corpusKey != "" {
if c := stringArg(args, spec.corpusKey); c != "" {
corpus = c
}
}
text := toolResultText(res)
okCall := hErr == nil && (res == nil || !res.IsError)
var count int
if override := resultCountFromContext(ctx); override >= 0 {
count = override
} else if okCall {
count = countFromResultText(text)
}
rec := queryLogRecord{
TS: time.Now().UTC().Format(time.RFC3339Nano),
Tool: tool,
Question: truncateRunes(question, 1024),
Corpus: corpus,
NodesReturned: count,
DurationMS: float64(time.Since(start).Microseconds()) / 1000.0,
ResultBytes: len(text),
ZeroResult: okCall && count == 0,
OK: okCall,
}
if s != nil {
rec.Repo, rec.Project = s.sessionLocality(ctx)
rec.Session = SessionIDFromContext(ctx)
}
if !okCall {
if hErr != nil {
rec.Error = truncateRunes(hErr.Error(), 512)
} else {
rec.Error = truncateRunes(strings.TrimSpace(text), 512)
}
}
if q.logResponses {
rec.Response = text
}
line, err := json.Marshal(&rec)
if err != nil {
return
}
q.append(line)
}
// append writes one JSONL line, opening (and rotating) the file lazily.
// All errors are swallowed — logging must never break a tool call.
func (q *queryLogger) append(line []byte) {
q.mu.Lock()
defer q.mu.Unlock()
if q.file == nil {
if err := os.MkdirAll(filepath.Dir(q.path), 0o755); err != nil {
q.enabled = false
return
}
f, err := os.OpenFile(q.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
q.enabled = false
return
}
q.file = f
if fi, err := f.Stat(); err == nil {
q.written = fi.Size()
}
}
if q.maxBytes > 0 && q.written+int64(len(line))+1 > q.maxBytes {
q.rotateLocked()
}
n, err := q.file.Write(append(line, '\n'))
if err != nil {
// A write failure (full disk, revoked handle) disables logging
// rather than spamming errors on every subsequent call.
_ = q.file.Close()
q.file = nil
q.enabled = false
return
}
q.written += int64(n)
}
// rotateLocked renames the current log to "<path>.1" (keeping one
// backup) and reopens a fresh file. Caller holds q.mu.
func (q *queryLogger) rotateLocked() {
if q.file != nil {
_ = q.file.Close()
q.file = nil
}
_ = os.Rename(q.path, q.path+".1")
f, err := os.OpenFile(q.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
q.enabled = false
return
}
q.file = f
q.written = 0
}
// Close flushes and closes the underlying file.
func (q *queryLogger) Close() {
if q == nil {
return
}
q.mu.Lock()
defer q.mu.Unlock()
if q.file != nil {
_ = q.file.Close()
q.file = nil
}
}
// --- result-count extraction ------------------------------------------------
// resultCountContextKey carries a handler-reported exact result count
// through the request context. Handlers that know their result size set
// it via recordQueryResultCount; the logger prefers it over parsing.
type resultCountContextKey struct{}
type resultCountHolder struct{ n int }
// withResultCount installs a result-count holder on ctx. Returns the
// new context and the holder. The wrapper calls this for logged tools.
func withResultCount(ctx context.Context) (context.Context, *resultCountHolder) {
h := &resultCountHolder{n: -1}
return context.WithValue(ctx, resultCountContextKey{}, h), h
}
// recordQueryResultCount lets a handler report its exact result count
// for the query log. No-op when the context carries no holder (the
// tool isn't logged, or logging is disabled).
func recordQueryResultCount(ctx context.Context, n int) {
if h, ok := ctx.Value(resultCountContextKey{}).(*resultCountHolder); ok && h != nil {
h.n = n
}
}
// resultCountFromContext returns the handler-reported count, or -1.
func resultCountFromContext(ctx context.Context) int {
if h, ok := ctx.Value(resultCountContextKey{}).(*resultCountHolder); ok && h != nil {
return h.n
}
return -1
}
// countFromResultText best-effort counts the results in a tool response
// across the formats Gortex emits (JSON object/array, GCX1, TOON/text).
// Used only when the handler did not report an exact count.
func countFromResultText(text string) int {
t := strings.TrimSpace(text)
if t == "" {
return 0
}
// not_modified / etag short-circuits carry no fresh results.
if strings.HasPrefix(t, "GCX1") {
// Header line + one line per row; subtract the header.
n := strings.Count(t, "\n")
if n <= 0 {
return 0
}
return n
}
switch t[0] {
case '{':
var m map[string]json.RawMessage
if json.Unmarshal([]byte(t), &m) == nil {
if raw, ok := m["not_modified"]; ok {
var b bool
if json.Unmarshal(raw, &b) == nil && b {
return 0
}
}
if raw, ok := m["total"]; ok {
var n int
if json.Unmarshal(raw, &n) == nil {
return n
}
}
best := -1
for _, key := range countResultKeys {
if raw, ok := m[key]; ok {
if c := jsonArrayLen(raw); c > best {
best = c
}
}
}
if best < 0 {
for _, raw := range m {
if c := jsonArrayLen(raw); c > best {
best = c
}
}
}
if best >= 0 {
return best
}
return 0
}
case '[':
if c := jsonArrayLen(json.RawMessage(t)); c >= 0 {
return c
}
}
// TOON / plain text: count non-empty lines.
n := 0
for _, ln := range strings.Split(t, "\n") {
if strings.TrimSpace(ln) != "" {
n++
}
}
return n
}
// jsonArrayLen returns the element count of a JSON array message, or -1
// when it isn't an array.
func jsonArrayLen(raw json.RawMessage) int {
var arr []json.RawMessage
if json.Unmarshal(raw, &arr) == nil {
return len(arr)
}
return -1
}
// firstStringArg returns the first present, non-empty arg among keys,
// stringifying scalars and joining string/array values.
func firstStringArg(args map[string]any, keys []string) string {
for _, k := range keys {
if v, ok := args[k]; ok {
if s := anyToQueryString(v); s != "" {
return s
}
}
}
return ""
}
// anyToQueryString renders an arg value as a compact question string.
func anyToQueryString(v any) string {
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case []any:
parts := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
parts = append(parts, s)
}
}
return strings.Join(parts, ",")
case float64:
return strconv.FormatFloat(t, 'g', -1, 64)
case bool:
return strconv.FormatBool(t)
}
return ""
}
// truncateRunes caps a string at n runes, appending an ellipsis marker.
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}