1229 lines
35 KiB
Go
1229 lines
35 KiB
Go
package control
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/fileref"
|
|
"reasonix/internal/proc"
|
|
"reasonix/internal/secrets"
|
|
)
|
|
|
|
// maxFileRefBytes caps how much of an @-referenced file is injected into a
|
|
// message, so "@somehuge.log" can't blow the context window. The head is kept
|
|
// and the rest noted as truncated.
|
|
const maxFileRefBytes = 64 * 1024
|
|
|
|
const pdfExtractTimeout = 8 * time.Second
|
|
const pdfExtractWaitDelay = 1 * time.Second
|
|
|
|
var extractPDFText = extractPDFTextDefault
|
|
|
|
type pdfExtractResult struct {
|
|
text string
|
|
tool string
|
|
truncated bool
|
|
}
|
|
|
|
// refKind distinguishes the two things an @reference can resolve to.
|
|
type refKind int
|
|
|
|
const (
|
|
refResource refKind = iota // an MCP resource: @<server>:<uri>
|
|
refFile // a local file or directory: @<path>
|
|
refImage // a local image attachment: @.reasonix/attachments/<file>
|
|
)
|
|
|
|
// ref is a resolved @reference found in a submitted line.
|
|
type ref struct {
|
|
kind refKind
|
|
server string // refResource
|
|
uri string // refResource
|
|
path string // refFile, relative to baseDir when baseDir is set
|
|
baseDir string // refFile override for session-authorized external roots
|
|
displayPath string // refFile label/path exposed in the resolved context block
|
|
raw string // the original token after '@', for labelling
|
|
}
|
|
|
|
// ExternalFolderRefEntry is a session-authorized entry under a dropped external
|
|
// folder. Path is the opaque @ token path to submit; display fields are safe for
|
|
// UI labels and transcripts.
|
|
type ExternalFolderRefEntry struct {
|
|
Name string
|
|
Path string
|
|
DisplayName string
|
|
DisplayPath string
|
|
IsDir bool
|
|
}
|
|
|
|
// refTokenRe matches an @reference token: '@' then a run of non-space chars.
|
|
var refTokenRe = regexp.MustCompile(`@([^\s]+)`)
|
|
var pathLocationSuffixRe = regexp.MustCompile(`:\d+(?::\d+)?:?$`)
|
|
|
|
const externalFolderRefPrefix = "__reasonix_external_folder"
|
|
|
|
// parseRefTokens extracts the deduped, punctuation-trimmed tokens following '@'
|
|
// in a line. Pure: classification (server? file?) happens in classifyRef.
|
|
func parseRefTokens(line string) []string {
|
|
var toks []string
|
|
seen := map[string]bool{}
|
|
for _, g := range refTokenRe.FindAllStringSubmatch(line, -1) {
|
|
t := strings.TrimRight(g[1], ".,;!?)]}")
|
|
if t == "" || seen[t] {
|
|
continue
|
|
}
|
|
seen[t] = true
|
|
toks = append(toks, t)
|
|
}
|
|
return toks
|
|
}
|
|
|
|
// classifyRef decides what a token refers to. A "server:uri" token whose server
|
|
// is connected is an MCP resource; otherwise a token that names an existing path
|
|
// is a file. Anything else (an @mention, an email) is not a reference. exists is
|
|
// injected so the rule is testable without touching the filesystem.
|
|
func classifyRef(token string, known map[string]bool, exists func(string) bool) (ref, bool) {
|
|
if i := strings.Index(token, ":"); i > 0 && i+1 < len(token) && known[token[:i]] {
|
|
return ref{kind: refResource, server: token[:i], uri: token[i+1:], raw: token}, true
|
|
}
|
|
if isAttachmentRef(token) && exists(token) {
|
|
if isImageAttachmentRef(token) {
|
|
return ref{kind: refImage, path: token, raw: token}, true
|
|
}
|
|
return ref{kind: refFile, path: token, raw: token}, true
|
|
}
|
|
if exists(token) {
|
|
return ref{kind: refFile, path: token, raw: token}, true
|
|
}
|
|
return ref{}, false
|
|
}
|
|
|
|
func isAttachmentRef(token string) bool {
|
|
return strings.HasPrefix(filepath.ToSlash(token), ".reasonix/attachments/")
|
|
}
|
|
|
|
func isImageAttachmentRef(token string) bool {
|
|
switch strings.ToLower(filepath.Ext(token)) {
|
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".tif", ".tiff":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RegisterExternalFolderRef authorizes one dropped directory outside the
|
|
// workspace as a structured @reference for this controller session. The returned
|
|
// token is path-like and whitespace-free so it survives the existing @ token
|
|
// parser even when the real directory path contains spaces or Windows drive
|
|
// punctuation.
|
|
func (c *Controller) RegisterExternalFolderRef(path string) (token, displayPath string, err error) {
|
|
if c == nil {
|
|
return "", "", fmt.Errorf("controller is not ready")
|
|
}
|
|
abs, err := normalizeExternalFolderRoot(path)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
token = externalFolderRefToken(abs)
|
|
c.externalFolderRefsMu.Lock()
|
|
if c.externalFolderRefs == nil {
|
|
c.externalFolderRefs = map[string]string{}
|
|
}
|
|
c.externalFolderRefs[token] = abs
|
|
c.externalFolderRefsMu.Unlock()
|
|
if c.externalFolderToolRefs != nil {
|
|
c.externalFolderToolRefs.RegisterReadRoot(token, abs)
|
|
}
|
|
return token, filepath.ToSlash(abs), nil
|
|
}
|
|
|
|
func normalizeExternalFolderRoot(path string) (string, error) {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return "", os.ErrInvalid
|
|
}
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
abs = filepath.Clean(abs)
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
abs = filepath.Clean(resolved)
|
|
}
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !info.IsDir() {
|
|
return "", fmt.Errorf("%s is not a directory", path)
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
func externalFolderRefToken(abs string) string {
|
|
sum := sha256.Sum256([]byte(filepath.Clean(abs)))
|
|
hash := hex.EncodeToString(sum[:])[:12]
|
|
name := safeExternalFolderRefComponent(filepath.Base(abs))
|
|
return externalFolderRefPrefix + "/" + hash + "/" + name
|
|
}
|
|
|
|
func safeExternalFolderRefComponent(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || name == "." || name == string(filepath.Separator) {
|
|
return "folder"
|
|
}
|
|
var b strings.Builder
|
|
lastDash := false
|
|
for _, r := range name {
|
|
ok := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-'
|
|
if ok {
|
|
b.WriteRune(r)
|
|
lastDash = false
|
|
continue
|
|
}
|
|
if !lastDash {
|
|
b.WriteByte('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
out := strings.Trim(b.String(), ".-")
|
|
if out == "" {
|
|
return "folder"
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeExternalFolderRefToken(token string) string {
|
|
token = strings.TrimSpace(token)
|
|
token = strings.TrimPrefix(token, "@")
|
|
token = filepath.ToSlash(token)
|
|
token = strings.TrimRight(token, "/")
|
|
return token
|
|
}
|
|
|
|
func (c *Controller) externalFolderRef(token string) (ref, bool) {
|
|
_, rel, abs, ok := c.externalFolderRefTarget(token)
|
|
if !ok {
|
|
return ref{}, false
|
|
}
|
|
displayPath := externalFolderDisplayPath(abs, rel)
|
|
return ref{kind: refFile, path: rel, baseDir: abs, displayPath: displayPath, raw: token}, true
|
|
}
|
|
|
|
func (c *Controller) externalFolderRefTarget(token string) (rootToken, rel, abs string, ok bool) {
|
|
key := normalizeExternalFolderRefToken(token)
|
|
if !strings.HasPrefix(key, externalFolderRefPrefix+"/") {
|
|
return "", "", "", false
|
|
}
|
|
c.externalFolderRefsMu.RLock()
|
|
defer c.externalFolderRefsMu.RUnlock()
|
|
if abs, ok := c.externalFolderRefs[key]; ok {
|
|
return key, ".", abs, true
|
|
}
|
|
for registered, abs := range c.externalFolderRefs {
|
|
if !strings.HasPrefix(key, registered+"/") {
|
|
continue
|
|
}
|
|
sub, ok := cleanExternalFolderSubpath(strings.TrimPrefix(key, registered+"/"))
|
|
if !ok {
|
|
return "", "", "", false
|
|
}
|
|
return registered, sub, abs, true
|
|
}
|
|
return "", "", "", false
|
|
}
|
|
|
|
func cleanExternalFolderSubpath(sub string) (string, bool) {
|
|
sub = strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(sub)), "/")
|
|
if sub == "" || sub == "." {
|
|
return ".", true
|
|
}
|
|
cleaned := filepath.Clean(filepath.FromSlash(sub))
|
|
if cleaned == "." {
|
|
return ".", true
|
|
}
|
|
if !filepath.IsLocal(cleaned) {
|
|
return "", false
|
|
}
|
|
return filepath.ToSlash(cleaned), true
|
|
}
|
|
|
|
func externalFolderDisplayPath(abs, rel string) string {
|
|
if rel == "" || rel == "." {
|
|
return filepath.ToSlash(abs)
|
|
}
|
|
return filepath.ToSlash(filepath.Join(abs, filepath.FromSlash(rel)))
|
|
}
|
|
|
|
func externalFolderDisplayName(abs, rel string) string {
|
|
name := filepath.Base(abs)
|
|
if rel != "" && rel != "." {
|
|
name = filepath.ToSlash(filepath.Join(name, filepath.FromSlash(rel)))
|
|
}
|
|
return name
|
|
}
|
|
|
|
// ListExternalFolderRefDir lists one directory level under a registered
|
|
// external folder token. handled is true only when tokenPath targets a
|
|
// registered external folder; callers can fall back to workspace listing when it
|
|
// is false.
|
|
func (c *Controller) ListExternalFolderRefDir(tokenPath string) (entries []ExternalFolderRefEntry, handled bool) {
|
|
rootToken, rel, abs, ok := c.externalFolderRefTarget(tokenPath)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
root, err := os.OpenRoot(abs)
|
|
if err != nil {
|
|
return nil, true
|
|
}
|
|
defer root.Close()
|
|
info, err := root.Stat(rel)
|
|
if err != nil || !info.IsDir() {
|
|
return nil, true
|
|
}
|
|
f, err := root.Open(rel)
|
|
if err != nil {
|
|
return nil, true
|
|
}
|
|
dirEntries, err := f.ReadDir(-1)
|
|
f.Close()
|
|
if err != nil {
|
|
return nil, true
|
|
}
|
|
dirs, files := []ExternalFolderRefEntry{}, []ExternalFolderRefEntry{}
|
|
for _, e := range dirEntries {
|
|
name := e.Name()
|
|
if skipRefDirEntry(name, e.IsDir()) {
|
|
continue
|
|
}
|
|
childRel := name
|
|
if rel != "." {
|
|
childRel = filepath.ToSlash(filepath.Join(rel, name))
|
|
}
|
|
item := ExternalFolderRefEntry{
|
|
Name: name,
|
|
Path: rootToken + "/" + childRel,
|
|
DisplayName: name,
|
|
DisplayPath: externalFolderDisplayPath(abs, childRel),
|
|
IsDir: e.IsDir(),
|
|
}
|
|
if e.IsDir() {
|
|
dirs = append(dirs, item)
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
continue
|
|
}
|
|
files = append(files, item)
|
|
}
|
|
sortExternalFolderRefEntries(dirs)
|
|
sortExternalFolderRefEntries(files)
|
|
return append(dirs, files...), true
|
|
}
|
|
|
|
// SearchExternalFolderRefs finds entries under all registered external folders.
|
|
// Returned Path values are opaque token paths, so selecting one stays within the
|
|
// current session's authorization boundary.
|
|
func (c *Controller) SearchExternalFolderRefs(query string, limit int) []ExternalFolderRefEntry {
|
|
query = strings.TrimSpace(query)
|
|
if limit <= 0 || len(query) < 2 || strings.ContainsAny(query, `/\`) {
|
|
return nil
|
|
}
|
|
c.externalFolderRefsMu.RLock()
|
|
roots := make([]struct {
|
|
token string
|
|
abs string
|
|
}, 0, len(c.externalFolderRefs))
|
|
for token, abs := range c.externalFolderRefs {
|
|
roots = append(roots, struct {
|
|
token string
|
|
abs string
|
|
}{token: token, abs: abs})
|
|
}
|
|
c.externalFolderRefsMu.RUnlock()
|
|
sort.Slice(roots, func(i, j int) bool {
|
|
return externalFolderDisplayPath(roots[i].abs, ".") < externalFolderDisplayPath(roots[j].abs, ".")
|
|
})
|
|
out := make([]ExternalFolderRefEntry, 0, limit)
|
|
queryLower := strings.ToLower(query)
|
|
for _, root := range roots {
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
if info, err := os.Stat(root.abs); err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
if strings.Contains(strings.ToLower(filepath.Base(root.abs)), queryLower) {
|
|
out = append(out, ExternalFolderRefEntry{
|
|
Name: filepath.Base(root.abs),
|
|
Path: root.token,
|
|
DisplayName: externalFolderDisplayName(root.abs, "."),
|
|
DisplayPath: externalFolderDisplayPath(root.abs, "."),
|
|
IsDir: true,
|
|
})
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
for _, result := range fileref.Search(root.abs, query, limit-len(out)) {
|
|
rel := filepath.ToSlash(result.Path)
|
|
out = append(out, ExternalFolderRefEntry{
|
|
Name: rel,
|
|
Path: root.token + "/" + rel,
|
|
DisplayName: externalFolderDisplayName(root.abs, rel),
|
|
DisplayPath: externalFolderDisplayPath(root.abs, rel),
|
|
IsDir: result.IsDir,
|
|
})
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ExternalFolderRefLocalPath resolves a registered external-folder token path to
|
|
// the local filesystem path authorized for this controller session.
|
|
func (c *Controller) ExternalFolderRefLocalPath(tokenPath string) (path, displayPath string, ok bool) {
|
|
_, rel, abs, ok := c.externalFolderRefTarget(tokenPath)
|
|
if !ok {
|
|
return "", "", false
|
|
}
|
|
return filepath.Join(abs, filepath.FromSlash(rel)), externalFolderDisplayPath(abs, rel), true
|
|
}
|
|
|
|
func sortExternalFolderRefEntries(entries []ExternalFolderRefEntry) {
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return strings.ToLower(entries[i].DisplayName) < strings.ToLower(entries[j].DisplayName)
|
|
})
|
|
}
|
|
|
|
func skipRefDirEntry(name string, isDir bool) bool {
|
|
switch name {
|
|
case ".DS_Store", "Thumbs.db":
|
|
return true
|
|
}
|
|
if !isDir {
|
|
return false
|
|
}
|
|
switch name {
|
|
case ".codex", ".git", ".idea", ".npm", ".pnpm-store", ".vscode", "__pycache__", "build", "dist", "node_modules":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// detectRefs finds the @references in a line: MCP resources for connected
|
|
// servers, and local paths that exist on disk.
|
|
func (c *Controller) detectRefs(line string) []ref {
|
|
return c.detectRefsMode(line, false)
|
|
}
|
|
|
|
func (c *Controller) detectRefsMode(line string, scopedOnly bool) []ref {
|
|
known := map[string]bool{}
|
|
for _, n := range c.mcp.serverNames() {
|
|
known[n] = true
|
|
}
|
|
|
|
var refs []ref
|
|
for _, tok := range parseRefTokens(line) {
|
|
if i := strings.Index(tok, ":"); i > 0 && i+1 < len(tok) && known[tok[:i]] {
|
|
refs = append(refs, ref{kind: refResource, server: tok[:i], uri: tok[i+1:], raw: tok})
|
|
continue
|
|
}
|
|
if r, ok := c.externalFolderRef(tok); ok {
|
|
refs = append(refs, r)
|
|
continue
|
|
}
|
|
if c.workspaceRoot != "" {
|
|
if rel, ok := workspaceRefPath(tok, c.workspaceRoot); ok {
|
|
kind := refFile
|
|
if isAttachmentRef(rel) && isImageAttachmentRef(rel) {
|
|
kind = refImage
|
|
}
|
|
refs = append(refs, ref{kind: kind, path: rel, raw: tok})
|
|
}
|
|
continue
|
|
}
|
|
if scopedOnly {
|
|
continue
|
|
}
|
|
if r, ok := classifyRef(tok, known, func(p string) bool {
|
|
_, err := os.Stat(p)
|
|
return err == nil
|
|
}); ok {
|
|
refs = append(refs, r)
|
|
}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
// HasRefs reports whether a line contains any resolvable @references, so a
|
|
// frontend can decide to resolve off its event loop only when needed.
|
|
func (c *Controller) HasRefs(line string) bool {
|
|
return len(c.detectRefs(line)) > 0
|
|
}
|
|
|
|
// inputImages resolves image @-references in the turn input to data URLs so the
|
|
// turn can carry them to a vision-capable model. Best-effort: an unreadable image
|
|
// is skipped — the @ref still lands as text via ResolveRefs.
|
|
func (c *Controller) inputImages(line string) []string {
|
|
if !c.imageInputEnabled() {
|
|
return nil
|
|
}
|
|
var urls []string
|
|
for _, r := range c.detectRefs(line) {
|
|
baseDir := c.workspaceRoot
|
|
if r.baseDir != "" {
|
|
baseDir = r.baseDir
|
|
}
|
|
if url, err := visionRefImageDataURL(r, baseDir); err == nil {
|
|
urls = append(urls, url)
|
|
}
|
|
}
|
|
return urls
|
|
}
|
|
|
|
func visionRefImageDataURL(r ref, baseDir string) (string, error) {
|
|
switch r.kind {
|
|
case refImage:
|
|
return visionImageDataURL(r.path)
|
|
case refFile:
|
|
return visionFileImageDataURL(r.path, baseDir)
|
|
default:
|
|
return "", fmt.Errorf("reference is not an image")
|
|
}
|
|
}
|
|
|
|
func visionFileImageDataURL(path, baseDir string) (string, error) {
|
|
absPath, absBase, ok := resolveAbsRef(path, baseDir)
|
|
if !ok {
|
|
return "", os.ErrNotExist
|
|
}
|
|
if absBase == "" {
|
|
return "", fmt.Errorf("workspace root is required for file image references")
|
|
}
|
|
|
|
root, err := os.OpenRoot(absBase)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer root.Close()
|
|
|
|
rel, err := filepath.Rel(absBase, absPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
info, err := root.Lstat(rel)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", fmt.Errorf("image path must not be a symlink")
|
|
}
|
|
if info.IsDir() || info.Size() <= 0 || info.Size() > maxImageAttachmentBytes {
|
|
return "", fmt.Errorf("image must be between 1 byte and 10 MB")
|
|
}
|
|
f, err := root.Open(rel)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
opened, err := f.Stat()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !os.SameFile(info, opened) {
|
|
return "", fmt.Errorf("image changed while opening")
|
|
}
|
|
return dataURLFromImageReader(f, path)
|
|
}
|
|
|
|
func dataURLFromImageReader(r io.Reader, path string) (string, error) {
|
|
raw, err := io.ReadAll(io.LimitReader(r, maxImageAttachmentBytes+1))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(raw) == 0 || len(raw) > maxImageAttachmentBytes {
|
|
return "", fmt.Errorf("image must be between 1 byte and 10 MB")
|
|
}
|
|
mime := detectedImageMime(raw)
|
|
if mime == "" {
|
|
return "", fmt.Errorf("%s is not a supported image", path)
|
|
}
|
|
raw, mime = compressForVision(raw, mime)
|
|
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(raw), nil
|
|
}
|
|
|
|
// resolveBareNames batch-resolves simple filenames (no path separator) that
|
|
// don't exist in cwd. It walks the working tree once and matches every
|
|
// unresolved name against the set, stopping when all are found. This runs in
|
|
// the async ResolveRefs path, never on the TUI event loop.
|
|
func resolveBareNames(refs []ref, workspaceRoot string) []ref {
|
|
need := map[string]*ref{}
|
|
var names []string
|
|
for i := range refs {
|
|
r := &refs[i]
|
|
if r.kind != refFile || r.path != "" || !isSafeBareRefName(r.raw) {
|
|
continue
|
|
}
|
|
if workspaceRoot != "" {
|
|
if rel, ok := workspaceRefPath(r.raw, workspaceRoot); ok {
|
|
r.path = rel
|
|
continue
|
|
}
|
|
}
|
|
need[r.raw] = r
|
|
names = append(names, r.raw)
|
|
}
|
|
if len(names) == 0 {
|
|
return refs
|
|
}
|
|
found := 0
|
|
cwd := workspaceRoot
|
|
if cwd == "" {
|
|
cwd, _ = os.Getwd()
|
|
}
|
|
_ = filepath.WalkDir(cwd, func(p string, d os.DirEntry, wErr error) error {
|
|
if wErr != nil || found == len(names) {
|
|
return filepath.SkipAll
|
|
}
|
|
if d.IsDir() {
|
|
switch d.Name() {
|
|
case ".git", "node_modules", ".DS_Store", "__pycache__", ".idea", ".vscode":
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if r, ok := need[d.Name()]; ok {
|
|
rel, _ := filepath.Rel(cwd, p)
|
|
r.path = filepath.ToSlash(rel)
|
|
delete(need, d.Name())
|
|
found++
|
|
}
|
|
return nil
|
|
})
|
|
return refs
|
|
}
|
|
|
|
func isSafeBareRefName(name string) bool {
|
|
if name == "" || name == "." || name == ".." {
|
|
return false
|
|
}
|
|
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
|
|
return false
|
|
}
|
|
return filepath.Base(name) == name && filepath.IsLocal(name)
|
|
}
|
|
|
|
// FileRefLine reports whether a submitted line is nothing but a path to an
|
|
// existing file — a dragged or pasted file lands as its bare path, which on
|
|
// POSIX starts with '/' and would otherwise be misread as a slash command. The
|
|
// returned string is that path turned into an @reference so it attaches.
|
|
func FileRefLine(line string) (string, bool) {
|
|
p := strings.Trim(strings.TrimSpace(line), `"'`)
|
|
if p == "" {
|
|
return "", false
|
|
}
|
|
if info, err := os.Stat(p); err != nil || info.IsDir() {
|
|
return "", false
|
|
}
|
|
return "@" + p, true
|
|
}
|
|
|
|
// SlashCodeCommentLine reports whether a slash-prefixed line is ordinary source
|
|
// text rather than a Reasonix slash command.
|
|
func SlashCodeCommentLine(line string) bool {
|
|
trimmed := strings.TrimSpace(line)
|
|
return strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*")
|
|
}
|
|
|
|
// SlashPathLineRef reports whether a slash-prefixed line starts with a local file
|
|
// path, including common compiler-location suffixes like ":12" or ":12:34".
|
|
// It returns an @reference for the file so diagnostics that begin with an
|
|
// absolute path can keep their original text while also attaching file context.
|
|
func SlashPathLineRef(line, baseDir string) (string, bool) {
|
|
token, ok := leadingSlashPathToken(line)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
for _, p := range pathTokenCandidates(token) {
|
|
if fileRefExists(p, baseDir) {
|
|
return "@" + p, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// SlashPathLikeLine reports whether a slash-prefixed line looks like a POSIX
|
|
// absolute path rather than a slash command. It intentionally stays conservative:
|
|
// unknown "/foo" remains an unknown command, while "/foo/bar..." is sent as
|
|
// ordinary prompt text even if the path no longer exists.
|
|
func SlashPathLikeLine(line string) bool {
|
|
token, ok := leadingSlashPathToken(line)
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, p := range pathTokenCandidates(token) {
|
|
if strings.Contains(p[1:], "/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func leadingSlashPathToken(line string) (string, bool) {
|
|
fields := strings.Fields(strings.TrimSpace(line))
|
|
if len(fields) == 0 {
|
|
return "", false
|
|
}
|
|
token := strings.Trim(fields[0], `"'`)
|
|
if !strings.HasPrefix(token, "/") || strings.HasPrefix(token, "//") {
|
|
return "", false
|
|
}
|
|
return token, true
|
|
}
|
|
|
|
func pathTokenCandidates(token string) []string {
|
|
token = strings.TrimRight(strings.Trim(token, `"'`), ".,;!?)]}")
|
|
if token == "" {
|
|
return nil
|
|
}
|
|
candidates := []string{token}
|
|
if stripped := pathLocationSuffixRe.ReplaceAllString(token, ""); stripped != token {
|
|
candidates = append(candidates, stripped)
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func fileRefExists(path, baseDir string) bool {
|
|
if baseDir != "" {
|
|
rel, _, absBase, ok := workspaceRel(path, baseDir)
|
|
if !ok {
|
|
return false
|
|
}
|
|
root, err := os.OpenRoot(absBase)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer root.Close()
|
|
info, err := root.Stat(rel)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
func workspaceRefPath(path, baseDir string) (string, bool) {
|
|
rel, _, absBase, ok := workspaceRel(path, baseDir)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
root, err := os.OpenRoot(absBase)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
defer root.Close()
|
|
if _, err := root.Stat(rel); err != nil {
|
|
return "", false
|
|
}
|
|
return filepath.ToSlash(rel), true
|
|
}
|
|
|
|
func workspaceRel(path, baseDir string) (rel, absPath, absBase string, ok bool) {
|
|
absPath, absBase, ok = resolveAbsRef(path, baseDir)
|
|
if !ok || absBase == "" {
|
|
return "", "", "", false
|
|
}
|
|
rel, err := filepath.Rel(absBase, absPath)
|
|
if err != nil || !filepath.IsLocal(rel) {
|
|
return "", "", "", false
|
|
}
|
|
return rel, absPath, absBase, true
|
|
}
|
|
|
|
// ResolveRefs resolves the @references in a line into a single tagged context
|
|
// block (file/dir contents, MCP resource bodies), plus per-reference error
|
|
// strings for any that failed. An empty block means no references resolved.
|
|
// Safe to call off a frontend's event loop; honours ctx for the resource reads.
|
|
func (c *Controller) ResolveRefs(ctx context.Context, line string) (block string, errs []string) {
|
|
return c.resolveRefs(ctx, line, false)
|
|
}
|
|
|
|
// ResolveScopedRefs is the HTTP/frontend variant: file references are honored
|
|
// only when they can be resolved under the controller workspace root.
|
|
func (c *Controller) ResolveScopedRefs(ctx context.Context, line string) (block string, errs []string) {
|
|
return c.resolveRefs(ctx, line, true)
|
|
}
|
|
|
|
func (c *Controller) resolveRefs(ctx context.Context, line string, scopedOnly bool) (block string, errs []string) {
|
|
refs := c.detectRefsMode(line, scopedOnly)
|
|
refs = resolveBareNames(refs, c.workspaceRoot)
|
|
var b strings.Builder
|
|
for _, r := range refs {
|
|
switch r.kind {
|
|
case refResource:
|
|
text, err := c.mcp.readResource(ctx, r.server, r.uri)
|
|
if err != nil {
|
|
errs = append(errs, "@"+r.raw+" — "+err.Error())
|
|
continue
|
|
}
|
|
appendRefBlock(&b, "resource", `ref="@`+r.raw+`"`, text)
|
|
case refFile:
|
|
baseDir := c.workspaceRoot
|
|
if r.baseDir != "" {
|
|
baseDir = r.baseDir
|
|
}
|
|
text, isDir, err := readFileRef(r.path, baseDir)
|
|
if err != nil {
|
|
errs = append(errs, "@"+r.raw+" — "+err.Error())
|
|
continue
|
|
}
|
|
tag := "file"
|
|
if isDir {
|
|
tag = "dir"
|
|
}
|
|
displayPath := r.path
|
|
if r.displayPath != "" {
|
|
displayPath = r.displayPath
|
|
}
|
|
appendRefBlock(&b, tag, `path="`+displayPath+`"`, text)
|
|
case refImage:
|
|
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.]")
|
|
}
|
|
}
|
|
return b.String(), errs
|
|
}
|
|
|
|
func appendRefBlock(b *strings.Builder, tag, attr, body string) {
|
|
if b.Len() > 0 {
|
|
b.WriteString("\n\n")
|
|
}
|
|
fmt.Fprintf(b, "<%s %s>\n%s\n</%s>", tag, attr, body, tag)
|
|
}
|
|
|
|
// maxDirEntries caps how many directory entries are injected so @some-huge-dir
|
|
// can't blow the context window.
|
|
const maxDirEntries = 100
|
|
|
|
const maxDirDepth = 16
|
|
|
|
func directoryRefNote() string {
|
|
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)
|
|
}
|
|
|
|
// readFileRef reads an @-referenced path for injection. A directory yields a
|
|
// recursive listing capped at maxDirEntries; a binary file (NUL in the first
|
|
// 8 KiB) is noted rather than dumped; a large file is truncated to
|
|
// maxFileRefBytes with a marker. isDir lets the caller pick the wrapping tag.
|
|
// When baseDir is non-empty the read is sandboxed under it via os.Root so
|
|
// user-supplied paths cannot escape the workspace; otherwise the path is
|
|
// used as-is (CLI single-workspace compatibility).
|
|
func readFileRef(path, baseDir string) (content string, isDir bool, err error) {
|
|
absPath, absBase, ok := resolveAbsRef(path, baseDir)
|
|
if !ok {
|
|
return "", false, os.ErrNotExist
|
|
}
|
|
if absBase == "" {
|
|
return readFileRefUnscoped(absPath)
|
|
}
|
|
|
|
root, rerr := os.OpenRoot(absBase)
|
|
if rerr != nil {
|
|
return "", false, rerr
|
|
}
|
|
defer root.Close()
|
|
|
|
rel, rerr := filepath.Rel(absBase, absPath)
|
|
if rerr != nil {
|
|
return "", false, rerr
|
|
}
|
|
displayPath := filepath.ToSlash(rel)
|
|
|
|
info, err := root.Stat(rel)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if info.IsDir() {
|
|
var b strings.Builder
|
|
b.WriteString(directoryRefNote())
|
|
b.WriteString("\n\n")
|
|
n := 0
|
|
err := walkRootDir(root, rel, rel, &b, &n, 0)
|
|
if n >= maxDirEntries {
|
|
b.WriteString("\n…[truncated; directory has more entries]…")
|
|
}
|
|
if err != nil {
|
|
return "", true, err
|
|
}
|
|
return b.String(), true, nil
|
|
}
|
|
|
|
if strings.EqualFold(filepath.Ext(rel), ".pdf") {
|
|
return readPDFRef(absPath, info.Size()), false, nil
|
|
}
|
|
|
|
f, err := root.Open(rel)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
defer f.Close()
|
|
|
|
buf := make([]byte, maxFileRefBytes+1)
|
|
n, rerr := io.ReadFull(f, buf)
|
|
if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
|
|
return "", false, rerr
|
|
}
|
|
data := buf[:n]
|
|
|
|
if mime := imageMime(data, rel); mime != "" {
|
|
return imageFileRefNote(displayPath, mime, info.Size(), true), false, nil
|
|
}
|
|
if bytes.IndexByte(data[:min(n, 8192)], 0) >= 0 {
|
|
return fmt.Sprintf("[binary file %s, %d bytes — not shown]", displayPath, info.Size()), false, nil
|
|
}
|
|
if n > maxFileRefBytes {
|
|
return string(data[:maxFileRefBytes]) + fmt.Sprintf("\n…[truncated; file is %d bytes]…", info.Size()), false, nil
|
|
}
|
|
return string(data), false, nil
|
|
}
|
|
|
|
// readFileRefUnscoped is the legacy readFileRef body kept for CLI single-workspace
|
|
// compatibility, where no controller-scoped sandbox is in effect.
|
|
func readFileRefUnscoped(path string) (content string, isDir bool, err error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if info.IsDir() {
|
|
var b strings.Builder
|
|
b.WriteString(directoryRefNote())
|
|
b.WriteString("\n\n")
|
|
n := 0
|
|
err := filepath.WalkDir(path, func(p string, d os.DirEntry, wErr error) error {
|
|
if wErr != nil {
|
|
return wErr
|
|
}
|
|
if n >= maxDirEntries {
|
|
return filepath.SkipAll
|
|
}
|
|
if p == path {
|
|
return nil
|
|
}
|
|
if skipRefDirEntry(d.Name(), d.IsDir()) {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
rel, rErr := filepath.Rel(path, p)
|
|
if rErr != nil {
|
|
rel = p
|
|
}
|
|
rel = strings.ReplaceAll(rel, string(os.PathSeparator), "/")
|
|
if d.IsDir() {
|
|
rel += "/"
|
|
}
|
|
b.WriteString(rel)
|
|
b.WriteByte('\n')
|
|
n++
|
|
return nil
|
|
})
|
|
if n >= maxDirEntries {
|
|
b.WriteString("\n…[truncated; directory has more entries]…")
|
|
}
|
|
if err != nil {
|
|
return "", true, err
|
|
}
|
|
return b.String(), true, nil
|
|
}
|
|
|
|
if strings.EqualFold(filepath.Ext(path), ".pdf") {
|
|
return readPDFRef(path, info.Size()), false, nil
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
defer f.Close()
|
|
|
|
buf := make([]byte, maxFileRefBytes+1)
|
|
n, rerr := io.ReadFull(f, buf)
|
|
if rerr != nil && rerr != io.ErrUnexpectedEOF && rerr != io.EOF {
|
|
return "", false, rerr
|
|
}
|
|
data := buf[:n]
|
|
|
|
if mime := imageMime(data, path); mime != "" {
|
|
return imageFileRefNote(path, mime, info.Size(), false), false, nil
|
|
}
|
|
if bytes.IndexByte(data[:min(n, 8192)], 0) >= 0 {
|
|
return fmt.Sprintf("[binary file %s, %d bytes — not shown]", path, info.Size()), false, nil
|
|
}
|
|
if n > maxFileRefBytes {
|
|
return string(data[:maxFileRefBytes]) + fmt.Sprintf("\n…[truncated; file is %d bytes]…", info.Size()), false, nil
|
|
}
|
|
return string(data), false, nil
|
|
}
|
|
|
|
func imageFileRefNote(displayPath, mime string, size int64, attached bool) string {
|
|
if attached {
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// walkRootDir walks a directory under a sandboxed *os.Root and writes each
|
|
// entry relative to base (skipping noisy ones like .git and node_modules) into b
|
|
// until n hits maxDirEntries.
|
|
func walkRootDir(root *os.Root, dir, base string, b *strings.Builder, n *int, depth int) error {
|
|
if depth > maxDirDepth || *n >= maxDirEntries {
|
|
return nil
|
|
}
|
|
f, err := root.Open(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries, err := f.ReadDir(-1)
|
|
f.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].IsDir() != entries[j].IsDir() {
|
|
return entries[i].IsDir()
|
|
}
|
|
return strings.ToLower(entries[i].Name()) < strings.ToLower(entries[j].Name())
|
|
})
|
|
for _, e := range entries {
|
|
if *n >= maxDirEntries {
|
|
return nil
|
|
}
|
|
name := e.Name()
|
|
child := filepath.ToSlash(filepath.Join(dir, name))
|
|
entry := name
|
|
if rel, err := filepath.Rel(base, child); err == nil && filepath.IsLocal(rel) {
|
|
entry = filepath.ToSlash(rel)
|
|
}
|
|
if skipRefDirEntry(name, e.IsDir()) {
|
|
continue
|
|
}
|
|
if e.IsDir() {
|
|
entry += "/"
|
|
}
|
|
b.WriteString(entry)
|
|
b.WriteByte('\n')
|
|
*n++
|
|
if e.IsDir() {
|
|
if err := walkRootDir(root, child, base, b, n, depth+1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// resolveAbsRef resolves the user-supplied @-reference path against baseDir
|
|
// and returns the absolute path plus the absolute base root to sandbox I/O
|
|
// under. With a baseDir, the path is confined under it (a relative path that
|
|
// escapes via ".." is rejected). With an empty baseDir, the path is returned
|
|
// as-is and the caller falls back to plain os.Stat/os.Open so CLI usage
|
|
// (where there is no controller-scoped workspace) keeps working.
|
|
func resolveAbsRef(path, baseDir string) (absPath, absBase string, ok bool) {
|
|
if baseDir == "" {
|
|
return path, "", true
|
|
}
|
|
absBase = baseDir
|
|
if !filepath.IsAbs(absBase) {
|
|
var err error
|
|
absBase, err = filepath.Abs(absBase)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
}
|
|
cleaned := filepath.Clean(path)
|
|
if !filepath.IsAbs(cleaned) {
|
|
cleaned = filepath.Join(absBase, cleaned)
|
|
}
|
|
rel, err := filepath.Rel(absBase, cleaned)
|
|
if err != nil || !filepath.IsLocal(rel) {
|
|
return "", "", false
|
|
}
|
|
return cleaned, absBase, true
|
|
}
|
|
|
|
func readPDFRef(path string, size int64) string {
|
|
result, err := extractPDFText(path)
|
|
if err != nil {
|
|
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)
|
|
}
|
|
text := strings.TrimSpace(result.text)
|
|
if text == "" {
|
|
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)
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "[PDF text extracted from %s using %s", path, result.tool)
|
|
if result.truncated {
|
|
fmt.Fprintf(&b, "; truncated to the first %d bytes", maxFileRefBytes)
|
|
}
|
|
b.WriteString("]\n")
|
|
b.WriteString(text)
|
|
return b.String()
|
|
}
|
|
|
|
func extractPDFTextDefault(path string) (pdfExtractResult, error) {
|
|
var firstErr error
|
|
if pdftotext, err := exec.LookPath("pdftotext"); err == nil {
|
|
if text, truncated, err := runPDFTextCommand(pdftotext, []string{"-enc", "UTF-8", "-layout", path, "-"}); err == nil {
|
|
return pdfExtractResult{text: text, tool: "pdftotext", truncated: truncated}, nil
|
|
} else {
|
|
firstErr = err
|
|
}
|
|
}
|
|
python, err := findPython()
|
|
if err != nil {
|
|
if firstErr != nil {
|
|
return pdfExtractResult{}, fmt.Errorf("pdftotext failed (%v), and Python PDF libraries are not available", firstErr)
|
|
}
|
|
return pdfExtractResult{}, fmt.Errorf("pdftotext and Python PDF libraries are not available")
|
|
}
|
|
text, truncated, err := runPDFTextCommand(python, []string{"-c", pythonPDFExtractScript, path})
|
|
if err != nil {
|
|
if firstErr != nil {
|
|
return pdfExtractResult{}, fmt.Errorf("pdftotext failed (%v), Python PDF extraction failed (%w)", firstErr, err)
|
|
}
|
|
return pdfExtractResult{}, err
|
|
}
|
|
return pdfExtractResult{text: text, tool: "Python PDF library", truncated: truncated}, nil
|
|
}
|
|
|
|
func findPython() (string, error) {
|
|
for _, name := range []string{"python3", "python", "py"} {
|
|
if p, err := exec.LookPath(name); err == nil {
|
|
return p, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("python not found")
|
|
}
|
|
|
|
func runPDFTextCommand(name string, args []string) (string, bool, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), pdfExtractTimeout)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
cmd.Env = secrets.ProcessEnv()
|
|
setShellKillTree(cmd)
|
|
cmd.WaitDelay = pdfExtractWaitDelay
|
|
proc.HideWindow(cmd)
|
|
var stdout limitedBuffer
|
|
var stderr limitedBuffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
waitErr := cmd.Run()
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return "", false, fmt.Errorf("PDF text extraction timed out")
|
|
}
|
|
if waitErr != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg != "" {
|
|
if stderr.Truncated() {
|
|
msg += "\n…[truncated]…"
|
|
}
|
|
return "", false, fmt.Errorf("%w: %s", waitErr, msg)
|
|
}
|
|
return "", false, waitErr
|
|
}
|
|
return stdout.String(), stdout.Truncated(), nil
|
|
}
|
|
|
|
type limitedBuffer struct {
|
|
buf bytes.Buffer
|
|
truncated bool
|
|
}
|
|
|
|
func (b *limitedBuffer) Write(p []byte) (int, error) {
|
|
remaining := maxFileRefBytes - b.buf.Len()
|
|
if remaining > 0 {
|
|
if len(p) > remaining {
|
|
_, _ = b.buf.Write(p[:remaining])
|
|
b.truncated = true
|
|
} else {
|
|
_, _ = b.buf.Write(p)
|
|
}
|
|
} else if len(p) > 0 {
|
|
b.truncated = true
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (b *limitedBuffer) String() string { return b.buf.String() }
|
|
|
|
func (b *limitedBuffer) Truncated() bool { return b.truncated }
|
|
|
|
const pythonPDFExtractScript = `
|
|
import sys
|
|
|
|
path = sys.argv[1]
|
|
|
|
try:
|
|
from pypdf import PdfReader
|
|
except Exception:
|
|
try:
|
|
from PyPDF2 import PdfReader
|
|
except Exception:
|
|
PdfReader = None
|
|
|
|
if PdfReader is not None:
|
|
reader = PdfReader(path)
|
|
for page in reader.pages:
|
|
text = page.extract_text() or ""
|
|
if text:
|
|
print(text)
|
|
sys.exit(0)
|
|
|
|
try:
|
|
import pdfplumber
|
|
except Exception as exc:
|
|
raise SystemExit("no supported Python PDF library found") from exc
|
|
|
|
with pdfplumber.open(path) as pdf:
|
|
for page in pdf.pages:
|
|
text = page.extract_text() or ""
|
|
if text:
|
|
print(text)
|
|
`
|
|
|
|
func imageMime(data []byte, path string) string {
|
|
mime := http.DetectContentType(data[:min(len(data), 512)])
|
|
if strings.HasPrefix(mime, "image/") {
|
|
return mime
|
|
}
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".png":
|
|
return "image/png"
|
|
case ".jpg", ".jpeg":
|
|
return "image/jpeg"
|
|
case ".gif":
|
|
return "image/gif"
|
|
case ".webp":
|
|
return "image/webp"
|
|
case ".tiff", ".tif":
|
|
return "image/tiff"
|
|
}
|
|
return ""
|
|
}
|