zzet--gortex
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 行
4.5 KiB
Go
172 行
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
|
|
}
|