返回 DeepSeek-Reasonix
refs.go
根目录 / internal / control / refs.go
1 package control
2
3 import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/base64"
8 "encoding/hex"
9 "fmt"
10 "html"
11 "io"
12 "net/http"
13 "os"
14 "os/exec"
15 "path/filepath"
16 "regexp"
17 "sort"
18 "strings"
19 "time"
20
21 "reasonix/internal/fileref"
22 "reasonix/internal/instruction"
23 "reasonix/internal/proc"
24 "reasonix/internal/secrets"
25 )
26
27 // maxFileRefBytes caps how much of an @-referenced file is injected into a
28 // message, so "@somehuge.log" can't blow the context window. The head is kept
29 // and the rest noted as truncated.
30 const maxFileRefBytes = 64 * 1024
31
32 const pdfExtractTimeout = 8 * time.Second
33 const pdfExtractWaitDelay = 1 * time.Second
34
35 var extractPDFText = extractPDFTextDefault
36
37 type pdfExtractResult struct {
38 text string
39 tool string
40 truncated bool
41 }
42
43 // refKind distinguishes the two things an @reference can resolve to.
44 type refKind int
45
46 const (
47 refResource refKind = iota // an MCP resource: @<server>:<uri>
48 refFile // a local file or directory: @<path>
49 refImage // a local image attachment: @.reasonix/attachments/<file>
50 )
51
52 // ref is a resolved @reference found in a submitted line.
53 type ref struct {
54 kind refKind
55 server string // refResource
56 uri string // refResource
57 path string // refFile, relative to baseDir when baseDir is set
58 baseDir string // refFile override for session-authorized external roots
59 displayPath string // refFile label/path exposed in the resolved context block
60 raw string // the original token after '@', for labelling
61 }
62
63 // ExternalFolderRefEntry is a session-authorized entry under a dropped external
64 // folder. Path is the opaque @ token path to submit; display fields are safe for
65 // UI labels and transcripts.
66 type ExternalFolderRefEntry struct {
67 Name string
68 Path string
69 DisplayName string
70 DisplayPath string
71 IsDir bool
72 }
73
74 var pathLocationSuffixRe = regexp.MustCompile(`:\d+(?::\d+)?:?$`)
75
76 const externalFolderRefPrefix = "__reasonix_external_folder"
77
78 // parseRefTokens extracts the deduped, punctuation-trimmed tokens following '@'
79 // in a line. A token is a run of non-whitespace bytes, except that a
80 // backslash-escaped space or tab is part of the token with the backslash
81 // dropped — that is how a path containing spaces survives the
82 // whitespace-delimited grammar (EscapeRefPath produces that form). Any other
83 // backslash stays literal so Windows separators keep their meaning. Pure:
84 // classification (server? file?) happens in classifyRef.
85 func parseRefTokens(line string) []string {
86 var toks []string
87 seen := map[string]bool{}
88 for i := 0; i < len(line); i++ {
89 if line[i] != '@' {
90 continue
91 }
92 var b strings.Builder
93 j := i + 1
94 for j < len(line) {
95 ch := line[j]
96 if ch == '\\' && j+1 < len(line) && (line[j+1] == ' ' || line[j+1] == '\t') {
97 b.WriteByte(line[j+1])
98 j += 2
99 continue
100 }
101 if isRefTokenBoundary(ch) {
102 break
103 }
104 b.WriteByte(ch)
105 j++
106 }
107 i = j - 1
108 t := strings.TrimRight(b.String(), ".,;!?)]}")
109 if t == "" || seen[t] {
110 continue
111 }
112 seen[t] = true
113 toks = append(toks, t)
114 }
115 return toks
116 }
117
118 // isRefTokenBoundary matches the whitespace class the old `@([^\s]+)` token
119 // regexp stopped at.
120 func isRefTokenBoundary(ch byte) bool {
121 switch ch {
122 case ' ', '\t', '\n', '\r', '\f':
123 return true
124 default:
125 return false
126 }
127 }
128
129 // EscapeRefPath returns path with spaces and tabs backslash-escaped so the
130 // result survives whitespace-delimited @-token parsing (parseRefTokens
131 // reverses it). Every other byte, including backslashes, passes through
132 // unchanged so Windows separators keep their meaning.
133 func EscapeRefPath(path string) string {
134 if !strings.ContainsAny(path, " \t") {
135 return path
136 }
137 var b strings.Builder
138 b.Grow(len(path) + 8)
139 for i := 0; i < len(path); i++ {
140 if path[i] == ' ' || path[i] == '\t' {
141 b.WriteByte('\\')
142 }
143 b.WriteByte(path[i])
144 }
145 return b.String()
146 }
147
148 // UnescapeRefPath reverses EscapeRefPath: a backslash before a space or tab is
149 // dropped; any other backslash stays literal.
150 func UnescapeRefPath(path string) string {
151 if !strings.Contains(path, `\`) {
152 return path
153 }
154 var b strings.Builder
155 b.Grow(len(path))
156 for i := 0; i < len(path); i++ {
157 if path[i] == '\\' && i+1 < len(path) && (path[i+1] == ' ' || path[i+1] == '\t') {
158 continue
159 }
160 b.WriteByte(path[i])
161 }
162 return b.String()
163 }
164
165 // classifyRef decides what a token refers to. A "server:uri" token whose server
166 // is connected is an MCP resource; otherwise a token that names an existing path
167 // is a file. Anything else (an @mention, an email) is not a reference. exists is
168 // injected so the rule is testable without touching the filesystem.
169 func classifyRef(token string, known map[string]bool, exists func(string) bool) (ref, bool) {
170 if i := strings.Index(token, ":"); i > 0 && i+1 < len(token) && known[token[:i]] {
171 return ref{kind: refResource, server: token[:i], uri: token[i+1:], raw: token}, true
172 }
173 if isAttachmentRef(token) && exists(token) {
174 if isImageAttachmentRef(token) {
175 return ref{kind: refImage, path: token, raw: token}, true
176 }
177 return ref{kind: refFile, path: token, raw: token}, true
178 }
179 if exists(token) {
180 return ref{kind: refFile, path: token, raw: token}, true
181 }
182 return ref{}, false
183 }
184
185 func isAttachmentRef(token string) bool {
186 return strings.HasPrefix(filepath.ToSlash(token), ".reasonix/attachments/")
187 }
188
189 func isImageAttachmentRef(token string) bool {
190 switch strings.ToLower(filepath.Ext(token)) {
191 case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".tif", ".tiff":
192 return true
193 }
194 return false
195 }
196
197 // RegisterExternalFolderRef authorizes one dropped directory outside the
198 // workspace as a structured @reference for this controller session. The returned
199 // token is path-like and whitespace-free so it survives the existing @ token
200 // parser even when the real directory path contains spaces or Windows drive
201 // punctuation.
202 func (c *Controller) RegisterExternalFolderRef(path string) (token, displayPath string, err error) {
203 if c == nil {
204 return "", "", fmt.Errorf("controller is not ready")
205 }
206 abs, err := normalizeExternalFolderRoot(path)
207 if err != nil {
208 return "", "", err
209 }
210 token = externalFolderRefToken(abs)
211 c.externalFolderRefsMu.Lock()
212 if c.externalFolderRefs == nil {
213 c.externalFolderRefs = map[string]string{}
214 }
215 c.externalFolderRefs[token] = abs
216 c.externalFolderRefsMu.Unlock()
217 if c.externalFolderToolRefs != nil {
218 c.externalFolderToolRefs.RegisterReadRoot(token, abs)
219 }
220 return token, filepath.ToSlash(abs), nil
221 }
222
223 func normalizeExternalFolderRoot(path string) (string, error) {
224 path = strings.TrimSpace(path)
225 if path == "" {
226 return "", os.ErrInvalid
227 }
228 abs, err := filepath.Abs(path)
229 if err != nil {
230 return "", err
231 }
232 abs = filepath.Clean(abs)
233 if resolved, err := filepath.EvalSymlinks(abs); err == nil {
234 abs = filepath.Clean(resolved)
235 }
236 info, err := os.Stat(abs)
237 if err != nil {
238 return "", err
239 }
240 if !info.IsDir() {
241 return "", fmt.Errorf("%s is not a directory", path)
242 }
243 return abs, nil
244 }
245
246 func externalFolderRefToken(abs string) string {
247 sum := sha256.Sum256([]byte(filepath.Clean(abs)))
248 hash := hex.EncodeToString(sum[:])[:12]
249 name := safeExternalFolderRefComponent(filepath.Base(abs))
250 return externalFolderRefPrefix + "/" + hash + "/" + name
251 }
252
253 func safeExternalFolderRefComponent(name string) string {
254 name = strings.TrimSpace(name)
255 if name == "" || name == "." || name == string(filepath.Separator) {
256 return "folder"
257 }
258 var b strings.Builder
259 lastDash := false
260 for _, r := range name {
261 ok := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-'
262 if ok {
263 b.WriteRune(r)
264 lastDash = false
265 continue
266 }
267 if !lastDash {
268 b.WriteByte('-')
269 lastDash = true
270 }
271 }
272 out := strings.Trim(b.String(), ".-")
273 if out == "" {
274 return "folder"
275 }
276 return out
277 }
278
279 func normalizeExternalFolderRefToken(token string) string {
280 token = strings.TrimSpace(token)
281 token = strings.TrimPrefix(token, "@")
282 token = filepath.ToSlash(token)
283 token = strings.TrimRight(token, "/")
284 return token
285 }
286
287 func (c *Controller) externalFolderRef(token string) (ref, bool) {
288 _, rel, abs, ok := c.externalFolderRefTarget(token)
289 if !ok {
290 return ref{}, false
291 }
292 displayPath := externalFolderDisplayPath(abs, rel)
293 return ref{kind: refFile, path: rel, baseDir: abs, displayPath: displayPath, raw: token}, true
294 }
295
296 func (c *Controller) externalFolderRefTarget(token string) (rootToken, rel, abs string, ok bool) {
297 key := normalizeExternalFolderRefToken(token)
298 if !strings.HasPrefix(key, externalFolderRefPrefix+"/") {
299 return "", "", "", false
300 }
301 c.externalFolderRefsMu.RLock()
302 defer c.externalFolderRefsMu.RUnlock()
303 if abs, ok := c.externalFolderRefs[key]; ok {
304 return key, ".", abs, true
305 }
306 for registered, abs := range c.externalFolderRefs {
307 if !strings.HasPrefix(key, registered+"/") {
308 continue
309 }
310 sub, ok := cleanExternalFolderSubpath(strings.TrimPrefix(key, registered+"/"))
311 if !ok {
312 return "", "", "", false
313 }
314 return registered, sub, abs, true
315 }
316 return "", "", "", false
317 }
318
319 func cleanExternalFolderSubpath(sub string) (string, bool) {
320 sub = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(sub)), "/")
321 if sub == "" || sub == "." {
322 return ".", true
323 }
324 cleaned := filepath.Clean(filepath.FromSlash(sub))
325 if cleaned == "." {
326 return ".", true
327 }
328 if !filepath.IsLocal(cleaned) {
329 return "", false
330 }
331 return filepath.ToSlash(cleaned), true
332 }
333
334 func externalFolderDisplayPath(abs, rel string) string {
335 if rel == "" || rel == "." {
336 return filepath.ToSlash(abs)
337 }
338 return filepath.ToSlash(filepath.Join(abs, filepath.FromSlash(rel)))
339 }
340
341 func externalFolderDisplayName(abs, rel string) string {
342 name := filepath.Base(abs)
343 if rel != "" && rel != "." {
344 name = filepath.ToSlash(filepath.Join(name, filepath.FromSlash(rel)))
345 }
346 return name
347 }
348
349 // ListExternalFolderRefDir lists one directory level under a registered
350 // external folder token. handled is true only when tokenPath targets a
351 // registered external folder; callers can fall back to workspace listing when it
352 // is false.
353 func (c *Controller) ListExternalFolderRefDir(tokenPath string) (entries []ExternalFolderRefEntry, handled bool) {
354 rootToken, rel, abs, ok := c.externalFolderRefTarget(tokenPath)
355 if !ok {
356 return nil, false
357 }
358 root, err := os.OpenRoot(abs)
359 if err != nil {
360 return nil, true
361 }
362 defer root.Close()
363 info, err := root.Stat(rel)
364 if err != nil || !info.IsDir() {
365 return nil, true
366 }
367 f, err := root.Open(rel)
368 if err != nil {
369 return nil, true
370 }
371 dirEntries, err := f.ReadDir(-1)
372 f.Close()
373 if err != nil {
374 return nil, true
375 }
376 dirs, files := []ExternalFolderRefEntry{}, []ExternalFolderRefEntry{}
377 for _, e := range dirEntries {
378 name := e.Name()
379 if skipRefDirEntry(name, e.IsDir()) {
380 continue
381 }
382 childRel := name
383 if rel != "." {
384 childRel = filepath.ToSlash(filepath.Join(rel, name))
385 }
386 item := ExternalFolderRefEntry{
387 Name: name,
388 Path: rootToken + "/" + childRel,
389 DisplayName: name,
390 DisplayPath: externalFolderDisplayPath(abs, childRel),
391 IsDir: e.IsDir(),
392 }
393 if e.IsDir() {
394 dirs = append(dirs, item)
395 continue
396 }
397 info, err := e.Info()
398 if err != nil || !info.Mode().IsRegular() {
399 continue
400 }
401 files = append(files, item)
402 }
403 sortExternalFolderRefEntries(dirs)
404 sortExternalFolderRefEntries(files)
405 return append(dirs, files...), true
406 }
407
408 // SearchExternalFolderRefs finds entries under all registered external folders.
409 // Returned Path values are opaque token paths, so selecting one stays within the
410 // current session's authorization boundary.
411 func (c *Controller) SearchExternalFolderRefs(query string, limit int) []ExternalFolderRefEntry {
412 query = strings.TrimSpace(query)
413 if limit <= 0 || len(query) < 2 || strings.ContainsAny(query, `/\`) {
414 return nil
415 }
416 c.externalFolderRefsMu.RLock()
417 roots := make([]struct {
418 token string
419 abs string
420 }, 0, len(c.externalFolderRefs))
421 for token, abs := range c.externalFolderRefs {
422 roots = append(roots, struct {
423 token string
424 abs string
425 }{token: token, abs: abs})
426 }
427 c.externalFolderRefsMu.RUnlock()
428 sort.Slice(roots, func(i, j int) bool {
429 return externalFolderDisplayPath(roots[i].abs, ".") < externalFolderDisplayPath(roots[j].abs, ".")
430 })
431 out := make([]ExternalFolderRefEntry, 0, limit)
432 queryLower := strings.ToLower(query)
433 for _, root := range roots {
434 if len(out) >= limit {
435 break
436 }
437 if info, err := os.Stat(root.abs); err != nil || !info.IsDir() {
438 continue
439 }
440 if strings.Contains(strings.ToLower(filepath.Base(root.abs)), queryLower) {
441 out = append(out, ExternalFolderRefEntry{
442 Name: filepath.Base(root.abs),
443 Path: root.token,
444 DisplayName: externalFolderDisplayName(root.abs, "."),
445 DisplayPath: externalFolderDisplayPath(root.abs, "."),
446 IsDir: true,
447 })
448 if len(out) >= limit {
449 break
450 }
451 }
452 for _, result := range fileref.Search(root.abs, query, limit-len(out)) {
453 rel := filepath.ToSlash(result.Path)
454 out = append(out, ExternalFolderRefEntry{
455 Name: rel,
456 Path: root.token + "/" + rel,
457 DisplayName: externalFolderDisplayName(root.abs, rel),
458 DisplayPath: externalFolderDisplayPath(root.abs, rel),
459 IsDir: result.IsDir,
460 })
461 if len(out) >= limit {
462 break
463 }
464 }
465 }
466 return out
467 }
468
469 // ExternalFolderRefLocalPath resolves a registered external-folder token path to
470 // the local filesystem path authorized for this controller session.
471 func (c *Controller) ExternalFolderRefLocalPath(tokenPath string) (path, displayPath string, ok bool) {
472 _, rel, abs, ok := c.externalFolderRefTarget(tokenPath)
473 if !ok {
474 return "", "", false
475 }
476 return filepath.Join(abs, filepath.FromSlash(rel)), externalFolderDisplayPath(abs, rel), true
477 }
478
479 func sortExternalFolderRefEntries(entries []ExternalFolderRefEntry) {
480 sort.Slice(entries, func(i, j int) bool {
481 return strings.ToLower(entries[i].DisplayName) < strings.ToLower(entries[j].DisplayName)
482 })
483 }
484
485 func skipRefDirEntry(name string, isDir bool) bool {
486 switch name {
487 case ".DS_Store", "Thumbs.db":
488 return true
489 }
490 if !isDir {
491 return false
492 }
493 switch name {
494 case ".codex", ".git", ".idea", ".npm", ".pnpm-store", ".vscode", "__pycache__", "build", "dist", "node_modules":
495 return true
496 }
497 return false
498 }
499
500 // detectRefs finds the @references in a line: MCP resources for connected
501 // servers, and local paths that exist on disk.
502 func (c *Controller) detectRefs(line string) []ref {
503 return c.detectRefsMode(line, false)
504 }
505
506 func (c *Controller) detectRefsMode(line string, scopedOnly bool) []ref {
507 known := map[string]bool{}
508 for _, n := range c.mcp.serverNames() {
509 known[n] = true
510 }
511
512 var refs []ref
513 for _, tok := range parseRefTokens(line) {
514 if i := strings.Index(tok, ":"); i > 0 && i+1 < len(tok) && known[tok[:i]] {
515 refs = append(refs, ref{kind: refResource, server: tok[:i], uri: tok[i+1:], raw: tok})
516 continue
517 }
518 if r, ok := c.externalFolderRef(tok); ok {
519 refs = append(refs, r)
520 continue
521 }
522 if c.workspaceRoot != "" {
523 if rel, ok := workspaceRefPath(tok, c.workspaceRoot); ok {
524 kind := refFile
525 if isAttachmentRef(rel) && isImageAttachmentRef(rel) {
526 kind = refImage
527 }
528 refs = append(refs, ref{kind: kind, path: rel, raw: tok})
529 }
530 continue
531 }
532 if scopedOnly {
533 continue
534 }
535 if r, ok := classifyRef(tok, known, func(p string) bool {
536 _, err := os.Stat(p)
537 return err == nil
538 }); ok {
539 refs = append(refs, r)
540 }
541 }
542 return refs
543 }
544
545 // HasRefs reports whether a line contains any resolvable @references, so a
546 // frontend can decide to resolve off its event loop only when needed.
547 func (c *Controller) HasRefs(line string) bool {
548 return len(c.detectRefs(line)) > 0
549 }
550
551 // inputImages resolves image @-references in the turn input to data URLs so the
552 // turn can carry them to a vision-capable model. Best-effort: an unreadable image
553 // is skipped — the @ref still lands as text via ResolveRefs.
554 func (c *Controller) inputImages(line string) []string {
555 if !c.imageInputEnabled() {
556 return nil
557 }
558 var urls []string
559 for _, r := range c.detectRefs(line) {
560 baseDir := c.workspaceRoot
561 if r.baseDir != "" {
562 baseDir = r.baseDir
563 }
564 if url, err := visionRefImageDataURL(r, baseDir); err == nil {
565 urls = append(urls, url)
566 }
567 }
568 return urls
569 }
570
571 func visionRefImageDataURL(r ref, baseDir string) (string, error) {
572 switch r.kind {
573 case refImage:
574 return visionImageDataURL(r.path)
575 case refFile:
576 return visionFileImageDataURL(r.path, baseDir)
577 default:
578 return "", fmt.Errorf("reference is not an image")
579 }
580 }
581
582 func visionFileImageDataURL(path, baseDir string) (string, error) {
583 absPath, absBase, ok := resolveAbsRef(path, baseDir)
584 if !ok {
585 return "", os.ErrNotExist
586 }
587 if absBase == "" {
588 return "", fmt.Errorf("workspace root is required for file image references")
589 }
590
591 root, err := os.OpenRoot(absBase)
592 if err != nil {
593 return "", err
594 }
595 defer root.Close()
596
597 rel, err := filepath.Rel(absBase, absPath)
598 if err != nil {
599 return "", err
600 }
601
602 info, err := root.Lstat(rel)
603 if err != nil {
604 return "", err
605 }
606 if info.Mode()&os.ModeSymlink != 0 {
607 return "", fmt.Errorf("image path must not be a symlink")
608 }
609 if info.IsDir() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
610 return "", fmt.Errorf("image must be between 1 byte and 10 MB")
611 }
612 f, err := root.Open(rel)
613 if err != nil {
614 return "", err
615 }
616 defer f.Close()
617 opened, err := f.Stat()
618 if err != nil {
619 return "", err
620 }
621 if !os.SameFile(info, opened) {
622 return "", fmt.Errorf("image changed while opening")
623 }
624 return dataURLFromImageReader(f, path)
625 }
626
627 func dataURLFromImageReader(r io.Reader, path string) (string, error) {
628 raw, err := io.ReadAll(io.LimitReader(r, maxImageAttachmentBytes+1))
629 if err != nil {
630 return "", err
631 }
632 if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
633 return "", fmt.Errorf("image must be between 1 byte and 10 MB")
634 }
635 mime := detectedImageMime(raw)
636 if mime == "" {
637 return "", fmt.Errorf("%s is not a supported image", path)
638 }
639 raw, mime = compressForVision(raw, mime)
640 return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
641 }
642
643 // resolveBareNames batch-resolves simple filenames (no path separator) that
644 // don't exist in cwd. It walks the working tree once and matches every
645 // unresolved name against the set, stopping when all are found. This runs in
646 // the async ResolveRefs path, never on the TUI event loop.
647 func resolveBareNames(refs []ref, workspaceRoot string) []ref {
648 need := map[string]*ref{}
649 var names []string
650 for i := range refs {
651 r := &refs[i]
652 if r.kind != refFile || r.path != "" || !isSafeBareRefName(r.raw) {
653 continue
654 }
655 if workspaceRoot != "" {
656 if rel, ok := workspaceRefPath(r.raw, workspaceRoot); ok {
657 r.path = rel
658 continue
659 }
660 }
661 need[r.raw] = r
662 names = append(names, r.raw)
663 }
664 if len(names) == 0 {
665 return refs
666 }
667 found := 0
668 cwd := workspaceRoot
669 if cwd == "" {
670 cwd, _ = os.Getwd()
671 }
672 _ = filepath.WalkDir(cwd, func(p string, d os.DirEntry, wErr error) error {
673 if wErr != nil || found == len(names) {
674 return filepath.SkipAll
675 }
676 if d.IsDir() {
677 switch d.Name() {
678 case ".git", "node_modules", ".DS_Store", "__pycache__", ".idea", ".vscode":
679 return filepath.SkipDir
680 }
681 return nil
682 }
683 if r, ok := need[d.Name()]; ok {
684 rel, _ := filepath.Rel(cwd, p)
685 r.path = filepath.ToSlash(rel)
686 delete(need, d.Name())
687 found++
688 }
689 return nil
690 })
691 return refs
692 }
693
694 func isSafeBareRefName(name string) bool {
695 if name == "" || name == "." || name == ".." {
696 return false
697 }
698 if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
699 return false
700 }
701 return filepath.Base(name) == name && filepath.IsLocal(name)
702 }
703
704 // FileRefLine reports whether a submitted line is nothing but a path to an
705 // existing file — a dragged or pasted file lands as its bare path, which on
706 // POSIX starts with '/' and would otherwise be misread as a slash command. The
707 // returned string is that path turned into an @reference so it attaches.
708 func FileRefLine(line string) (string, bool) {
709 p := strings.Trim(strings.TrimSpace(line), `"'`)
710 if p == "" {
711 return "", false
712 }
713 if info, err := os.Stat(p); err != nil || info.IsDir() {
714 return "", false
715 }
716 return "@" + EscapeRefPath(p), true
717 }
718
719 // SlashCodeCommentLine reports whether a slash-prefixed line is ordinary source
720 // text rather than a Reasonix slash command.
721 func SlashCodeCommentLine(line string) bool {
722 trimmed := strings.TrimSpace(line)
723 return strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*")
724 }
725
726 // SlashPathLineRef reports whether a slash-prefixed line starts with a local file
727 // path, including common compiler-location suffixes like ":12" or ":12:34".
728 // It returns an @reference for the file so diagnostics that begin with an
729 // absolute path can keep their original text while also attaching file context.
730 func SlashPathLineRef(line, baseDir string) (string, bool) {
731 token, ok := leadingSlashPathToken(line)
732 if !ok {
733 return "", false
734 }
735 for _, p := range pathTokenCandidates(token) {
736 if fileRefExists(p, baseDir) {
737 return "@" + p, true
738 }
739 }
740 return "", false
741 }
742
743 // SlashPathLikeLine reports whether a slash-prefixed line looks like a POSIX
744 // absolute path rather than a slash command. It intentionally stays conservative:
745 // unknown "/foo" remains an unknown command, while "/foo/bar..." is sent as
746 // ordinary prompt text even if the path no longer exists.
747 func SlashPathLikeLine(line string) bool {
748 token, ok := leadingSlashPathToken(line)
749 if !ok {
750 return false
751 }
752 for _, p := range pathTokenCandidates(token) {
753 if strings.Contains(p[1:], "/") {
754 return true
755 }
756 }
757 return false
758 }
759
760 func leadingSlashPathToken(line string) (string, bool) {
761 fields := strings.Fields(strings.TrimSpace(line))
762 if len(fields) == 0 {
763 return "", false
764 }
765 token := strings.Trim(fields[0], `"'`)
766 if !strings.HasPrefix(token, "/") || strings.HasPrefix(token, "//") {
767 return "", false
768 }
769 return token, true
770 }
771
772 func pathTokenCandidates(token string) []string {
773 token = strings.TrimRight(strings.Trim(token, `"'`), ".,;!?)]}")
774 if token == "" {
775 return nil
776 }
777 candidates := []string{token}
778 if stripped := pathLocationSuffixRe.ReplaceAllString(token, ""); stripped != token {
779 candidates = append(candidates, stripped)
780 }
781 return candidates
782 }
783
784 func fileRefExists(path, baseDir string) bool {
785 if baseDir != "" {
786 rel, _, absBase, ok := workspaceRel(path, baseDir)
787 if !ok {
788 return false
789 }
790 root, err := os.OpenRoot(absBase)
791 if err != nil {
792 return false
793 }
794 defer root.Close()
795 info, err := root.Stat(rel)
796 return err == nil && !info.IsDir()
797 }
798 info, err := os.Stat(path)
799 return err == nil && !info.IsDir()
800 }
801
802 func workspaceRefPath(path, baseDir string) (string, bool) {
803 rel, _, absBase, ok := workspaceRel(path, baseDir)
804 if !ok {
805 return "", false
806 }
807 root, err := os.OpenRoot(absBase)
808 if err != nil {
809 return "", false
810 }
811 defer root.Close()
812 if _, err := root.Stat(rel); err != nil {
813 return "", false
814 }
815 return filepath.ToSlash(rel), true
816 }
817
818 func workspaceRel(path, baseDir string) (rel, absPath, absBase string, ok bool) {
819 absPath, absBase, ok = resolveAbsRef(path, baseDir)
820 if !ok || absBase == "" {
821 return "", "", "", false
822 }
823 rel, err := filepath.Rel(absBase, absPath)
824 if err != nil || !filepath.IsLocal(rel) {
825 return "", "", "", false
826 }
827 return rel, absPath, absBase, true
828 }
829
830 // ResolveRefs resolves the @references in a line into a single tagged context
831 // block (file/dir contents, MCP resource bodies), plus per-reference error
832 // strings for any that failed. An empty block means no references resolved.
833 // Safe to call off a frontend's event loop; honours ctx for the resource reads.
834 func (c *Controller) ResolveRefs(ctx context.Context, line string) (block string, errs []string) {
835 return c.resolveRefs(ctx, line, false)
836 }
837
838 // ResolveScopedRefs is the HTTP/frontend variant: file references are honored
839 // only when they can be resolved under the controller workspace root.
840 func (c *Controller) ResolveScopedRefs(ctx context.Context, line string) (block string, errs []string) {
841 return c.resolveRefs(ctx, line, true)
842 }
843
844 func (c *Controller) resolveRefs(ctx context.Context, line string, scopedOnly bool) (block string, errs []string) {
845 refs := c.detectRefsMode(line, scopedOnly)
846 refs = resolveBareNames(refs, c.workspaceRoot)
847 var b strings.Builder
848 includedInstructionPaths := map[string]bool{}
849 includedInstructionBodies := map[string]bool{}
850 if current := c.memory.current(); current != nil {
851 for _, doc := range current.Docs {
852 includedInstructionPaths[cleanAbsPath(doc.Path)] = true
853 includedInstructionBodies[doc.Body] = true
854 }
855 }
856 for _, r := range refs {
857 switch r.kind {
858 case refResource:
859 text, err := c.mcp.readResource(ctx, r.server, r.uri)
860 if err != nil {
861 errs = append(errs, "@"+r.raw+" — "+err.Error())
862 continue
863 }
864 appendRefBlock(&b, "resource", `ref="@`+r.raw+`"`, text)
865 case refFile:
866 baseDir := c.workspaceRoot
867 if r.baseDir != "" {
868 baseDir = r.baseDir
869 }
870 text, isDir, err := readFileRef(r.path, baseDir)
871 if err != nil {
872 errs = append(errs, "@"+r.raw+" — "+err.Error())
873 continue
874 }
875 pathInstructions, diagnostics := c.resolveReferencedInstructions(r, baseDir, includedInstructionPaths, includedInstructionBodies)
876 if pathInstructions != "" {
877 appendRefBlock(&b, "path-instructions", `target="`+html.EscapeString(displayPathForRef(r))+`"`, pathInstructions)
878 }
879 for _, diagnostic := range diagnostics {
880 errs = append(errs, "@"+r.raw+" — "+diagnostic.Message)
881 }
882 tag := "file"
883 if isDir {
884 tag = "dir"
885 }
886 displayPath := r.path
887 if r.displayPath != "" {
888 displayPath = r.displayPath
889 }
890 appendRefBlock(&b, tag, `path="`+displayPath+`"`, text)
891 case refImage:
892 appendRefBlock(&b, "image", `path="`+r.path+`"`, "[image attachment available at @"+r.path+"; sent as direct model image input only when the selected model supports vision. Text-only models can still use an available OCR/image/vision tool with this local path; image bytes are not inlined into prompt text.]")
893 }
894 }
895 return b.String(), errs
896 }
897
898 func (c *Controller) resolveReferencedInstructions(r ref, baseDir string, includedPaths, includedBodies map[string]bool) (string, []instruction.Diagnostic) {
899 mem := c.memory.current()
900 if mem == nil || strings.TrimSpace(c.workspaceRoot) == "" || r.baseDir != "" {
901 return "", nil
902 }
903 absPath, absBase, ok := resolveAbsRef(r.path, baseDir)
904 if !ok || cleanAbsPath(absBase) != cleanAbsPath(c.workspaceRoot) {
905 return "", nil
906 }
907 targetDir := absPath
908 if info, err := os.Stat(absPath); err != nil || !info.IsDir() {
909 targetDir = filepath.Dir(absPath)
910 }
911 resolved := instruction.Resolve(instruction.ResolveOptions{
912 WorkspaceRoot: c.workspaceRoot,
913 TargetDir: targetDir,
914 UserDir: mem.UserDir,
915 })
916 var delta []instruction.Document
917 for _, doc := range resolved.Documents {
918 pathKey := cleanAbsPath(doc.Path)
919 if includedPaths[pathKey] || includedBodies[doc.Body] {
920 continue
921 }
922 includedPaths[pathKey] = true
923 includedBodies[doc.Body] = true
924 delta = append(delta, doc)
925 }
926 return instruction.Block(delta), resolved.Diagnostics
927 }
928
929 func displayPathForRef(r ref) string {
930 if r.displayPath != "" {
931 return r.displayPath
932 }
933 return r.path
934 }
935
936 func cleanAbsPath(path string) string {
937 abs, err := filepath.Abs(path)
938 if err != nil {
939 return filepath.Clean(path)
940 }
941 return filepath.Clean(abs)
942 }
943
944 func appendRefBlock(b *strings.Builder, tag, attr, body string) {
945 if b.Len() > 0 {
946 b.WriteString("\n\n")
947 }
948 fmt.Fprintf(b, "<%s %s>\n%s\n</%s>", tag, attr, body, tag)
949 }
950
951 // maxDirEntries caps how many directory entries are injected so @some-huge-dir
952 // can't blow the context window.
953 const maxDirEntries = 100
954
955 const maxDirDepth = 16
956
957 func directoryRefNote() string {
958 return fmt.Sprintf("[directory listing only; file contents are not inlined. Mention a listed file path to read its content. Common generated/vendor folders are skipped. Listing is capped at %d entries and %d nested levels.]", maxDirEntries, maxDirDepth)
959 }
960
961 // readFileRef reads an @-referenced path for injection. A directory yields a
962 // recursive listing capped at maxDirEntries; a binary file (NUL in the first
963 // 8 KiB) is noted rather than dumped; a large file is truncated to
964 // maxFileRefBytes with a marker. isDir lets the caller pick the wrapping tag.
965 // When baseDir is non-empty the read is sandboxed under it via os.Root so
966 // user-supplied paths cannot escape the workspace; otherwise the path is
967 // used as-is (CLI single-workspace compatibility).
968 func readFileRef(path, baseDir string) (content string, isDir bool, err error) {
969 absPath, absBase, ok := resolveAbsRef(path, baseDir)
970 if !ok {
971 return "", false, os.ErrNotExist
972 }
973 if absBase == "" {
974 return readFileRefUnscoped(absPath)
975 }
976
977 root, rerr := os.OpenRoot(absBase)
978 if rerr != nil {
979 return "", false, rerr
980 }
981 defer root.Close()
982
983 rel, rerr := filepath.Rel(absBase, absPath)
984 if rerr != nil {
985 return "", false, rerr
986 }
987 displayPath := filepath.ToSlash(rel)
988
989 info, err := root.Stat(rel)
990 if err != nil {
991 return "", false, err
992 }
993 if info.IsDir() {
994 var b strings.Builder
995 b.WriteString(directoryRefNote())
996 b.WriteString("\n\n")
997 n := 0
998 err := walkRootDir(root, rel, rel, &b, &n, 0)
999 if n >= maxDirEntries {
1000 b.WriteString("\n…[truncated; directory has more entries]…")
1001 }
1002 if err != nil {
1003 return "", true, err
1004 }
1005 return b.String(), true, nil
1006 }
1007
1008 if strings.EqualFold(filepath.Ext(rel), ".pdf") {
1009 return readPDFRef(absPath, info.Size()), false, nil
1010 }
1011
1012 f, err := root.Open(rel)
1013 if err != nil {
1014 return "", false, err
1015 }
1016 defer f.Close()
1017
1018 buf := make([]byte, maxFileRefBytes+1)
1019 n, rerr := io.ReadFull(f, buf)
1020 if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
1021 return "", false, rerr
1022 }
1023 data := buf[:n]
1024
1025 if mime := imageMime(data, rel); mime != "" {
1026 return imageFileRefNote(displayPath, mime, info.Size(), true), false, nil
1027 }
1028 if bytes.IndexByte(data[:min(n, 8192)], 0) >= 0 {
1029 return fmt.Sprintf("[binary file %s, %d bytes — not shown]", displayPath, info.Size()), false, nil
1030 }
1031 if n > maxFileRefBytes {
1032 return string(data[:maxFileRefBytes]) + fmt.Sprintf("\n…[truncated; file is %d bytes]…", info.Size()), false, nil
1033 }
1034 return string(data), false, nil
1035 }
1036
1037 // readFileRefUnscoped is the legacy readFileRef body kept for CLI single-workspace
1038 // compatibility, where no controller-scoped sandbox is in effect.
1039 func readFileRefUnscoped(path string) (content string, isDir bool, err error) {
1040 info, err := os.Stat(path)
1041 if err != nil {
1042 return "", false, err
1043 }
1044 if info.IsDir() {
1045 var b strings.Builder
1046 b.WriteString(directoryRefNote())
1047 b.WriteString("\n\n")
1048 n := 0
1049 err := filepath.WalkDir(path, func(p string, d os.DirEntry, wErr error) error {
1050 if wErr != nil {
1051 return wErr
1052 }
1053 if n >= maxDirEntries {
1054 return filepath.SkipAll
1055 }
1056 if p == path {
1057 return nil
1058 }
1059 if skipRefDirEntry(d.Name(), d.IsDir()) {
1060 if d.IsDir() {
1061 return filepath.SkipDir
1062 }
1063 return nil
1064 }
1065 rel, rErr := filepath.Rel(path, p)
1066 if rErr != nil {
1067 rel = p
1068 }
1069 rel = strings.ReplaceAll(rel, string(os.PathSeparator), "/")
1070 if d.IsDir() {
1071 rel += "/"
1072 }
1073 b.WriteString(rel)
1074 b.WriteByte('\n')
1075 n++
1076 return nil
1077 })
1078 if n >= maxDirEntries {
1079 b.WriteString("\n…[truncated; directory has more entries]…")
1080 }
1081 if err != nil {
1082 return "", true, err
1083 }
1084 return b.String(), true, nil
1085 }
1086
1087 if strings.EqualFold(filepath.Ext(path), ".pdf") {
1088 return readPDFRef(path, info.Size()), false, nil
1089 }
1090
1091 f, err := os.Open(path)
1092 if err != nil {
1093 return "", false, err
1094 }
1095 defer f.Close()
1096
1097 buf := make([]byte, maxFileRefBytes+1)
1098 n, rerr := io.ReadFull(f, buf)
1099 if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
1100 return "", false, rerr
1101 }
1102 data := buf[:n]
1103
1104 if mime := imageMime(data, path); mime != "" {
1105 return imageFileRefNote(path, mime, info.Size(), false), false, nil
1106 }
1107 if bytes.IndexByte(data[:min(n, 8192)], 0) >= 0 {
1108 return fmt.Sprintf("[binary file %s, %d bytes — not shown]", path, info.Size()), false, nil
1109 }
1110 if n > maxFileRefBytes {
1111 return string(data[:maxFileRefBytes]) + fmt.Sprintf("\n…[truncated; file is %d bytes]…", info.Size()), false, nil
1112 }
1113 return string(data), false, nil
1114 }
1115
1116 func imageFileRefNote(displayPath, mime string, size int64, attached bool) string {
1117 if attached {
1118 return fmt.Sprintf("[image file %s, mime=%s, %d bytes — sent as direct model image input only when the selected model supports vision. Text-only models can still use an available OCR/image/vision tool with this local path; image bytes are not inlined into prompt text.]", displayPath, mime, size)
1119 }
1120 return fmt.Sprintf("[image file %s, mime=%s, %d bytes — not sent as direct model image input because no workspace root is available. Use a workspace-scoped file reference, image attachment, or an available OCR/image/vision tool with a readable local path.]", displayPath, mime, size)
1121 }
1122
1123 // walkRootDir walks a directory under a sandboxed *os.Root and writes each
1124 // entry relative to base (skipping noisy ones like .git and node_modules) into b
1125 // until n hits maxDirEntries.
1126 func walkRootDir(root *os.Root, dir, base string, b *strings.Builder, n *int, depth int) error {
1127 if depth > maxDirDepth || *n >= maxDirEntries {
1128 return nil
1129 }
1130 f, err := root.Open(dir)
1131 if err != nil {
1132 return err
1133 }
1134 entries, err := f.ReadDir(-1)
1135 f.Close()
1136 if err != nil {
1137 return err
1138 }
1139 sort.Slice(entries, func(i, j int) bool {
1140 if entries[i].IsDir() != entries[j].IsDir() {
1141 return entries[i].IsDir()
1142 }
1143 return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
1144 })
1145 for _, e := range entries {
1146 if *n >= maxDirEntries {
1147 return nil
1148 }
1149 name := e.Name()
1150 child := filepath.ToSlash(filepath.Join(dir, name))
1151 entry := name
1152 if rel, err := filepath.Rel(base, child); err == nil && filepath.IsLocal(rel) {
1153 entry = filepath.ToSlash(rel)
1154 }
1155 if skipRefDirEntry(name, e.IsDir()) {
1156 continue
1157 }
1158 if e.IsDir() {
1159 entry += "/"
1160 }
1161 b.WriteString(entry)
1162 b.WriteByte('\n')
1163 *n++
1164 if e.IsDir() {
1165 if err := walkRootDir(root, child, base, b, n, depth+1); err != nil {
1166 return err
1167 }
1168 }
1169 }
1170 return nil
1171 }
1172
1173 // resolveAbsRef resolves the user-supplied @-reference path against baseDir
1174 // and returns the absolute path plus the absolute base root to sandbox I/O
1175 // under. With a baseDir, the path is confined under it (a relative path that
1176 // escapes via ".." is rejected). With an empty baseDir, the path is returned
1177 // as-is and the caller falls back to plain os.Stat/os.Open so CLI usage
1178 // (where there is no controller-scoped workspace) keeps working.
1179 func resolveAbsRef(path, baseDir string) (absPath, absBase string, ok bool) {
1180 if baseDir == "" {
1181 return path, "", true
1182 }
1183 absBase = baseDir
1184 if !filepath.IsAbs(absBase) {
1185 var err error
1186 absBase, err = filepath.Abs(absBase)
1187 if err != nil {
1188 return "", "", false
1189 }
1190 }
1191 cleaned := filepath.Clean(path)
1192 if !filepath.IsAbs(cleaned) {
1193 cleaned = filepath.Join(absBase, cleaned)
1194 }
1195 rel, err := filepath.Rel(absBase, cleaned)
1196 if err != nil || !filepath.IsLocal(rel) {
1197 return "", "", false
1198 }
1199 return cleaned, absBase, true
1200 }
1201
1202 func readPDFRef(path string, size int64) string {
1203 result, err := extractPDFText(path)
1204 if err != nil {
1205 return fmt.Sprintf("[PDF file %s, %d bytes — text extraction unavailable: %v. If this is a scanned/image-only PDF, use OCR or an available multimodal/vision tool with this path.]", path, size, err)
1206 }
1207 text := strings.TrimSpace(result.text)
1208 if text == "" {
1209 return fmt.Sprintf("[PDF file %s, %d bytes — no extractable text found. It may be scanned/image-only; use OCR or an available multimodal/vision tool with this path.]", path, size)
1210 }
1211 var b strings.Builder
1212 fmt.Fprintf(&b, "[PDF text extracted from %s using %s", path, result.tool)
1213 if result.truncated {
1214 fmt.Fprintf(&b, "; truncated to the first %d bytes", maxFileRefBytes)
1215 }
1216 b.WriteString("]\n")
1217 b.WriteString(text)
1218 return b.String()
1219 }
1220
1221 func extractPDFTextDefault(path string) (pdfExtractResult, error) {
1222 var firstErr error
1223 if pdftotext, err := exec.LookPath("pdftotext"); err == nil {
1224 if text, truncated, err := runPDFTextCommand(pdftotext, []string{"-enc", "UTF-8", "-layout", path, "-"}); err == nil {
1225 return pdfExtractResult{text: text, tool: "pdftotext", truncated: truncated}, nil
1226 } else {
1227 firstErr = err
1228 }
1229 }
1230 python, err := findPython()
1231 if err != nil {
1232 if firstErr != nil {
1233 return pdfExtractResult{}, fmt.Errorf("pdftotext failed (%v), and Python PDF libraries are not available", firstErr)
1234 }
1235 return pdfExtractResult{}, fmt.Errorf("pdftotext and Python PDF libraries are not available")
1236 }
1237 text, truncated, err := runPDFTextCommand(python, []string{"-c", pythonPDFExtractScript, path})
1238 if err != nil {
1239 if firstErr != nil {
1240 return pdfExtractResult{}, fmt.Errorf("pdftotext failed (%v), Python PDF extraction failed (%w)", firstErr, err)
1241 }
1242 return pdfExtractResult{}, err
1243 }
1244 return pdfExtractResult{text: text, tool: "Python PDF library", truncated: truncated}, nil
1245 }
1246
1247 func findPython() (string, error) {
1248 for _, name := range []string{"python3", "python", "py"} {
1249 if p, err := exec.LookPath(name); err == nil {
1250 return p, nil
1251 }
1252 }
1253 return "", fmt.Errorf("python not found")
1254 }
1255
1256 func runPDFTextCommand(name string, args []string) (string, bool, error) {
1257 ctx, cancel := context.WithTimeout(context.Background(), pdfExtractTimeout)
1258 defer cancel()
1259 cmd := exec.CommandContext(ctx, name, args...)
1260 cmd.Env = secrets.ProcessEnv()
1261 setShellKillTree(cmd)
1262 cmd.WaitDelay = pdfExtractWaitDelay
1263 proc.HideWindow(cmd)
1264 var stdout limitedBuffer
1265 var stderr limitedBuffer
1266 cmd.Stdout = &stdout
1267 cmd.Stderr = &stderr
1268 waitErr := cmd.Run()
1269 if ctx.Err() == context.DeadlineExceeded {
1270 return "", false, fmt.Errorf("PDF text extraction timed out")
1271 }
1272 if waitErr != nil {
1273 msg := strings.TrimSpace(stderr.String())
1274 if msg != "" {
1275 if stderr.Truncated() {
1276 msg += "\n…[truncated]…"
1277 }
1278 return "", false, fmt.Errorf("%w: %s", waitErr, msg)
1279 }
1280 return "", false, waitErr
1281 }
1282 return stdout.String(), stdout.Truncated(), nil
1283 }
1284
1285 type limitedBuffer struct {
1286 buf bytes.Buffer
1287 truncated bool
1288 }
1289
1290 func (b *limitedBuffer) Write(p []byte) (int, error) {
1291 remaining := maxFileRefBytes - b.buf.Len()
1292 if remaining > 0 {
1293 if len(p) > remaining {
1294 _, _ = b.buf.Write(p[:remaining])
1295 b.truncated = true
1296 } else {
1297 _, _ = b.buf.Write(p)
1298 }
1299 } else if len(p) > 0 {
1300 b.truncated = true
1301 }
1302 return len(p), nil
1303 }
1304
1305 func (b *limitedBuffer) String() string { return b.buf.String() }
1306
1307 func (b *limitedBuffer) Truncated() bool { return b.truncated }
1308
1309 const pythonPDFExtractScript = `
1310 import sys
1311
1312 path = sys.argv[1]
1313
1314 try:
1315 from pypdf import PdfReader
1316 except Exception:
1317 try:
1318 from PyPDF2 import PdfReader
1319 except Exception:
1320 PdfReader = None
1321
1322 if PdfReader is not None:
1323 reader = PdfReader(path)
1324 for page in reader.pages:
1325 text = page.extract_text() or ""
1326 if text:
1327 print(text)
1328 sys.exit(0)
1329
1330 try:
1331 import pdfplumber
1332 except Exception as exc:
1333 raise SystemExit("no supported Python PDF library found") from exc
1334
1335 with pdfplumber.open(path) as pdf:
1336 for page in pdf.pages:
1337 text = page.extract_text() or ""
1338 if text:
1339 print(text)
1340 `
1341
1342 func imageMime(data []byte, path string) string {
1343 mime := http.DetectContentType(data[:min(len(data), 512)])
1344 if strings.HasPrefix(mime, "image/") {
1345 return mime
1346 }
1347 switch strings.ToLower(filepath.Ext(path)) {
1348 case ".png":
1349 return "image/png"
1350 case ".jpg", ".jpeg":
1351 return "image/jpeg"
1352 case ".gif":
1353 return "image/gif"
1354 case ".webp":
1355 return "image/webp"
1356 case ".tiff", ".tif":
1357 return "image/tiff"
1358 }
1359 return ""
1360 }
1361
1361 lines GO