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
333 lines
6.9 KiB
Go
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
|
|
}
|