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: @: refFile // a local file or directory: @ refImage // a local image attachment: @.reasonix/attachments/ ) // 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", 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 "" }