// Package mention understands the @path notation of a prompt: "explain // @internal/acp/acp.go" names a file the model should look at. There is no // picker — the user types the path — and the file's content does not travel // in the prompt: as for an editor's @-mention over ACP, the mention becomes an // `[attached file: ]` line and the model reads the file with // its tools. Both front ends use it, so a typed @path means the same thing in // the terminal and in an editor whose picker was bypassed. package mention import ( "fmt" "os" "path/filepath" "strings" ) // Attachment is one @path mention that named an existing file or directory. type Attachment struct { Mention string // as typed, without the @ and the trailing punctuation Path string // absolute, cleaned Dir bool // a directory rather than a file } // trailing is the sentence punctuation a mention may be glued to — "look at // @main.go, then…" mentions main.go, not "main.go,". const trailing = ",.;:!?)]}'\"`" // Expand finds the @path mentions of input, resolves them against cwd (an // absolute path starts with / or ~/, anything else is relative to cwd) and // returns the input followed by one attachment line per existing path, in // order of first appearance, each path once: // // [attached file: /work/project/main.go] // [attached directory: /work/project/internal] // // A mention that names nothing on disk is left alone: "@bob" in a sentence is // not a file. A @ glued to a word (an e-mail address) is not a mention either. func Expand(input, cwd string) (string, []Attachment) { var out []Attachment seen := map[string]bool{} for _, m := range mentions(input) { path := resolve(m, cwd) if seen[path] { continue } info, err := os.Stat(path) if err != nil { continue } seen[path] = true out = append(out, Attachment{Mention: m, Path: path, Dir: info.IsDir()}) } var b strings.Builder b.WriteString(input) for _, a := range out { fmt.Fprintf(&b, "\n%s", a.Line()) } return b.String(), out } // Line is the attachment as the model sees it — the same shape an ACP // resource_link takes, so the model meets one convention, not two. func (a Attachment) Line() string { if a.Dir { return fmt.Sprintf("[attached directory: %s]", a.Path) } return fmt.Sprintf("[attached file: %s]", a.Path) } // mentions returns the candidate paths: every "@something" that starts the // input or follows a space, an opening bracket or a quote, stripped of the // punctuation it may end with. Existence is the caller's business. func mentions(input string) []string { var out []string for i := 0; i < len(input); i++ { if input[i] != '@' || !startsMention(input, i) { continue } end := i + 1 for end < len(input) && !isSpace(input[end]) { end++ } m := strings.TrimRight(input[i+1:end], trailing) if m != "" { out = append(out, m) } i = end } return out } func startsMention(input string, i int) bool { if i == 0 { return true } return isSpace(input[i-1]) || strings.IndexByte("([{'\"`", input[i-1]) >= 0 } func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' } // resolve turns a mention into an absolute, cleaned path. func resolve(m, cwd string) string { switch { case filepath.IsAbs(m): return filepath.Clean(m) case m == "~" || strings.HasPrefix(m, "~/"): if home, err := os.UserHomeDir(); err == nil { return filepath.Clean(filepath.Join(home, m[1:])) } } return filepath.Clean(filepath.Join(cwd, m)) }