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

589 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mcp
import (
"sort"
"strings"
"sync"
"time"
"github.com/zzet/gortex/internal/persistence"
"github.com/zzet/gortex/internal/search/rerank"
)
// comboManager is the in-memory wrapper for ComboStore. It records
// (query → symbol) associations whenever an agent consumes a symbol that
// was returned by a recent search, and exposes a boost score used by the
// search-result reranker. The reranker applies a multiplier once a match
// has been seen at least comboMinHits times, matching FFF's min-3 gate.
type comboManager struct {
mu sync.Mutex
store persistence.ComboStore
dir string
mode AgentMode
// kwStore is the per-keyword association index -- a sibling of
// `store` that keys on each surviving query token rather than the
// whole normalized query. It is persisted to its own file
// (kwDir) so the two schemas evolve independently. Guarded by the
// same mu.
kwStore persistence.KeywordStore
kwDir string
// Overridable for tests. Seconds since the unix epoch, not a Time, so
// comparisons stay fast and the struct stays gob-friendly.
now func() int64
}
const (
// Min hits before a (query, symbol) pair starts receiving a boost.
// Below this we treat the association as noise.
comboMinHits = 3
// Boost per hit, capped at comboMaxBoost. BM25 scores in practice
// land in the 15 range; a multiplier of ~1.3x per extra hit means a
// well-established combo can dominate a cold result of similar BM25
// score without overwhelming a much stronger BM25 hit.
comboBoostPerHit = 0.3
comboMaxBoost = 3.0
// Max age in seconds for a combo match before it's evicted on access.
// AI mode: 7 days — agents churn through queries quickly, stale combos
// become noise. Human mode: 30 days — sessions span weeks and a "I
// always mean this file when I search for X" association is genuine.
comboMaxAgeAISec = int64(7 * 86400)
comboMaxAgeHumanSec = int64(30 * 86400)
// Per-keyword boost gate. A keyword is a coarser key than a whole
// query, so the per-keyword path uses a lower min-hits threshold
// and a smaller per-hit boost than the exact-query path -- a
// keyword-only match should nudge, never dominate.
keywordMinHits = 2
keywordBoostPerHit = 0.12
// keywordMaxBoost caps the per-keyword multiplier well below
// comboMaxBoost so an exact-query combo always out-boosts a
// keyword-only one.
keywordMaxBoost = 1.6
)
func newComboManager(cacheDir, repoPath string, mode AgentMode) *comboManager {
nowFn := func() int64 { return time.Now().Unix() }
if cacheDir == "" || repoPath == "" {
return &comboManager{mode: mode, now: nowFn}
}
dir := persistence.ComboDir(cacheDir, repoPath)
kwDir := persistence.KeywordDir(cacheDir, repoPath)
cm := &comboManager{dir: dir, kwDir: kwDir, mode: mode, now: nowFn}
if loaded, err := persistence.LoadCombo(dir); err == nil && loaded != nil {
cm.store = *loaded
}
if loaded, err := persistence.LoadKeyword(kwDir); err == nil && loaded != nil {
cm.kwStore = *loaded
}
return cm
}
func (cm *comboManager) maxAgeSec() int64 {
if cm.mode == ModeHuman {
return comboMaxAgeHumanSec
}
return comboMaxAgeAISec
}
// reapStaleLocked drops matches older than the mode's max age. Called
// lazily on Record and BoostMap so we never keep a background goroutine
// just for GC.
func (cm *comboManager) reapStaleLocked() {
cutoff := cm.now() - cm.maxAgeSec()
queries := cm.store.Queries[:0]
for qi := range cm.store.Queries {
q := &cm.store.Queries[qi]
fresh := q.Matches[:0]
for _, m := range q.Matches {
if m.LastUsed >= cutoff {
fresh = append(fresh, m)
}
}
q.Matches = fresh
// Drop a query once its last match expires. Without this the
// emptied shell lingers forever: it bloats findQueryLocked's
// linear scan and makes HasData report data that no longer exists.
if len(fresh) > 0 {
queries = append(queries, *q)
}
}
cm.store.Queries = queries
// The per-keyword index decays on the same clock as the
// whole-query index -- a keyword association no agent has
// reinforced inside the mode's window is stale noise.
keywords := cm.kwStore.Keywords[:0]
for ki := range cm.kwStore.Keywords {
k := &cm.kwStore.Keywords[ki]
fresh := k.Matches[:0]
for _, m := range k.Matches {
if m.LastUsed >= cutoff {
fresh = append(fresh, m)
}
}
k.Matches = fresh
if len(fresh) > 0 {
keywords = append(keywords, *k)
}
}
cm.kwStore.Keywords = keywords
}
// normalizeQuery collapses whitespace and lowercases. Matches FFF's
// blake3(project + "::" + query) pattern at a coarser grain — we don't
// need exact byte fidelity, just "treat variant spacings as the same".
func normalizeQuery(q string) string {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
return ""
}
fields := strings.Fields(q)
return strings.Join(fields, " ")
}
// Record tallies one (query → symbol) hit. If the query is unseen, a new
// entry is created; if the symbol already has an entry for the query, its
// HitCount bumps by one and LastUsed refreshes. Always flushed to disk.
func (cm *comboManager) Record(rawQuery, symbolID string) {
if cm == nil {
return
}
q := normalizeQuery(rawQuery)
if q == "" || symbolID == "" {
return
}
cm.mu.Lock()
defer cm.mu.Unlock()
cm.reapStaleLocked()
cm.recordOneLocked(rawQuery, q, symbolID, cm.now())
cm.persistLocked()
}
// RecordBatch tallies several (query → symbol) hits for the same query
// in one locked section with a single persist. Used by the tool-call
// observer: when the agent opens a file (get_editing_context /
// read_file) that contains symbols a recent search returned, every such
// symbol is credited to that search's query at once.
func (cm *comboManager) RecordBatch(rawQuery string, symbolIDs []string) {
if cm == nil || len(symbolIDs) == 0 {
return
}
q := normalizeQuery(rawQuery)
if q == "" {
return
}
cm.mu.Lock()
defer cm.mu.Unlock()
cm.reapStaleLocked()
now := cm.now()
recorded := false
for _, id := range symbolIDs {
if id == "" {
continue
}
cm.recordOneLocked(rawQuery, q, id, now)
recorded = true
}
if recorded {
cm.persistLocked()
}
}
// RecordNegative tallies an implicit negative — the agent was shown
// these symbols for a query carrying the given keywords but skipped over
// them to pick a lower-ranked result. Recorded only in the per-keyword
// index (the cluster grain), so KeywordBoostMap nets hitsmisses and a
// persistently-skipped symbol loses its learned boost. The exact
// whole-query index stays positive-only.
func (cm *comboManager) RecordNegative(rawQuery string, skippedIDs []string) {
if cm == nil || len(skippedIDs) == 0 {
return
}
keywords := keywordTokens(rawQuery)
if len(keywords) == 0 {
return
}
cm.mu.Lock()
defer cm.mu.Unlock()
cm.reapStaleLocked()
now := cm.now()
recorded := false
for _, id := range skippedIDs {
if id == "" {
continue
}
for _, kw := range keywords {
cm.recordKeywordMissLocked(kw, id, now)
recorded = true
}
}
if recorded && cm.kwDir != "" {
_ = persistence.SaveKeyword(cm.kwDir, &cm.kwStore)
}
}
// recordOneLocked tallies one (query → symbol) hit in both the
// whole-query and per-keyword indexes. Caller holds cm.mu and is
// responsible for persistence.
func (cm *comboManager) recordOneLocked(rawQuery, q, symbolID string, now int64) {
idx := cm.findQueryLocked(q)
if idx < 0 {
cm.store.Queries = append(cm.store.Queries, persistence.ComboQuery{
Query: q,
Matches: []persistence.ComboMatch{{SymbolID: symbolID, HitCount: 1, LastUsed: now}},
})
} else {
cq := &cm.store.Queries[idx]
mIdx := -1
for i := range cq.Matches {
if cq.Matches[i].SymbolID == symbolID {
mIdx = i
break
}
}
if mIdx < 0 {
cq.Matches = append(cq.Matches, persistence.ComboMatch{
SymbolID: symbolID, HitCount: 1, LastUsed: now,
})
} else {
cq.Matches[mIdx].HitCount++
cq.Matches[mIdx].LastUsed = now
}
// Keep matches ordered by hit count descending so hot symbols float
// to the top — cheap because the list is tiny (capped below).
sort.Slice(cq.Matches, func(i, j int) bool {
return cq.Matches[i].HitCount > cq.Matches[j].HitCount
})
if cap := persistence.MaxComboEntries(); len(cq.Matches) > cap {
cq.Matches = cq.Matches[:cap]
}
// Move the recently-touched query to the tail so SaveCombo's MRU
// trim preserves it.
cm.moveToEndLocked(idx)
}
// Mirror the hit into the per-keyword index: one (keyword ->
// symbol) association per surviving query token. A later task
// with overlapping keywords -- but different phrasing -- inherits
// these even though its whole-query key never matches.
cm.recordKeywordsLocked(rawQuery, symbolID, now)
}
// persistLocked flushes both stores. Caller holds cm.mu.
func (cm *comboManager) persistLocked() {
if cm.dir != "" {
_ = persistence.SaveCombo(cm.dir, &cm.store)
}
if cm.kwDir != "" {
_ = persistence.SaveKeyword(cm.kwDir, &cm.kwStore)
}
}
// BoostMap returns a per-symbol multiplier derived from combo history for
// the given query. Returns nil when the query is empty or has no matches
// above the minimum-hits threshold; callers treat nil as "no reweight".
func (cm *comboManager) BoostMap(rawQuery string) map[string]float64 {
if cm == nil {
return nil
}
q := normalizeQuery(rawQuery)
if q == "" {
return nil
}
cm.mu.Lock()
defer cm.mu.Unlock()
cm.reapStaleLocked()
idx := cm.findQueryLocked(q)
if idx < 0 {
return nil
}
out := make(map[string]float64, len(cm.store.Queries[idx].Matches))
for _, m := range cm.store.Queries[idx].Matches {
if int(m.HitCount) < comboMinHits {
continue
}
extra := float64(int(m.HitCount)-comboMinHits+1) * comboBoostPerHit
boost := 1.0 + extra
if boost > comboMaxBoost {
boost = comboMaxBoost
}
out[m.SymbolID] = boost
}
if len(out) == 0 {
return nil
}
return out
}
// findQueryLocked returns the index of q in store.Queries, or -1.
// Caller must hold cm.mu.
func (cm *comboManager) findQueryLocked(q string) int {
for i := range cm.store.Queries {
if cm.store.Queries[i].Query == q {
return i
}
}
return -1
}
// moveToEndLocked rotates the slice so element at idx lives at the end,
// preserving relative order of the rest. Used to make the MRU trim cheap.
// Caller must hold cm.mu.
func (cm *comboManager) moveToEndLocked(idx int) {
if idx < 0 || idx >= len(cm.store.Queries)-1 {
return
}
q := cm.store.Queries[idx]
copy(cm.store.Queries[idx:], cm.store.Queries[idx+1:])
cm.store.Queries[len(cm.store.Queries)-1] = q
}
// moveKeywordToEndLocked rotates kwStore.Keywords so the entry at idx
// lives at the tail, mirroring moveToEndLocked for the whole-query store.
// SaveKeyword front-trims on overflow, so without this an early-inserted
// but frequently-reinforced keyword would be evicted before a cold
// late-inserted one. Caller must hold cm.mu.
func (cm *comboManager) moveKeywordToEndLocked(idx int) {
if idx < 0 || idx >= len(cm.kwStore.Keywords)-1 {
return
}
k := cm.kwStore.Keywords[idx]
copy(cm.kwStore.Keywords[idx:], cm.kwStore.Keywords[idx+1:])
cm.kwStore.Keywords[len(cm.kwStore.Keywords)-1] = k
}
// HasData reports whether any queries have been recorded. Used by the
// feedback tool to decide whether to surface combo stats.
func (cm *comboManager) HasData() bool {
if cm == nil {
return false
}
cm.mu.Lock()
defer cm.mu.Unlock()
// Reap first so a store holding only expired (soon-to-be-pruned)
// entries doesn't report itself as having data.
cm.reapStaleLocked()
return len(cm.store.Queries) > 0
}
// keywordGenericStopWords are generic software nouns that match
// thousands of unrelated symbols -- mirroring the LLM-expansion
// stoplist. Recording a (keyword -> symbol) hit for one of these
// would pollute the index: the keyword would "associate" with
// whatever symbol the agent happened to pick. Combined with
// assistStopWords (English function words) this is the filter the
// per-keyword recorder applies to every query token.
var keywordGenericStopWords = map[string]struct{}{
"function": {}, "functions": {}, "method": {}, "methods": {},
"library": {}, "module": {}, "modules": {}, "package": {}, "packages": {},
"system": {}, "service": {}, "services": {}, "code": {}, "source": {},
"data": {}, "value": {}, "values": {}, "object": {}, "objects": {},
"item": {}, "items": {}, "info": {}, "information": {}, "content": {},
"thing": {}, "things": {}, "stuff": {}, "general": {}, "common": {},
"basic": {}, "simple": {}, "main": {}, "text": {}, "logic": {},
"process": {}, "handle": {}, "handler": {}, "handling": {}, "flow": {},
"action": {}, "helper": {}, "helpers": {}, "util": {}, "utils": {},
"utility": {}, "type": {}, "kind": {}, "name": {}, "list": {},
}
// keywordTokens extracts the meaningful query keywords from a raw
// query: the camelCase-aware tokens, lowercased and deduplicated,
// minus English function words (assistStopWords) and generic
// software nouns (keywordGenericStopWords), and minus sub-3-char
// fragments that carry no association signal.
func keywordTokens(rawQuery string) []string {
var out []string
seen := map[string]struct{}{}
for _, tok := range rerank.Tokenize(rawQuery) {
t := strings.ToLower(strings.TrimSpace(tok))
if len(t) < 3 {
continue
}
if _, stop := assistStopWords[t]; stop {
continue
}
if _, stop := keywordGenericStopWords[t]; stop {
continue
}
if _, dup := seen[t]; dup {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
return out
}
// recordKeywordsLocked records a (keyword -> symbol) hit for every
// surviving keyword of rawQuery. Caller holds cm.mu. The per-keyword
// match list is kept hit-ordered and capped tighter than the
// whole-query list.
func (cm *comboManager) recordKeywordsLocked(rawQuery, symbolID string, now int64) {
for _, kw := range keywordTokens(rawQuery) {
idx := cm.findKeywordLocked(kw)
if idx < 0 {
cm.kwStore.Keywords = append(cm.kwStore.Keywords, persistence.KeywordAssoc{
Keyword: kw,
Matches: []persistence.KeywordMatch{{SymbolID: symbolID, HitCount: 1, LastUsed: now}},
})
continue
}
ka := &cm.kwStore.Keywords[idx]
mIdx := -1
for i := range ka.Matches {
if ka.Matches[i].SymbolID == symbolID {
mIdx = i
break
}
}
if mIdx < 0 {
ka.Matches = append(ka.Matches, persistence.KeywordMatch{
SymbolID: symbolID, HitCount: 1, LastUsed: now,
})
} else {
ka.Matches[mIdx].HitCount++
ka.Matches[mIdx].LastUsed = now
}
sort.Slice(ka.Matches, func(i, j int) bool {
return ka.Matches[i].HitCount > ka.Matches[j].HitCount
})
if cap := persistence.MaxKeywordEntries(); len(ka.Matches) > cap {
ka.Matches = ka.Matches[:cap]
}
// An existing keyword was re-touched: rotate it to the tail so
// SaveKeyword's front-trim evicts least-recently-used keywords.
cm.moveKeywordToEndLocked(idx)
}
}
// recordKeywordMissLocked bumps the implicit-negative MissCount for a
// (keyword -> symbol) association, creating a miss-only entry when the
// symbol has no prior association for the keyword. Caller holds cm.mu.
func (cm *comboManager) recordKeywordMissLocked(kw, symbolID string, now int64) {
idx := cm.findKeywordLocked(kw)
if idx < 0 {
cm.kwStore.Keywords = append(cm.kwStore.Keywords, persistence.KeywordAssoc{
Keyword: kw,
Matches: []persistence.KeywordMatch{{SymbolID: symbolID, MissCount: 1, LastUsed: now}},
})
return
}
ka := &cm.kwStore.Keywords[idx]
for i := range ka.Matches {
if ka.Matches[i].SymbolID == symbolID {
ka.Matches[i].MissCount++
ka.Matches[i].LastUsed = now
cm.moveKeywordToEndLocked(idx)
return
}
}
ka.Matches = append(ka.Matches, persistence.KeywordMatch{SymbolID: symbolID, MissCount: 1, LastUsed: now})
// Keep the list bounded; order by hit count so a miss-only entry is
// the first to be dropped under pressure (it carries no boost).
if cap := persistence.MaxKeywordEntries(); len(ka.Matches) > cap {
sort.Slice(ka.Matches, func(i, j int) bool {
return ka.Matches[i].HitCount > ka.Matches[j].HitCount
})
ka.Matches = ka.Matches[:cap]
}
cm.moveKeywordToEndLocked(idx)
}
// findKeywordLocked returns the index of kw in kwStore.Keywords, or
// -1. Caller holds cm.mu.
func (cm *comboManager) findKeywordLocked(kw string) int {
for i := range cm.kwStore.Keywords {
if cm.kwStore.Keywords[i].Keyword == kw {
return i
}
}
return -1
}
// KeywordBoostMap returns a per-symbol multiplier derived from the
// per-keyword association index for rawQuery. It tokenizes the query,
// looks up each keyword's recorded symbols, and boosts a symbol by
// how many of the N query keywords it matched (K) scaled by K/N and
// the keyword hit count. A symbol that matches every query keyword
// gets the full keyword boost; one that matches a single keyword of
// five gets a fifth of it.
//
// Returns nil when the query has no usable keywords or nothing clears
// the keywordMinHits gate; callers treat nil as "no reweight". The
// boost is capped at keywordMaxBoost -- below comboMaxBoost -- so an
// exact whole-query combo always out-boosts a keyword-only match.
func (cm *comboManager) KeywordBoostMap(rawQuery string) map[string]float64 {
if cm == nil {
return nil
}
keywords := keywordTokens(rawQuery)
if len(keywords) == 0 {
return nil
}
cm.mu.Lock()
defer cm.mu.Unlock()
cm.reapStaleLocked()
n := float64(len(keywords))
// Per symbol: how many distinct query keywords it matched, and the
// summed hit count across those keywords.
type acc struct {
matched int
hits int
}
per := map[string]*acc{}
for _, kw := range keywords {
idx := cm.findKeywordLocked(kw)
if idx < 0 {
continue
}
for _, m := range cm.kwStore.Keywords[idx].Matches {
// Net the implicit-negative misses against the hits: a
// symbol the agent keeps skipping over loses its boost.
effective := int(m.HitCount) - int(m.MissCount)
if effective < keywordMinHits {
continue
}
a := per[m.SymbolID]
if a == nil {
a = &acc{}
per[m.SymbolID] = a
}
a.matched++
a.hits += effective
}
}
if len(per) == 0 {
return nil
}
out := make(map[string]float64, len(per))
for sym, a := range per {
coverage := float64(a.matched) / n
// Each hit above the gate adds keywordBoostPerHit, scaled by the
// fraction of query keywords the symbol covered. Use the AVERAGE
// per-keyword strength, not the summed hits: a.hits already grows
// with the number of matched keywords, so multiplying the sum by
// coverage (which also grows with that count) would make the boost
// super-linear in coverage instead of the documented ~K/N.
avgHits := float64(a.hits) / float64(a.matched)
extra := (avgHits - keywordMinHits + 1) * keywordBoostPerHit * coverage
if extra < 0 {
extra = 0
}
boost := 1.0 + extra
if boost > keywordMaxBoost {
boost = keywordMaxBoost
}
if boost > 1.0 {
out[sym] = boost
}
}
if len(out) == 0 {
return nil
}
return out
}