1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
|
// 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: <absolute path>]` 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))
}
|