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
172 lines
4.5 KiB
Go
172 lines
4.5 KiB
Go
package mcp
|
||
|
||
import (
|
||
"math"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/zzet/gortex/internal/persistence"
|
||
)
|
||
|
||
// AgentMode selects the decay regime for frecency + combo. AI sessions
|
||
// churn through symbols in minutes, not days, so recent accesses should
|
||
// dominate much more aggressively than in a human-editor setting.
|
||
type AgentMode int
|
||
|
||
const (
|
||
// ModeAI is the default for MCP server runs — 3-day half-life.
|
||
ModeAI AgentMode = iota
|
||
// ModeHuman is reserved for interactive CLI / editor integrations
|
||
// where sessions span days or weeks.
|
||
ModeHuman
|
||
)
|
||
|
||
// Exponential half-life constants, chosen so exp(-k * halfLife) = 0.5.
|
||
// k = ln(2) / halfLife_in_days.
|
||
const (
|
||
aiHalfLifeDays = 3.0
|
||
humanHalfLifeDays = 10.0
|
||
secondsPerDay = 86400.0
|
||
|
||
// maxFrecencyBoost caps the multiplier applied to a single result so
|
||
// frecency can never override a strong BM25 + combo signal on its own.
|
||
maxFrecencyBoost = 1.5
|
||
)
|
||
|
||
// frecencyTracker records implicit "this symbol was useful" signals by
|
||
// timestamping every consume call and exposing a decayed score. Persists
|
||
// per repo alongside feedback + combo.
|
||
type frecencyTracker struct {
|
||
mu sync.Mutex
|
||
store persistence.FrecencyStore
|
||
dir string
|
||
mode AgentMode
|
||
|
||
now func() int64 // seconds since unix epoch; overridable for tests
|
||
}
|
||
|
||
func newFrecencyTracker(cacheDir, repoPath string, mode AgentMode) *frecencyTracker {
|
||
nowFn := func() int64 { return time.Now().Unix() }
|
||
if cacheDir == "" || repoPath == "" {
|
||
return &frecencyTracker{mode: mode, now: nowFn}
|
||
}
|
||
dir := persistence.FrecencyDir(cacheDir, repoPath)
|
||
ft := &frecencyTracker{dir: dir, mode: mode, now: nowFn}
|
||
if loaded, err := persistence.LoadFrecency(dir); err == nil && loaded != nil {
|
||
ft.store = *loaded
|
||
}
|
||
return ft
|
||
}
|
||
|
||
// Record appends one access timestamp for symbolID. The per-symbol history
|
||
// is bounded; once the cap is hit the oldest access is dropped.
|
||
func (ft *frecencyTracker) Record(symbolID string) {
|
||
if ft == nil || symbolID == "" {
|
||
return
|
||
}
|
||
ft.mu.Lock()
|
||
defer ft.mu.Unlock()
|
||
|
||
now := ft.now()
|
||
cap := persistence.MaxFrecencyAccesses()
|
||
|
||
idx := ft.findLocked(symbolID)
|
||
if idx < 0 {
|
||
ft.store.Symbols = append(ft.store.Symbols, persistence.FrecencyAccesses{
|
||
SymbolID: symbolID,
|
||
Times: []int64{now},
|
||
})
|
||
} else {
|
||
s := &ft.store.Symbols[idx]
|
||
s.Times = append(s.Times, now)
|
||
if len(s.Times) > cap {
|
||
s.Times = s.Times[len(s.Times)-cap:]
|
||
}
|
||
ft.moveToEndLocked(idx)
|
||
}
|
||
|
||
if ft.dir == "" {
|
||
return
|
||
}
|
||
_ = persistence.SaveFrecency(ft.dir, &ft.store)
|
||
}
|
||
|
||
// Score returns a decayed frecency score for symbolID. Zero when unknown
|
||
// or when every access has decayed below the numerical floor.
|
||
func (ft *frecencyTracker) Score(symbolID string) float64 {
|
||
if ft == nil || symbolID == "" {
|
||
return 0
|
||
}
|
||
ft.mu.Lock()
|
||
defer ft.mu.Unlock()
|
||
idx := ft.findLocked(symbolID)
|
||
if idx < 0 {
|
||
return 0
|
||
}
|
||
k := ft.decayPerDay()
|
||
now := float64(ft.now())
|
||
var score float64
|
||
for _, t := range ft.store.Symbols[idx].Times {
|
||
dtDays := (now - float64(t)) / secondsPerDay
|
||
if dtDays < 0 {
|
||
dtDays = 0
|
||
}
|
||
score += math.Exp(-k * dtDays)
|
||
}
|
||
return score
|
||
}
|
||
|
||
// BoostFor converts a raw frecency score into a multiplier in [1, maxFrecencyBoost].
|
||
// A multiplier of 1 means no reweighting — callers can multiply directly.
|
||
func (ft *frecencyTracker) BoostFor(symbolID string) float64 {
|
||
s := ft.Score(symbolID)
|
||
if s <= 0 {
|
||
return 1.0
|
||
}
|
||
// Map a typical recent-access score (1–maxFrecencyAccesses) into
|
||
// [0, maxFrecencyBoost-1]. Saturates quickly so a very hot symbol
|
||
// doesn't steamroll moderately hot ones.
|
||
extra := s / float64(persistence.MaxFrecencyAccesses()) * (maxFrecencyBoost - 1.0)
|
||
if extra > maxFrecencyBoost-1.0 {
|
||
extra = maxFrecencyBoost - 1.0
|
||
}
|
||
return 1.0 + extra
|
||
}
|
||
|
||
// decayPerDay returns k such that the weight halves every halfLife days.
|
||
func (ft *frecencyTracker) decayPerDay() float64 {
|
||
halfLife := aiHalfLifeDays
|
||
if ft.mode == ModeHuman {
|
||
halfLife = humanHalfLifeDays
|
||
}
|
||
return math.Ln2 / halfLife
|
||
}
|
||
|
||
func (ft *frecencyTracker) findLocked(id string) int {
|
||
for i := range ft.store.Symbols {
|
||
if ft.store.Symbols[i].SymbolID == id {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func (ft *frecencyTracker) moveToEndLocked(idx int) {
|
||
if idx < 0 || idx >= len(ft.store.Symbols)-1 {
|
||
return
|
||
}
|
||
s := ft.store.Symbols[idx]
|
||
copy(ft.store.Symbols[idx:], ft.store.Symbols[idx+1:])
|
||
ft.store.Symbols[len(ft.store.Symbols)-1] = s
|
||
}
|
||
|
||
// HasData reports whether any accesses have been recorded.
|
||
func (ft *frecencyTracker) HasData() bool {
|
||
if ft == nil {
|
||
return false
|
||
}
|
||
ft.mu.Lock()
|
||
defer ft.mu.Unlock()
|
||
return len(ft.store.Symbols) > 0
|
||
}
|