Files
iris-context/ai/utils.go
T
VERSE 94c8af7b1d 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
2026-07-11 15:17:14 +07:00

168 lines
5.1 KiB
Go

package ai
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/versenilvis/iris/spec"
)
func CleanSuggestion(raw string) string {
s := strings.TrimSpace(raw)
if strings.HasPrefix(s, "```") {
lines := strings.Split(s, "\n")
if len(lines) > 1 {
endIdx := len(lines)
if strings.HasPrefix(strings.TrimSpace(lines[len(lines)-1]), "```") {
endIdx = len(lines) - 1
}
s = strings.TrimSpace(strings.Join(lines[1:endIdx], "\n"))
}
}
if len(s) >= 2 && strings.HasPrefix(s, "`") && strings.HasSuffix(s, "`") && !strings.HasPrefix(s, "``") {
s = s[1 : len(s)-1]
}
if len(s) >= 2 && ((strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"")) || (strings.HasPrefix(s, "'") && strings.HasSuffix(s, "'"))) {
inner := s[1 : len(s)-1]
if !strings.ContainsAny(inner, "\"'") {
s = inner
}
}
return strings.TrimSpace(s)
}
func NormalizeSuggestion(buf string, suggCmd string) string {
suggCmd = CleanSuggestion(suggCmd)
if strings.Contains(buf, "-m \"") || strings.Contains(buf, "-am \"") || strings.Contains(buf, "--message \"") {
if !strings.HasPrefix(strings.ToLower(suggCmd), strings.ToLower(buf)) {
for _, flag := range []string{"-m ", "-am ", "--message "} {
idx := strings.Index(suggCmd, flag)
if idx != -1 {
afterFlag := suggCmd[idx+len(flag):]
if !strings.HasPrefix(afterFlag, "\"") && !strings.HasPrefix(afterFlag, "'") {
suggCmd = suggCmd[:idx+len(flag)] + "\"" + afterFlag + "\""
break
}
}
}
}
}
return suggCmd
}
func ShouldOverwrite(originalBuf string, currentBuf string, newSugg *spec.Suggestion, currentConfidence int) bool {
if newSugg == nil {
return false
}
if !strings.HasPrefix(currentBuf, originalBuf) {
return false
}
if !strings.HasPrefix(strings.ToLower(newSugg.Cmd), strings.ToLower(currentBuf)) {
return false
}
return newSugg.Confidence > currentConfidence
}
func ExtractScriptsAndTargets(sb *strings.Builder, dir string, prefix string) {
if data, err := os.ReadFile(filepath.Join(dir, "package.json")); err == nil {
var pkg struct {
Scripts map[string]string `json:"scripts"`
}
if err := json.Unmarshal(data, &pkg); err == nil && len(pkg.Scripts) > 0 {
var scriptNames []string
for name, cmd := range pkg.Scripts {
scriptNames = append(scriptNames, fmt.Sprintf("%s: %s", name, cmd))
}
sort.Strings(scriptNames)
if len(scriptNames) > 20 {
scriptNames = append(scriptNames[:20], "... (truncated)")
}
label := "package.json"
if prefix != "" {
label = prefix + "/package.json"
}
fmt.Fprintf(sb, "Available %s scripts:\n%s\n\n", label, strings.Join(scriptNames, "\n"))
}
}
if file, err := os.Open(filepath.Join(dir, "Makefile")); err == nil {
defer func() { _ = file.Close() }()
var targets []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
// Skip variable assignments because operators like := or colons in values trick the colon parser into misclassifying variables as build targets
if strings.Contains(line, "=") {
continue
}
if idx := strings.Index(line, ":"); idx > 0 && !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") {
target := strings.TrimSpace(line[:idx])
if target != "" && target != ".PHONY" && !strings.Contains(target, " ") && !seen[target] && !strings.HasPrefix(target, ".") {
seen[target] = true
targets = append(targets, target)
}
}
}
_ = scanner.Err()
if len(targets) > 0 {
sort.Strings(targets)
// Cap at 10 targets to keep AI prompt short and avoid exceeding 6000 TPM limit (basedd on Groq api docs because Im using it now)
if len(targets) > 10 {
targets = append(targets[:10], "... (truncated)")
}
label := "Makefile"
if prefix != "" {
label = prefix + "/Makefile"
}
fmt.Fprintf(sb, "Available %s targets:\n%s\n\n", label, strings.Join(targets, ", "))
}
}
openJustfile := func() (*os.File, error) {
if f, err := os.Open(filepath.Join(dir, "justfile")); err == nil {
return f, nil
}
return os.Open(filepath.Join(dir, "Justfile"))
}
if file, err := openJustfile(); err == nil {
defer func() { _ = file.Close() }()
var recipes []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(file)
recipeRegex := regexp.MustCompile(`^([a-zA-Z0-9_-]+):`)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "[") {
continue
}
if matches := recipeRegex.FindStringSubmatch(line); len(matches) > 1 {
recipe := matches[1]
if !seen[recipe] {
seen[recipe] = true
recipes = append(recipes, recipe)
}
}
}
_ = scanner.Err()
if len(recipes) > 0 {
sort.Strings(recipes)
// Cap at 10 recipes to keep AI prompt short and avoid exceeding 6000 TPM limit
if len(recipes) > 10 {
recipes = append(recipes[:10], "... (truncated)")
}
label := "justfile"
if prefix != "" {
label = prefix + "/justfile"
}
fmt.Fprintf(sb, "Available %s recipes:\n%s\n\n", label, strings.Join(recipes, ", "))
}
}
}