Files
iris-context/integration/history.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

261 lines
5.4 KiB
Go

package integration
import (
"bufio"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/versenilvis/fuzzy"
"github.com/versenilvis/iris/integration/shell"
)
var (
historyCache []string
idMapCache map[string]int
searcherCache *fuzzy.Searcher
mu sync.Mutex
lastModTime int64
)
type HistResult struct {
ID int
Cmd string
FuzzyScore int
}
func init() {
idMapCache = make(map[string]int)
}
func SearchHistory(query string, aliases map[string]string) ([]HistResult, error) {
mu.Lock()
defer mu.Unlock()
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
shellName := "bash"
if shell.Current != nil {
shellName = shell.Current.GetName()
}
var histFile string
switch shellName {
case "zsh":
histFile = filepath.Join(home, ".zsh_history")
case "fish":
histFile = filepath.Join(home, ".local", "share", "fish", "fish_history")
default:
histFile = filepath.Join(home, ".bash_history")
}
if info, err := os.Stat(histFile); err == nil {
if info.ModTime().UnixNano() > lastModTime {
historyCache = nil // force reload
idMapCache = make(map[string]int)
lastModTime = info.ModTime().UnixNano()
}
}
// lazy load history if cache is empty
if len(historyCache) == 0 {
file, err := os.Open(histFile)
if err != nil {
return nil, err
}
defer func() { _ = file.Close() }()
var allCmds []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
cmd := line
if shellName == "zsh" {
parts := strings.SplitN(line, ";", 2)
if len(parts) == 2 {
cmd = parts[1]
}
} else if shellName == "bash" {
if strings.HasPrefix(line, "#") && len(line) > 1 {
isTimestamp := true
for _, c := range line[1:] {
if c < '0' || c > '9' {
isTimestamp = false
break
}
}
if isTimestamp {
continue
}
}
} else if shellName == "fish" {
if after, ok := strings.CutPrefix(line, "- cmd: "); ok {
cmd = after
} else {
continue
}
}
cmd = strings.TrimSpace(cmd)
if cmd != "" {
allCmds = append(allCmds, cmd)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
// build historyCache backwards so newest commands come first
seen := make(map[string]bool)
for i := len(allCmds) - 1; i >= 0; i-- {
cmd := allCmds[i]
if !seen[cmd] {
historyCache = append(historyCache, cmd)
seen[cmd] = true
// we assign the ID as the original line number (1-indexed based on allCmds length)
idMapCache[cmd] = i + 1
}
}
searcherCache = fuzzy.NewPlainSearcher(historyCache)
}
if query == "" {
var results []HistResult
limit := min(len(historyCache), 100)
for i := range limit {
cmd := historyCache[i]
results = append(results, HistResult{
ID: idMapCache[cmd],
Cmd: cmd,
})
}
return results, nil
}
var alternativeQueries []string
for name, target := range aliases {
if target != "" {
qLow := strings.ToLower(query)
tLow := strings.ToLower(target)
nLow := strings.ToLower(name)
if qLow == tLow {
alternativeQueries = append(alternativeQueries, name)
} else if strings.HasPrefix(qLow, tLow+" ") {
suffix := query[len(target):]
alternativeQueries = append(alternativeQueries, name+suffix)
}
if qLow == nLow {
alternativeQueries = append(alternativeQueries, target)
} else if strings.HasPrefix(qLow, nLow+" ") {
suffix := query[len(name):]
alternativeQueries = append(alternativeQueries, target+suffix)
}
}
}
var results []HistResult
seenCmds := make(map[string]bool)
addMatches := func(q string) {
qLow := strings.ToLower(q)
queryFirstWord := ""
if strings.IndexByte(qLow, ' ') != -1 {
if fields := strings.Fields(qLow); len(fields) > 0 {
queryFirstWord = fields[0]
}
}
matches := searcherCache.SearchWithScores(q, &fuzzy.SearchOptions{Limit: 200})
for _, m := range matches {
if seenCmds[m.Str] {
continue
}
// filter results by command name match
firstWord := m.Str
if idx := strings.IndexByte(m.Str, ' '); idx != -1 {
firstWord = m.Str[:idx]
}
firstWordLow := strings.ToLower(firstWord)
if queryFirstWord != "" {
if firstWordLow != queryFirstWord {
continue
}
} else {
if !strings.HasPrefix(firstWordLow, qLow) {
continue
}
}
seenCmds[m.Str] = true
results = append(results, HistResult{
ID: idMapCache[m.Str],
Cmd: m.Str,
FuzzyScore: m.Score,
})
}
}
addMatches(query)
for _, altQ := range alternativeQueries {
addMatches(altQ)
}
getTier := func(cmd, q string) int {
bestTier := 4
check := func(ql string) {
cmdLow := strings.ToLower(cmd)
qlLow := strings.ToLower(ql)
tier := 4
if cmdLow == qlLow {
tier = 1
} else if strings.HasPrefix(cmdLow, qlLow) {
tier = 2
} else if strings.Contains(cmdLow, qlLow) {
tier = 3
}
if tier < bestTier {
bestTier = tier
}
}
check(q)
for _, altQ := range alternativeQueries {
check(altQ)
}
return bestTier
}
tiers := make([]int, len(results))
for i, r := range results {
tiers[i] = getTier(r.Cmd, query)
}
sort.SliceStable(results, func(i, j int) bool {
tI := tiers[i]
tJ := tiers[j]
if tI != tJ {
return tI < tJ
}
if tI == 4 && results[i].FuzzyScore != results[j].FuzzyScore {
return results[i].FuzzyScore > results[j].FuzzyScore
}
return results[i].ID > results[j].ID
})
return results, nil
}