Files
iris-context/internal/ai/utils.go
VERSE f54e97a6c0 fix: enhance prompt (#36)
* Enforce verbatim input buffer prefix and add few-shot examples in
system prompt
* Omit empty context fields and format recent commands line by line
* Wrap untrusted data (`GitStatus`, `RecentCmds`, `DynamicContext`, and
`PreviousCommand`) in an explicit security boundary to prevent prompt
injection vectors
* Optimize string formatting with direct `fmt.Fprintf` inside
`BuildCompletionPrompt` to resolve `QF1012` staticcheck warnings
* Restore exact character prefix in `NormalizeSuggestion` to keep
`ShouldOverwrite` logic accurate
* Auto-join all continuation suffix completions (flags, quotes, regular
word arguments, and filenames) and insert a separating space whenever
the buffer ends in an ordinary word character
* Replace byte-indexed slicing with rune-safe slices (`[]rune`) and
`strings.EqualFold` to guarantee valid UTF-8 boundaries and prevent
panics on multi-byte characters
* Update and verify all unit tests (`go test -race ./internal/ai/...`)
with zero impact on other branches
2026-07-13 10:15:43 +07:00

191 lines
6.0 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 {
rawCmd := strings.TrimSpace(suggCmd)
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
}
}
}
}
}
if buf != "" {
bufRunes := []rune(buf)
suggRunes := []rune(suggCmd)
if len(suggRunes) >= len(bufRunes) && strings.EqualFold(string(suggRunes[:len(bufRunes)]), buf) {
suggCmd = buf + string(suggRunes[len(bufRunes):])
} else if fields := strings.Fields(buf); len(fields) > 0 && len(suggCmd) > 0 {
firstWord := strings.ToLower(fields[0])
suggLow := strings.ToLower(suggCmd)
if !strings.HasPrefix(suggLow, firstWord) && !strings.HasPrefix(suggLow, "sudo ") {
delta := suggCmd
if strings.HasPrefix(rawCmd, "-") || strings.HasPrefix(rawCmd, "\"") || strings.HasPrefix(rawCmd, "'") {
delta = rawCmd
}
if strings.HasSuffix(buf, " ") || strings.HasSuffix(buf, "\"") || strings.HasSuffix(buf, "'") || strings.HasSuffix(buf, "=") || strings.HasSuffix(buf, "/") {
suggCmd = buf + delta
} else {
suggCmd = buf + " " + delta
}
}
}
}
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, ", "))
}
}
}