feat: AI suggestion (#34)

## Feat
* feat(ai): add AI suggestion engine, context gathering, and ghost text
overlay (`v0.3.0`)

## Security & Perf
* fix(ai): restrict background `--help` execution to a hardcoded command
allowlist to prevent RCE
* fix(root): defer context cancellation in goroutine to prevent resource
leaks
* perf(ai): implement LRU eviction and maximum size limit for
`ProviderCache`
* perf(ai): truncate command context output
(`CommandContextProvider.Gather`) to 1000 characters to save tokens

## Bug Fixes
* fix(ai): add mutex synchronization and thread-safe snapshotting to
`AIEngine.RegisterProvider` and `GatherDynamicContext`
* fix(ai): return a copy of fresh `Suggestion` in `AIEngine.Suggest` to
prevent cache mutation
* fix(ai): use length-prefixed encoding in `EnvSnapshot.Hash` to prevent
delimiter collisions
* fix(ai): skip variable assignment lines containing `=` when extracting
Makefile targets
* fix(runner): check `scanner.Err()` and return `nil` on scan errors in
justfile generator
* test(ci): rename `git commit` to `git checkout` in overlay test to
resolve CI `typos` false positive
This commit is contained in:
VERSE
2026-07-11 15:17:14 +07:00
committed by GitHub
parent 4728ca0c7f
commit 94c8af7b1d
29 changed files with 2263 additions and 45 deletions
+118
View File
@@ -0,0 +1,118 @@
package ai
import (
"context"
"sync"
"time"
"github.com/versenilvis/iris/spec"
)
type cacheEntry struct {
data string
expireTime time.Time
}
type ProviderCache struct {
mu sync.Mutex
entries map[string]cacheEntry
ttl time.Duration
}
func NewProviderCache(ttl time.Duration) *ProviderCache {
if ttl == 0 {
ttl = 4 * time.Second
}
return &ProviderCache{
entries: make(map[string]cacheEntry),
ttl: ttl,
}
}
func (c *ProviderCache) GetOrGather(ctx context.Context, p ContextProvider) string {
c.mu.Lock()
entry, ok := c.entries[p.Name()]
if ok && time.Now().Before(entry.expireTime) {
c.mu.Unlock()
return entry.data
}
c.mu.Unlock()
data, err := p.Gather(ctx)
if err != nil || ctx.Err() != nil {
return ""
}
c.mu.Lock()
if len(c.entries) >= 50 {
now := time.Now()
for k, v := range c.entries {
if now.After(v.expireTime) {
delete(c.entries, k)
}
}
if len(c.entries) >= 50 {
c.entries = make(map[string]cacheEntry)
}
}
c.entries[p.Name()] = cacheEntry{
data: data,
expireTime: time.Now().Add(c.ttl),
}
c.mu.Unlock()
return data
}
func (c *ProviderCache) Clear() {
c.mu.Lock()
c.entries = make(map[string]cacheEntry)
c.mu.Unlock()
}
type ContextCache struct {
mu sync.Mutex
lastSnapshotHash string
lastSuggestion *spec.Suggestion
lastFetchedAt time.Time
}
func NewContextCache() *ContextCache {
return &ContextCache{}
}
func (c *ContextCache) ShouldCallAI(snap EnvSnapshot, minInterval time.Duration) bool {
c.mu.Lock()
defer c.mu.Unlock()
hash := snap.Hash()
if hash == c.lastSnapshotHash {
return false
}
if time.Since(c.lastFetchedAt) < minInterval {
return false
}
return true
}
func (c *ContextCache) GetCachedSuggestion(snap EnvSnapshot) *spec.Suggestion {
c.mu.Lock()
defer c.mu.Unlock()
if snap.Hash() == c.lastSnapshotHash {
return c.lastSuggestion
}
return nil
}
func (c *ContextCache) Update(snap EnvSnapshot, sugg *spec.Suggestion) {
c.mu.Lock()
defer c.mu.Unlock()
c.lastSnapshotHash = snap.Hash()
c.lastSuggestion = sugg
c.lastFetchedAt = time.Now()
}
func (c *ContextCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.lastSnapshotHash = ""
c.lastSuggestion = nil
}