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