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

121 lines
3.9 KiB
Go

package persistence
import (
"compress/gzip"
"encoding/gob"
"fmt"
"os"
"path/filepath"
)
const (
keywordFile = "keyword.gob.gz"
maxKeywords = 4000
maxKeywordEntriesPerKey = 10
)
// KeywordMatch records one (keyword -> symbol) association. HitCount
// is how many times the agent picked this symbol following a query
// that contained the keyword; LastUsed is a unix timestamp (seconds)
// for decay and reaping. Structurally identical to ComboMatch but
// kept as a distinct type so the two stores' schemas can diverge.
type KeywordMatch struct {
SymbolID string
HitCount uint32
LastUsed int64
// MissCount is the implicit-negative tally: how many times the
// agent was shown this symbol for a query carrying the keyword but
// skipped over it to pick a lower-ranked result. The per-keyword
// boost nets HitCount-MissCount, so a symbol the agent keeps passing
// over loses its learned boost. A zero value (legacy data) is the
// pre-negative-signal behaviour. Decays on the same clock as hits.
MissCount uint32
}
// KeywordAssoc holds all recorded matches for one query keyword
// within a single repo. Ordered most-hit-first after any record.
type KeywordAssoc struct {
Keyword string
Matches []KeywordMatch
}
// KeywordStore is the persisted per-keyword association index for one
// repo. Where ComboStore keys on the whole normalized query,
// KeywordStore keys on each surviving query token -- so a new task
// with overlapping keywords but different phrasing still inherits the
// associations its keywords earned. Separate file from combo and
// feedback so each subsystem's schema evolves independently.
type KeywordStore struct {
Version string
RepoPath string
Keywords []KeywordAssoc
}
// KeywordDir returns the on-disk directory for keyword storage.
// Shares the repo cache key with combo / feedback so all repo-scoped
// state lives together.
func KeywordDir(cacheDir, repoPath string) string {
return filepath.Join(cacheDir, RepoCacheKey(repoPath))
}
// LoadKeyword reads the keyword store from disk. A missing file is
// not an error -- it yields an empty store.
func LoadKeyword(dir string) (*KeywordStore, error) {
path := filepath.Join(dir, keywordFile)
f, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return &KeywordStore{}, nil
}
return nil, fmt.Errorf("persistence: open keyword: %w", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return nil, fmt.Errorf("persistence: gzip reader keyword: %w", err)
}
defer gz.Close()
var store KeywordStore
if err := gob.NewDecoder(gz).Decode(&store); err != nil {
return nil, fmt.Errorf("persistence: gob decode keyword: %w", err)
}
return &store, nil
}
// SaveKeyword writes the keyword store with gob+gzip compression.
// Trims the oldest keywords if over cap so the file cannot grow
// unboundedly on a long-running daemon.
func SaveKeyword(dir string, store *KeywordStore) error {
if len(store.Keywords) > maxKeywords {
trim := len(store.Keywords) - maxKeywords
store.Keywords = store.Keywords[trim:]
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("persistence: mkdir keyword: %w", err)
}
path := filepath.Join(dir, keywordFile)
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("persistence: create keyword: %w", err)
}
gz := gzip.NewWriter(f)
enc := gob.NewEncoder(gz)
if err := enc.Encode(store); err != nil {
_ = gz.Close()
_ = f.Close()
return fmt.Errorf("persistence: gob encode keyword: %w", err)
}
if err := gz.Close(); err != nil {
_ = f.Close()
return fmt.Errorf("persistence: gzip close keyword: %w", err)
}
return f.Close()
}
// MaxKeywordEntries returns the cap on matches per keyword. Tighter
// than MaxComboEntries -- a single keyword is a coarser key than a
// whole query, so its match list is held shorter to stay precise.
func MaxKeywordEntries() int { return maxKeywordEntriesPerKey }