Files
iris-context/internal/scoring/frecency.go
T
VERSE 860b475b53 feat: scoring and frecency (#37)
Enhance lookup mechanics and priority propagation (`spec/lookup.go`)
Remove the requirement of typing a leading `-` when searching for
options, allowing flag suggestions to surface naturally during input
Accurately forward the `Priority` field from registry specs into the
`Suggestion` struct so the scoring engine evaluates relevance properly

Integrate scoring into the main suggestion pipeline
(`root/suggestions.go`)
Collect context signals using `scoring.CollectSignals(cwd, query,
rootCmd, store)` with the live working directory retrieved from
`spec.GetCWD()`
Pass the entire deduplicated list across spec, history and AI items
through `scoring.Score(deduped, signals)` to compute scores and sort by
descending order before rendering on screen

Record execution history in Frecency Store (`root/wrapper.go`)
Save the last executed command to `lastSubmittedCommand` whenever Enter
is pressed or a suggestion is selected
Trigger `store.Record(...)` asynchronously within the `IRIS_CMD_STOP`
hook using a dedicated goroutine guarded by panic recovery and timeout
limits, preventing blocks on the main shell thread

Optimize workspace identification for AI
(`internal/ai/context_provider.go`)
Use `workspace.DetectCached(cwd)` to instantly fetch ecosystem context
across Git, Node, Go, Rust, Python and Docker alongside active git
branch state
This caching mechanism avoids redundant subprocess execution when
outside git repositories, significantly speeding up prompt context
generation
2026-07-13 17:56:29 +07:00

333 lines
6.9 KiB
Go

package scoring
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
type FrecencyEntry struct {
Cmd string
Cwd string
Count int
LastUsed time.Time
RawScore float64
}
type FrecencyStore struct {
db *sql.DB
mu sync.Mutex
}
func NewFrecencyStore(dbPath string) (*FrecencyStore, error) {
if dbPath == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
dbPath = filepath.Join(home, ".local", "share", "iris", "history.db")
}
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("failed to create directory for history.db: %w", err)
}
_ = os.Chmod(dir, 0700)
if f, err := os.OpenFile(dbPath, os.O_CREATE, 0600); err == nil {
_ = f.Close()
}
_ = os.Chmod(dbPath, 0600)
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
store := &FrecencyStore{db: db}
if err := store.initSchema(context.Background()); err != nil {
_ = db.Close()
return nil, err
}
_ = os.Chmod(dbPath, 0600)
return store, nil
}
func (f *FrecencyStore) configureSQLite(ctx context.Context) error {
_, err := f.db.ExecContext(ctx, "PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;")
return err
}
func (f *FrecencyStore) initSchema(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 2000*time.Millisecond)
defer cancel()
if err := f.configureSQLite(ctxTimeout); err != nil {
return err
}
schema := `
CREATE TABLE IF NOT EXISTS history_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cmd TEXT NOT NULL,
cwd TEXT NOT NULL,
count INTEGER DEFAULT 1,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(cmd, cwd)
);
CREATE INDEX IF NOT EXISTS idx_history_cwd_cmd ON history_entries(cwd, cmd);
`
_, err := f.db.ExecContext(ctxTimeout, schema)
return err
}
func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string) error {
if f == nil {
return nil
}
cmd = strings.TrimSpace(cmd)
if cmd == "" || cwd == "" {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
query := `
INSERT INTO history_entries (cmd, cwd, count, last_used)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(cmd, cwd) DO UPDATE SET
count = count + 1,
last_used = CURRENT_TIMESTAMP;
`
_, err := f.db.ExecContext(ctxTimeout, query, cmd, cwd)
return err
}
func (f *FrecencyStore) RawScore(count int, lastUsed time.Time) float64 {
if count <= 0 {
return 0
}
age := max(time.Since(lastUsed), 0)
var weight float64
switch {
case age <= time.Hour:
weight = 100.0
case age <= 24*time.Hour:
weight = 50.0
case age <= 7*24*time.Hour:
weight = 20.0
case age <= 30*24*time.Hour:
weight = 5.0
default:
weight = 1.0
}
return float64(count) * weight
}
func (f *FrecencyStore) QueryLocal(ctx context.Context, cwd, prefix string, limit int) ([]FrecencyEntry, error) {
if f == nil {
return nil, nil
}
if limit <= 0 {
limit = 50
}
f.mu.Lock()
defer f.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
var rows *sql.Rows
var err error
if prefix != "" {
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ? AND cmd LIKE ?`, cwd, prefix+"%")
} else {
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cwd = ?`, cwd)
}
if err != nil {
return nil, err
}
defer rows.Close()
var entries []FrecencyEntry
for rows.Next() {
var cmd, rCwd string
var count int
var lastUsedRaw string
if err := rows.Scan(&cmd, &rCwd, &count, &lastUsedRaw); err != nil {
continue
}
t, err := parseTimestamp(lastUsedRaw)
if err != nil {
t = time.Now()
}
entries = append(entries, FrecencyEntry{
Cmd: cmd,
Cwd: rCwd,
Count: count,
LastUsed: t,
RawScore: f.RawScore(count, t),
})
}
if err := rows.Err(); err != nil {
return nil, err
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].RawScore > entries[j].RawScore
})
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
func (f *FrecencyStore) QueryGlobal(ctx context.Context, prefix string, limit int) ([]FrecencyEntry, error) {
if f == nil {
return nil, nil
}
if limit <= 0 {
limit = 50
}
f.mu.Lock()
defer f.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
var rows *sql.Rows
var err error
if prefix != "" {
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries WHERE cmd LIKE ?`, prefix+"%")
} else {
rows, err = f.db.QueryContext(ctxTimeout, `SELECT cmd, cwd, count, last_used FROM history_entries`)
}
if err != nil {
return nil, err
}
defer rows.Close()
dedupe := make(map[string]*FrecencyEntry)
for rows.Next() {
var cmd, rCwd string
var count int
var lastUsedRaw string
if err := rows.Scan(&cmd, &rCwd, &count, &lastUsedRaw); err != nil {
continue
}
t, err := parseTimestamp(lastUsedRaw)
if err != nil {
t = time.Now()
}
score := f.RawScore(count, t)
if existing, found := dedupe[cmd]; found {
existing.Count += count
existing.RawScore += score
if t.After(existing.LastUsed) {
existing.LastUsed = t
existing.Cwd = rCwd
}
} else {
dedupe[cmd] = &FrecencyEntry{
Cmd: cmd,
Cwd: rCwd,
Count: count,
LastUsed: t,
RawScore: score,
}
}
}
if err := rows.Err(); err != nil {
return nil, err
}
var entries []FrecencyEntry
for _, entry := range dedupe {
entries = append(entries, *entry)
}
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].RawScore > entries[j].RawScore
})
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
func (f *FrecencyStore) Close() error {
if f == nil {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
if f.db != nil {
return f.db.Close()
}
return nil
}
func parseTimestamp(s string) (time.Time, error) {
if t, err := time.Parse("2006-01-02 15:04:05", s); err == nil {
return t, nil
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
if t, err := time.Parse("2006-01-02 15:04:05.999999999-07:00", s); err == nil {
return t, nil
}
return time.Parse("2006-01-02", s)
}
var (
globalFrecencyStore *FrecencyStore
globalFrecencyMu sync.Mutex
)
func GetFrecencyStore() (*FrecencyStore, error) {
globalFrecencyMu.Lock()
defer globalFrecencyMu.Unlock()
if globalFrecencyStore != nil {
return globalFrecencyStore, nil
}
store, err := NewFrecencyStore("")
if err != nil {
return nil, err
}
globalFrecencyStore = store
return globalFrecencyStore, nil
}