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
121 lines
3.9 KiB
Go
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 }
|